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 {API} from './comm/api.js';
20import {CrossFrameAPI} from './comm/cross-frame-api.js';
21import {createApiMap, invokeApiMapHandler} from './core/api-map.js';
22import {EventDispatcher} from './core/event-dispatcher.js';
23import {ExtensionError} from './core/extension-error.js';
24import {log} from './core/log.js';
25import {deferPromise} from './core/utilities.js';
26import {WebExtension} from './extension/web-extension.js';
27
28/**
29 * @returns {boolean}
30 */
31function checkChromeNotAvailable() {
32 let hasChrome = false;
33 let hasBrowser = false;
34 try {
35 hasChrome = (typeof chrome === 'object' && chrome !== null && typeof chrome.runtime !== 'undefined');
36 } catch (e) {
37 // NOP
38 }
39 try {
40 hasBrowser = (typeof browser === 'object' && browser !== null && typeof browser.runtime !== 'undefined');
41 } catch (e) {
42 // NOP
43 }
44 return (hasBrowser && !hasChrome);
45}
46
47// Set up chrome alias if it's not available (Edge Legacy)
48if (checkChromeNotAvailable()) {
49 // @ts-expect-error - objects should have roughly the same interface
50 // eslint-disable-next-line no-global-assign
51 chrome = browser;
52}
53
54/**
55 * @param {WebExtension} webExtension
56 */
57async function waitForBackendReady(webExtension) {
58 const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());
59 /** @type {import('application').ApiMap} */
60 const apiMap = createApiMap([['applicationBackendReady', () => { resolve(); }]]);
61 /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
62 const onMessage = ({action, params}, _sender, callback) => invokeApiMapHandler(apiMap, action, params, [], callback);
63 chrome.runtime.onMessage.addListener(onMessage);
64 try {
65 await webExtension.sendMessagePromise({action: 'requestBackendReadySignal'});
66 await promise;
67 } finally {
68 chrome.runtime.onMessage.removeListener(onMessage);
69 }
70}
71
72/**
73 * @returns {Promise<void>}
74 */
75function waitForDomContentLoaded() {
76 return new Promise((resolve) => {
77 if (document.readyState !== 'loading') {
78 resolve();
79 return;
80 }
81 const onDomContentLoaded = () => {
82 document.removeEventListener('DOMContentLoaded', onDomContentLoaded);
83 resolve();
84 };
85 document.addEventListener('DOMContentLoaded', onDomContentLoaded);
86 });
87}
88
89/**
90 * The Yomitan class is a core component through which various APIs are handled and invoked.
91 * @augments EventDispatcher<import('application').Events>
92 */
93export class Application extends EventDispatcher {
94 /**
95 * Creates a new instance. The instance should not be used until it has been fully prepare()'d.
96 * @param {API} api
97 * @param {CrossFrameAPI} crossFrameApi
98 */
99 constructor(api, crossFrameApi) {
100 super();
101 /** @type {WebExtension} */
102 this._webExtension = new WebExtension();
103 /** @type {?boolean} */
104 this._isBackground = null;
105 /** @type {API} */
106 this._api = api;
107 /** @type {CrossFrameAPI} */
108 this._crossFrame = crossFrameApi;
109 /** @type {boolean} */
110 this._isReady = false;
111 /* eslint-disable @stylistic/no-multi-spaces */
112 /** @type {import('application').ApiMap} */
113 this._apiMap = createApiMap([
114 ['applicationIsReady', this._onMessageIsReady.bind(this)],
115 ['applicationGetUrl', this._onMessageGetUrl.bind(this)],
116 ['applicationOptionsUpdated', this._onMessageOptionsUpdated.bind(this)],
117 ['applicationDatabaseUpdated', this._onMessageDatabaseUpdated.bind(this)],
118 ['applicationZoomChanged', this._onMessageZoomChanged.bind(this)],
119 ]);
120 /* eslint-enable @stylistic/no-multi-spaces */
121 }
122
123 /** @type {WebExtension} */
124 get webExtension() {
125 return this._webExtension;
126 }
127
128 /**
129 * Gets the API instance for communicating with the backend.
130 * This value will be null on the background page/service worker.
131 * @type {API}
132 */
133 get api() {
134 return this._api;
135 }
136
137 /**
138 * Gets the CrossFrameAPI instance for communicating with different frames.
139 * This value will be null on the background page/service worker.
140 * @type {CrossFrameAPI}
141 */
142 get crossFrame() {
143 return this._crossFrame;
144 }
145
146 /**
147 * @type {?number}
148 */
149 get tabId() {
150 return this._crossFrame.tabId;
151 }
152
153 /**
154 * @type {?number}
155 */
156 get frameId() {
157 return this._crossFrame.frameId;
158 }
159
160 /**
161 * Prepares the instance for use.
162 */
163 prepare() {
164 chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
165 log.on('logGenericError', this._onLogGenericError.bind(this));
166 }
167
168 /**
169 * Sends a message to the backend indicating that the frame is ready and all script
170 * setup has completed.
171 */
172 ready() {
173 if (this._isReady) { return; }
174 this._isReady = true;
175 void this._webExtension.sendMessagePromise({action: 'applicationReady'});
176 }
177
178 /** */
179 triggerStorageChanged() {
180 this.trigger('storageChanged', {});
181 }
182
183 /** */
184 triggerClosePopups() {
185 this.trigger('closePopups', {});
186 }
187
188 /**
189 * @param {boolean} waitForDom
190 * @param {(application: Application) => (Promise<void>)} mainFunction
191 */
192 static async main(waitForDom, mainFunction) {
193 const supportsServiceWorker = 'serviceWorker' in navigator; // Basically, all browsers except Firefox. But it's possible Firefox will support it in the future, so we check in this fashion to be future-proof.
194 const inExtensionContext = window.location.protocol === new URL(import.meta.url).protocol; // This code runs both in content script as well as in the iframe, so we need to differentiate the situation
195 /** @type {MessagePort | null} */
196 // If this is Firefox, we don't have a service worker and can't postMessage,
197 // so we temporarily create a SharedWorker in order to establish a MessageChannel
198 // which we can use to postMessage with the backend.
199 // This can only be done in the extension context (aka iframe within popup),
200 // not in the content script context.
201 const backendPort = !supportsServiceWorker && inExtensionContext ?
202 (() => {
203 const sharedWorkerBridge = new SharedWorker(new URL('comm/shared-worker-bridge.js', import.meta.url), {type: 'module'});
204 const backendChannel = new MessageChannel();
205 sharedWorkerBridge.port.postMessage({action: 'connectToBackend1'}, [backendChannel.port1]);
206 sharedWorkerBridge.port.close();
207 return backendChannel.port2;
208 })() :
209 null;
210
211 const webExtension = new WebExtension();
212 log.configure(webExtension.extensionName);
213
214 const mediaDrawingWorkerToBackendChannel = new MessageChannel();
215 const mediaDrawingWorker = inExtensionContext ? new Worker(new URL('display/media-drawing-worker.js', import.meta.url), {type: 'module'}) : null;
216 mediaDrawingWorker?.postMessage({action: 'connectToDatabaseWorker'}, [mediaDrawingWorkerToBackendChannel.port2]);
217
218 const api = new API(webExtension, mediaDrawingWorker, backendPort);
219 await waitForBackendReady(webExtension);
220 if (mediaDrawingWorker !== null) {
221 api.connectToDatabaseWorker(mediaDrawingWorkerToBackendChannel.port1);
222 }
223 setInterval(() => {
224 void api.heartbeat();
225 }, 20 * 1000);
226
227 const {tabId, frameId} = await api.frameInformationGet();
228 const crossFrameApi = new CrossFrameAPI(api, tabId, frameId);
229 crossFrameApi.prepare();
230 const application = new Application(api, crossFrameApi);
231 application.prepare();
232 if (waitForDom) { await waitForDomContentLoaded(); }
233 try {
234 await mainFunction(application);
235 } catch (error) {
236 log.error(error);
237 } finally {
238 application.ready();
239 }
240 }
241
242 // Private
243
244 /**
245 * @returns {string}
246 */
247 _getUrl() {
248 return location.href;
249 }
250
251 /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */
252 _onMessage({action, params}, _sender, callback) {
253 return invokeApiMapHandler(this._apiMap, action, params, [], callback);
254 }
255
256 /** @type {import('application').ApiHandler<'applicationIsReady'>} */
257 _onMessageIsReady() {
258 return this._isReady;
259 }
260
261 /** @type {import('application').ApiHandler<'applicationGetUrl'>} */
262 _onMessageGetUrl() {
263 return {url: this._getUrl()};
264 }
265
266 /** @type {import('application').ApiHandler<'applicationOptionsUpdated'>} */
267 _onMessageOptionsUpdated({source}) {
268 if (source !== 'background') {
269 this.trigger('optionsUpdated', {source});
270 }
271 }
272
273 /** @type {import('application').ApiHandler<'applicationDatabaseUpdated'>} */
274 _onMessageDatabaseUpdated({type, cause}) {
275 this.trigger('databaseUpdated', {type, cause});
276 }
277
278 /** @type {import('application').ApiHandler<'applicationZoomChanged'>} */
279 _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) {
280 this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor});
281 }
282
283 /**
284 * @param {import('log').Events['logGenericError']} params
285 */
286 async _onLogGenericError({error, level, context}) {
287 try {
288 await this._api.logGenericErrorBackend(ExtensionError.serialize(error), level, context);
289 } catch (e) {
290 // NOP
291 }
292 }
293}