/* * Copyright (C) 2023 Yomitan Authors * Copyright (C) 2020-2022 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/>. */ import {EventDispatcher, EventListenerCollection, deserializeError, invokeMessageHandler, log, serializeError} from '../core.js'; import {yomitan} from '../yomitan.js'; class CrossFrameAPIPort extends EventDispatcher { constructor(otherTabId, otherFrameId, port, messageHandlers) { super(); this._otherTabId = otherTabId; this._otherFrameId = otherFrameId; this._port = port; this._messageHandlers = messageHandlers; this._activeInvocations = new Map(); this._invocationId = 0; this._eventListeners = new EventListenerCollection(); } get otherTabId() { return this._otherTabId; } 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 (${action})`)); return; } const id = this._invocationId++; const invocation = { id, resolve, reject, responseTimeout, action, ack: false, timer: null }; this._activeInvocations.set(id, invocation); if (ackTimeout !== null) { try { invocation.timer = setTimeout(() => this._onError(id, 'Acknowledgement timeout'), ackTimeout); } catch (e) { this._onError(id, '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, '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') { log.warn(new Error(`Request ${id} not found for acknowledgement`)); return; } if (invocation.ack) { this._onError(id, `Request ${id} already acknowledged`); 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, 'Response timeout'), responseTimeout); } catch (e) { this._onError(id, 'Failed to set timeout'); } } } _onResult(id, data) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { log.warn(new Error(`Request ${id} not found`)); return; } if (!invocation.ack) { this._onError(id, `Request ${id} not acknowledged`); 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(deserializeError(error)); } else { invocation.resolve(data.result); } } _onError(id, error) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { return; } if (typeof error === 'string') { error = new Error(`${error} (${invocation.action})`); } 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); this._sendAck(id); if (typeof messageHandler === 'undefined') { this._sendError(id, new Error(`Unknown action: ${action}`)); return false; } const callback = (data) => this._sendResult(id, data); return invokeMessageHandler(messageHandler, params, callback); } _sendResponse(data) { if (this._port === null) { return; } try { this._port.postMessage(data); } catch (e) { // NOP } } _sendAck(id) { this._sendResponse({type: 'ack', id}); } _sendResult(id, data) { this._sendResponse({type: 'result', id, data}); } _sendError(id, error) { this._sendResponse({type: 'result', id, data: {error: serializeError(error)}}); } } export 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); this._tabId = null; this._frameId = null; } async prepare() { chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); ({tabId: this._tabId, frameId: this._frameId} = await yomitan.api.frameInformationGet()); } invoke(targetFrameId, action, params={}) { return this.invokeTab(null, targetFrameId, action, params); } async invokeTab(targetTabId, targetFrameId, action, params={}) { if (typeof targetTabId !== 'number') { targetTabId = this._tabId; } const commPort = await this._getOrCreateCommPort(targetTabId, 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); } } unregisterHandler(key) { return this._messageHandlers.delete(key); } // Private _onConnect(port) { try { let details; try { details = JSON.parse(port.name); } catch (e) { return; } if (details.name !== 'cross-frame-communication-port') { return; } const otherTabId = details.otherTabId; const otherFrameId = details.otherFrameId; this._setupCommPort(otherTabId, otherFrameId, port); } catch (e) { port.disconnect(); log.error(e); } } _onDisconnect(commPort) { commPort.off('disconnect', this._onDisconnectBind); const {otherTabId, otherFrameId} = commPort; const tabPorts = this._commPorts.get(otherTabId); if (typeof tabPorts !== 'undefined') { tabPorts.delete(otherFrameId); if (tabPorts.size === 0) { this._commPorts.delete(otherTabId); } } } _getOrCreateCommPort(otherTabId, otherFrameId) { const tabPorts = this._commPorts.get(otherTabId); if (typeof tabPorts !== 'undefined') { const commPort = tabPorts.get(otherFrameId); if (typeof commPort !== 'undefined') { return commPort; } } return this._createCommPort(otherTabId, otherFrameId); } async _createCommPort(otherTabId, otherFrameId) { await yomitan.api.openCrossFramePort(otherTabId, otherFrameId); const tabPorts = this._commPorts.get(otherTabId); if (typeof tabPorts !== 'undefined') { const commPort = tabPorts.get(otherFrameId); if (typeof commPort !== 'undefined') { return commPort; } } } _setupCommPort(otherTabId, otherFrameId, port) { const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._messageHandlers); let tabPorts = this._commPorts.get(otherTabId); if (typeof tabPorts === 'undefined') { tabPorts = new Map(); this._commPorts.set(otherTabId, tabPorts); } tabPorts.set(otherFrameId, commPort); commPort.prepare(); commPort.on('disconnect', this._onDisconnectBind); return commPort; } }