diff options
| -rw-r--r-- | ext/bg/js/backend.js | 40 | ||||
| -rw-r--r-- | ext/mixed/js/comm.js | 282 | 
2 files changed, 322 insertions, 0 deletions
| diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 20d31efc..8df4fd9d 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -163,6 +163,7 @@ class Backend {                  chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this));              }              chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); +            chrome.runtime.onConnect.addListener(this._onConnect.bind(this));              const options = this.getOptions(this.optionsContext);              if (options.general.showGuide) { @@ -236,6 +237,45 @@ class Backend {          }      } +    _onConnect(port) { +        try { +            const match = /^background-cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); +            if (match === null) { return; } + +            const tabId = (port.sender && port.sender.tab ? port.sender.tab.id : null); +            if (typeof tabId !== 'number') { +                throw new Error('Port does not have an associated tab ID'); +            } +            const senderFrameId = port.sender.frameId; +            if (typeof tabId !== 'number') { +                throw new Error('Port does not have an associated frame ID'); +            } +            const targetFrameId = parseInt(match[1], 10); + +            let forwardPort = chrome.tabs.connect(tabId, {frameId: targetFrameId, name: `cross-frame-communication-port-${senderFrameId}`}); + +            const cleanup = () => { +                this.checkLastError(chrome.runtime.lastError); +                if (forwardPort !== null) { +                    forwardPort.disconnect(); +                    forwardPort = null; +                } +                if (port !== null) { +                    port.disconnect(); +                    port = null; +                } +            }; + +            port.onMessage.addListener((message) => { forwardPort.postMessage(message); }); +            forwardPort.onMessage.addListener((message) => { port.postMessage(message); }); +            port.onDisconnect.addListener(cleanup); +            forwardPort.onDisconnect.addListener(cleanup); +        } catch (e) { +            port.disconnect(); +            yomichan.logError(e); +        } +    } +      _onClipboardText({text}) {          this._onCommandSearch({mode: 'popup', query: text});      } diff --git a/ext/mixed/js/comm.js b/ext/mixed/js/comm.js new file mode 100644 index 00000000..7787616e --- /dev/null +++ b/ext/mixed/js/comm.js @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2020  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +class CrossFrameAPIPort extends EventDispatcher { +    constructor(otherFrameId, port, messageHandlers) { +        super(); +        this._otherFrameId = otherFrameId; +        this._port = port; +        this._messageHandlers = messageHandlers; +        this._activeInvocations = new Map(); +        this._invocationId = 0; +        this._eventListeners = new EventListenerCollection(); +    } + +    get otherFrameId() { +        return this._otherFrameId; +    } + +    prepare() { +        this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); +        this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); +    } + +    invoke(action, params, ackTimeout, responseTimeout) { +        return new Promise((resolve, reject) => { +            if (this._port === null) { +                reject(new Error('Port is disconnected')); +                return; +            } + +            const id = this._invocationId++; +            const invocation = {id, resolve, reject, responseTimeout, ack: false, timer: null}; +            this._activeInvocations.set(id, invocation); + +            if (ackTimeout !== null) { +                try { +                    invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (ack)')), ackTimeout); +                } catch (e) { +                    this._onError(id, new Error('Failed to set timeout')); +                    return; +                } +            } + +            try { +                this._port.postMessage({type: 'invoke', id, data: {action, params}}); +            } catch (e) { +                this._onError(id, e); +            } +        }); +    } + +    disconnect() { +        this._onDisconnect(); +    } + +    // Private + +    _onDisconnect() { +        if (this._port === null) { return; } +        this._eventListeners.removeAllEventListeners(); +        this._port = null; +        for (const id of this._activeInvocations.keys()) { +            this._onError(id, new Error('Disconnected')); +        } +        this.trigger('disconnect', this); +    } + +    _onMessage({type, id, data}) { +        switch (type) { +            case 'invoke': +                this._onInvoke(id, data); +                break; +            case 'ack': +                this._onAck(id); +                break; +            case 'result': +                this._onResult(id, data); +                break; +        } +    } + +    // Response handlers + +    _onAck(id) { +        const invocation = this._activeInvocations.get(id); +        if (typeof invocation === 'undefined') { +            yomichan.logWarning(new Error(`Request ${id} not found for ack`)); +            return; +        } + +        if (invocation.ack) { +            this._onError(id, new Error(`Request ${id} already ack'd`)); +            return; +        } + +        invocation.ack = true; + +        if (invocation.timer !== null) { +            clearTimeout(invocation.timer); +            invocation.timer = null; +        } + +        const responseTimeout = invocation.responseTimeout; +        if (responseTimeout !== null) { +            try { +                invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (response)')), responseTimeout); +            } catch (e) { +                this._onError(id, new Error('Failed to set timeout')); +            } +        } +    } + +    _onResult(id, data) { +        const invocation = this._activeInvocations.get(id); +        if (typeof invocation === 'undefined') { +            yomichan.logWarning(new Error(`Request ${id} not found`)); +            return; +        } + +        if (!invocation.ack) { +            this._onError(id, new Error(`Request ${id} not ack'd`)); +            return; +        } + +        this._activeInvocations.delete(id); + +        if (invocation.timer !== null) { +            clearTimeout(invocation.timer); +            invocation.timer = null; +        } + +        const error = data.error; +        if (typeof error !== 'undefined') { +            invocation.reject(jsonToError(error)); +        } else { +            invocation.resolve(data.result); +        } +    } + +    _onError(id, error) { +        const invocation = this._activeInvocations.get(id); +        if (typeof invocation === 'undefined') { return; } + +        this._activeInvocations.delete(id); +        if (invocation.timer !== null) { +            clearTimeout(invocation.timer); +            invocation.timer = null; +        } +        invocation.reject(error); +    } + +    // Invocation + +    _onInvoke(id, {action, params}) { +        const messageHandler = this._messageHandlers.get(action); +        if (typeof messageHandler === 'undefined') { +            this._sendError(id, new Error(`Unknown action: ${action}`)); +            return; +        } + +        const {handler, async} = messageHandler; + +        this._sendAck(id); +        if (async) { +            this._invokeHandlerAsync(id, handler, params); +        } else { +            this._invokeHandler(id, handler, params); +        } +    } + +    _invokeHandler(id, handler, params) { +        try { +            const result = handler(params); +            this._sendResult(id, result); +        } catch (error) { +            this._sendError(id, error); +        } +    } + +    async _invokeHandlerAsync(id, handler, params) { +        try { +            const result = await handler(params); +            this._sendResult(id, result); +        } catch (error) { +            this._sendError(id, error); +        } +    } + +    _sendResponse(data) { +        if (this._port === null) { return; } +        try { +            this._port.postMessage(data); +        } catch (e) { +            // NOP +        } +    } + +    _sendAck(id) { +        this._sendResponse({type: 'ack', id}); +    } + +    _sendResult(id, result) { +        this._sendResponse({type: 'result', id, data: {result}}); +    } + +    _sendError(id, error) { +        this._sendResponse({type: 'result', id, data: {error: errorToJson(error)}}); +    } +} + +class CrossFrameAPI { +    constructor() { +        this._ackTimeout = 3000; // 3 seconds +        this._responseTimeout = 10000; // 10 seconds +        this._commPorts = new Map(); +        this._messageHandlers = new Map(); +        this._onDisconnectBind = this._onDisconnect.bind(this); +    } + +    prepare() { +        chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); +    } + +    async invoke(targetFrameId, action, params={}) { +        const commPort = this._getOrCreateCommPort(targetFrameId); +        return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); +    } + +    registerHandlers(messageHandlers) { +        for (const [key, value] of messageHandlers) { +            if (this._messageHandlers.has(key)) { +                throw new Error(`Handler ${key} is already registered`); +            } +            this._messageHandlers.set(key, value); +        } +    } + +    _onConnect(port) { +        const match = /^cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); +        if (match === null) { return; } + +        const otherFrameId = parseInt(match[1], 10); +        this._setupCommPort(otherFrameId, port); +    } + +    _onDisconnect(commPort) { +        commPort.off('disconnect', this._onDisconnectBind); +        this._commPorts.delete(commPort.otherFrameId); +    } + +    _getOrCreateCommPort(otherFrameId) { +        const commPort = this._commPorts.get(otherFrameId); +        return (typeof commPort !== 'undefined' ? commPort : this._createCommPort(otherFrameId)); +    } + +    _createCommPort(otherFrameId) { +        const port = chrome.runtime.connect(null, {name: `background-cross-frame-communication-port-${otherFrameId}`}); +        return this._setupCommPort(otherFrameId, port); +    } + +    _setupCommPort(otherFrameId, port) { +        const commPort = new CrossFrameAPIPort(otherFrameId, port, this._messageHandlers); +        this._commPorts.set(otherFrameId, commPort); +        commPort.prepare(); +        commPort.on('disconnect', this._onDisconnectBind); +        return commPort; +    } +} |