Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 487 lines 15 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2020-2022 Yomichan Authors 4 * 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 */ 18 19import {extendApiMap, invokeApiMapHandler} from '../core/api-map.js'; 20import {EventDispatcher} from '../core/event-dispatcher.js'; 21import {EventListenerCollection} from '../core/event-listener-collection.js'; 22import {ExtensionError} from '../core/extension-error.js'; 23import {parseJson} from '../core/json.js'; 24import {log} from '../core/log.js'; 25import {safePerformance} from '../core/safe-performance.js'; 26 27/** 28 * @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEvents> 29 */ 30export class CrossFrameAPIPort extends EventDispatcher { 31 /** 32 * @param {number} otherTabId 33 * @param {number} otherFrameId 34 * @param {chrome.runtime.Port} port 35 * @param {import('cross-frame-api').ApiMap} apiMap 36 */ 37 constructor(otherTabId, otherFrameId, port, apiMap) { 38 super(); 39 /** @type {number} */ 40 this._otherTabId = otherTabId; 41 /** @type {number} */ 42 this._otherFrameId = otherFrameId; 43 /** @type {?chrome.runtime.Port} */ 44 this._port = port; 45 /** @type {import('cross-frame-api').ApiMap} */ 46 this._apiMap = apiMap; 47 /** @type {Map<number, import('cross-frame-api').Invocation>} */ 48 this._activeInvocations = new Map(); 49 /** @type {number} */ 50 this._invocationId = 0; 51 /** @type {EventListenerCollection} */ 52 this._eventListeners = new EventListenerCollection(); 53 } 54 55 /** @type {number} */ 56 get otherTabId() { 57 return this._otherTabId; 58 } 59 60 /** @type {number} */ 61 get otherFrameId() { 62 return this._otherFrameId; 63 } 64 65 /** 66 * @throws {Error} 67 */ 68 prepare() { 69 if (this._port === null) { throw new Error('Invalid state'); } 70 this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); 71 this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); 72 this._eventListeners.addEventListener(window, 'pageshow', this._onPageShow.bind(this)); 73 this._eventListeners.addEventListener(document, 'resume', this._onResume.bind(this)); 74 } 75 76 /** 77 * @template {import('cross-frame-api').ApiNames} TName 78 * @param {TName} action 79 * @param {import('cross-frame-api').ApiParams<TName>} params 80 * @param {number} ackTimeout 81 * @param {number} responseTimeout 82 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>} 83 */ 84 invoke(action, params, ackTimeout, responseTimeout) { 85 return new Promise((resolve, reject) => { 86 if (this._port === null) { 87 reject(new Error(`Port is disconnected (${action})`)); 88 return; 89 } 90 91 const id = this._invocationId++; 92 /** @type {import('cross-frame-api').Invocation} */ 93 const invocation = { 94 id, 95 resolve, 96 reject, 97 responseTimeout, 98 action, 99 ack: false, 100 timer: null, 101 }; 102 this._activeInvocations.set(id, invocation); 103 104 if (ackTimeout !== null) { 105 try { 106 invocation.timer = setTimeout(() => this._onError(id, 'Acknowledgement timeout'), ackTimeout); 107 } catch (e) { 108 this._onError(id, 'Failed to set timeout'); 109 return; 110 } 111 } 112 safePerformance.mark(`cross-frame-api:invoke:${action}`); 113 try { 114 this._port.postMessage(/** @type {import('cross-frame-api').InvokeMessage} */ ({type: 'invoke', id, data: {action, params}})); 115 } catch (e) { 116 this._onError(id, e); 117 } 118 }); 119 } 120 121 /** */ 122 disconnect() { 123 this._onDisconnect(); 124 } 125 126 // Private 127 128 /** 129 * @param {Event} e 130 */ 131 _onResume(e) { 132 // Page Resumed after being frozen 133 log.log('Yomitan cross frame reset. Resuming after page frozen.', e); 134 this._onDisconnect(); 135 } 136 137 /** 138 * @param {PageTransitionEvent} e 139 */ 140 _onPageShow(e) { 141 // Page restored from BFCache 142 if (e.persisted) { 143 log.log('Yomitan cross frame reset. Page restored from BFCache.', e); 144 this._onDisconnect(); 145 } 146 } 147 148 /** */ 149 _onDisconnect() { 150 if (this._port === null) { return; } 151 this._eventListeners.removeAllEventListeners(); 152 this._port = null; 153 for (const id of this._activeInvocations.keys()) { 154 this._onError(id, 'Disconnected'); 155 } 156 this.trigger('disconnect', this); 157 } 158 159 /** 160 * @param {import('cross-frame-api').Message} details 161 */ 162 _onMessage(details) { 163 const {type, id} = details; 164 switch (type) { 165 case 'invoke': 166 this._onInvoke(id, details.data); 167 break; 168 case 'ack': 169 this._onAck(id); 170 break; 171 case 'result': 172 this._onResult(id, details.data); 173 break; 174 } 175 } 176 177 // Response handlers 178 179 /** 180 * @param {number} id 181 */ 182 _onAck(id) { 183 const invocation = this._activeInvocations.get(id); 184 if (typeof invocation === 'undefined') { 185 log.warn(new Error(`Request ${id} not found for acknowledgement`)); 186 return; 187 } 188 189 if (invocation.ack) { 190 this._onError(id, `Request ${id} already acknowledged`); 191 return; 192 } 193 194 invocation.ack = true; 195 196 if (invocation.timer !== null) { 197 clearTimeout(invocation.timer); 198 invocation.timer = null; 199 } 200 201 const responseTimeout = invocation.responseTimeout; 202 if (responseTimeout !== null) { 203 try { 204 invocation.timer = setTimeout(() => this._onError(id, 'Response timeout'), responseTimeout); 205 } catch (e) { 206 this._onError(id, 'Failed to set timeout'); 207 } 208 } 209 } 210 211 /** 212 * @param {number} id 213 * @param {import('core').Response<import('cross-frame-api').ApiReturnAny>} data 214 */ 215 _onResult(id, data) { 216 const invocation = this._activeInvocations.get(id); 217 if (typeof invocation === 'undefined') { 218 log.warn(new Error(`Request ${id} not found`)); 219 return; 220 } 221 222 if (!invocation.ack) { 223 this._onError(id, `Request ${id} not acknowledged`); 224 return; 225 } 226 227 this._activeInvocations.delete(id); 228 229 if (invocation.timer !== null) { 230 clearTimeout(invocation.timer); 231 invocation.timer = null; 232 } 233 234 const error = data.error; 235 if (typeof error !== 'undefined') { 236 invocation.reject(ExtensionError.deserialize(error)); 237 } else { 238 invocation.resolve(data.result); 239 } 240 } 241 242 /** 243 * @param {number} id 244 * @param {unknown} errorOrMessage 245 */ 246 _onError(id, errorOrMessage) { 247 const invocation = this._activeInvocations.get(id); 248 if (typeof invocation === 'undefined') { return; } 249 250 const error = errorOrMessage instanceof Error ? errorOrMessage : new Error(`${errorOrMessage} (${invocation.action})`); 251 252 this._activeInvocations.delete(id); 253 if (invocation.timer !== null) { 254 clearTimeout(invocation.timer); 255 invocation.timer = null; 256 } 257 invocation.reject(error); 258 } 259 260 // Invocation 261 262 /** 263 * @param {number} id 264 * @param {import('cross-frame-api').ApiMessageAny} details 265 */ 266 _onInvoke(id, {action, params}) { 267 this._sendAck(id); 268 invokeApiMapHandler( 269 this._apiMap, 270 action, 271 params, 272 [], 273 (data) => this._sendResult(id, data), 274 () => this._sendError(id, new Error(`Unknown action: ${action}`)), 275 ); 276 } 277 278 /** 279 * @param {import('cross-frame-api').Message} data 280 */ 281 _sendResponse(data) { 282 if (this._port === null) { return; } 283 try { 284 this._port.postMessage(data); 285 } catch (e) { 286 // NOP 287 } 288 } 289 290 /** 291 * @param {number} id 292 */ 293 _sendAck(id) { 294 this._sendResponse({type: 'ack', id}); 295 } 296 297 /** 298 * @param {number} id 299 * @param {import('core').Response<import('cross-frame-api').ApiReturnAny>} data 300 */ 301 _sendResult(id, data) { 302 this._sendResponse({type: 'result', id, data}); 303 } 304 305 /** 306 * @param {number} id 307 * @param {Error} error 308 */ 309 _sendError(id, error) { 310 this._sendResponse({type: 'result', id, data: {error: ExtensionError.serialize(error)}}); 311 } 312} 313 314export class CrossFrameAPI { 315 /** 316 * @param {import('../comm/api.js').API} api 317 * @param {?number} tabId 318 * @param {?number} frameId 319 */ 320 constructor(api, tabId, frameId) { 321 /** @type {import('../comm/api.js').API} */ 322 this._api = api; 323 /** @type {number} */ 324 this._ackTimeout = 3000; // 3 seconds 325 /** @type {number} */ 326 this._responseTimeout = 10000; // 10 seconds 327 /** @type {Map<number, Map<number, CrossFrameAPIPort>>} */ 328 this._commPorts = new Map(); 329 /** @type {import('cross-frame-api').ApiMap} */ 330 this._apiMap = new Map(); 331 /** @type {(port: CrossFrameAPIPort) => void} */ 332 this._onDisconnectBind = this._onDisconnect.bind(this); 333 /** @type {?number} */ 334 this._tabId = tabId; 335 /** @type {?number} */ 336 this._frameId = frameId; 337 } 338 339 /** 340 * @type {?number} 341 */ 342 get tabId() { 343 return this._tabId; 344 } 345 346 /** 347 * @type {?number} 348 */ 349 get frameId() { 350 return this._frameId; 351 } 352 353 /** */ 354 prepare() { 355 chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); 356 } 357 358 /** 359 * @template {import('cross-frame-api').ApiNames} TName 360 * @param {number} targetFrameId 361 * @param {TName} action 362 * @param {import('cross-frame-api').ApiParams<TName>} params 363 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>} 364 */ 365 invoke(targetFrameId, action, params) { 366 return this.invokeTab(null, targetFrameId, action, params); 367 } 368 369 /** 370 * @template {import('cross-frame-api').ApiNames} TName 371 * @param {?number} targetTabId 372 * @param {number} targetFrameId 373 * @param {TName} action 374 * @param {import('cross-frame-api').ApiParams<TName>} params 375 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>} 376 */ 377 async invokeTab(targetTabId, targetFrameId, action, params) { 378 if (typeof targetTabId !== 'number') { 379 targetTabId = this._tabId; 380 if (typeof targetTabId !== 'number') { 381 throw new Error('Unknown target tab id for invocation'); 382 } 383 } 384 const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId); 385 return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); 386 } 387 388 /** 389 * @param {import('cross-frame-api').ApiMapInit} handlers 390 */ 391 registerHandlers(handlers) { 392 extendApiMap(this._apiMap, handlers); 393 } 394 395 // Private 396 397 /** 398 * @param {chrome.runtime.Port} port 399 */ 400 _onConnect(port) { 401 try { 402 /** @type {import('cross-frame-api').PortDetails} */ 403 let details; 404 try { 405 details = parseJson(port.name); 406 } catch (e) { 407 return; 408 } 409 if (details.name !== 'cross-frame-communication-port') { return; } 410 411 const otherTabId = details.otherTabId; 412 const otherFrameId = details.otherFrameId; 413 this._setupCommPort(otherTabId, otherFrameId, port); 414 } catch (e) { 415 port.disconnect(); 416 log.error(e); 417 } 418 } 419 420 /** 421 * @param {CrossFrameAPIPort} commPort 422 */ 423 _onDisconnect(commPort) { 424 commPort.off('disconnect', this._onDisconnectBind); 425 const {otherTabId, otherFrameId} = commPort; 426 const tabPorts = this._commPorts.get(otherTabId); 427 if (typeof tabPorts !== 'undefined') { 428 tabPorts.delete(otherFrameId); 429 if (tabPorts.size === 0) { 430 this._commPorts.delete(otherTabId); 431 } 432 } 433 } 434 435 /** 436 * @param {number} otherTabId 437 * @param {number} otherFrameId 438 * @returns {Promise<CrossFrameAPIPort>} 439 */ 440 async _getOrCreateCommPort(otherTabId, otherFrameId) { 441 const tabPorts = this._commPorts.get(otherTabId); 442 if (typeof tabPorts !== 'undefined') { 443 const commPort = tabPorts.get(otherFrameId); 444 if (typeof commPort !== 'undefined') { 445 return commPort; 446 } 447 } 448 return await this._createCommPort(otherTabId, otherFrameId); 449 } 450 451 /** 452 * @param {number} otherTabId 453 * @param {number} otherFrameId 454 * @returns {Promise<CrossFrameAPIPort>} 455 */ 456 async _createCommPort(otherTabId, otherFrameId) { 457 await this._api.openCrossFramePort(otherTabId, otherFrameId); 458 459 const tabPorts = this._commPorts.get(otherTabId); 460 if (typeof tabPorts !== 'undefined') { 461 const commPort = tabPorts.get(otherFrameId); 462 if (typeof commPort !== 'undefined') { 463 return commPort; 464 } 465 } 466 throw new Error('Comm port didn\'t open'); 467 } 468 469 /** 470 * @param {number} otherTabId 471 * @param {number} otherFrameId 472 * @param {chrome.runtime.Port} port 473 * @returns {CrossFrameAPIPort} 474 */ 475 _setupCommPort(otherTabId, otherFrameId, port) { 476 const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._apiMap); 477 let tabPorts = this._commPorts.get(otherTabId); 478 if (typeof tabPorts === 'undefined') { 479 tabPorts = new Map(); 480 this._commPorts.set(otherTabId, tabPorts); 481 } 482 tabPorts.set(otherFrameId, commPort); 483 commPort.prepare(); 484 commPort.on('disconnect', this._onDisconnectBind); 485 return commPort; 486 } 487}