Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 293 lines 10 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 {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}