Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
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}