diff options
Diffstat (limited to 'ext/js/application.js')
-rw-r--r-- | ext/js/application.js | 255 |
1 files changed, 255 insertions, 0 deletions
diff --git a/ext/js/application.js b/ext/js/application.js new file mode 100644 index 00000000..87bd0e86 --- /dev/null +++ b/ext/js/application.js @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2023-2024 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 {API} from './comm/api.js'; +import {CrossFrameAPI} from './comm/cross-frame-api.js'; +import {createApiMap, invokeApiMapHandler} from './core/api-map.js'; +import {EventDispatcher} from './core/event-dispatcher.js'; +import {ExtensionError} from './core/extension-error.js'; +import {log} from './core/logger.js'; +import {deferPromise} from './core/utilities.js'; +import {WebExtension} from './extension/web-extension.js'; + +/** + * @returns {boolean} + */ +function checkChromeNotAvailable() { + let hasChrome = false; + let hasBrowser = false; + try { + hasChrome = (typeof chrome === 'object' && chrome !== null && typeof chrome.runtime !== 'undefined'); + } catch (e) { + // NOP + } + try { + hasBrowser = (typeof browser === 'object' && browser !== null && typeof browser.runtime !== 'undefined'); + } catch (e) { + // NOP + } + return (hasBrowser && !hasChrome); +} + +// Set up chrome alias if it's not available (Edge Legacy) +if (checkChromeNotAvailable()) { + // @ts-expect-error - objects should have roughly the same interface + // eslint-disable-next-line no-global-assign + chrome = browser; +} + +/** + * The Yomitan class is a core component through which various APIs are handled and invoked. + * @augments EventDispatcher<import('application').Events> + */ +export class Application extends EventDispatcher { + /** + * Creates a new instance. The instance should not be used until it has been fully prepare()'d. + */ + constructor() { + super(); + + /** @type {WebExtension} */ + this._webExtension = new WebExtension(); + + /** @type {string} */ + this._extensionName = 'Yomitan'; + try { + const manifest = chrome.runtime.getManifest(); + this._extensionName = `${manifest.name} v${manifest.version}`; + } catch (e) { + // NOP + } + + /** @type {?string} */ + this._extensionUrlBase = null; + try { + this._extensionUrlBase = this._webExtension.getUrl('/'); + } catch (e) { + // NOP + } + + /** @type {?boolean} */ + this._isBackground = null; + /** @type {?API} */ + this._api = null; + /** @type {?CrossFrameAPI} */ + this._crossFrame = null; + /** @type {boolean} */ + this._isReady = false; + + const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); + /** @type {Promise<void>} */ + this._isBackendReadyPromise = promise; + /** @type {?(() => void)} */ + this._isBackendReadyPromiseResolve = resolve; + + /* eslint-disable no-multi-spaces */ + /** @type {import('application').ApiMap} */ + this._apiMap = createApiMap([ + ['applicationIsReady', this._onMessageIsReady.bind(this)], + ['applicationBackendReady', this._onMessageBackendReady.bind(this)], + ['applicationGetUrl', this._onMessageGetUrl.bind(this)], + ['applicationOptionsUpdated', this._onMessageOptionsUpdated.bind(this)], + ['applicationDatabaseUpdated', this._onMessageDatabaseUpdated.bind(this)], + ['applicationZoomChanged', this._onMessageZoomChanged.bind(this)] + ]); + /* eslint-enable no-multi-spaces */ + } + + /** @type {WebExtension} */ + get webExtension() { + return this._webExtension; + } + + /** + * Whether the current frame is the background page/service worker or not. + * @type {boolean} + */ + get isBackground() { + if (this._isBackground === null) { throw new Error('Not prepared'); } + return /** @type {boolean} */ (this._isBackground); + } + + /** + * Gets the API instance for communicating with the backend. + * This value will be null on the background page/service worker. + * @type {API} + */ + get api() { + if (this._api === null) { throw new Error('Not prepared'); } + return this._api; + } + + /** + * Gets the CrossFrameAPI instance for communicating with different frames. + * This value will be null on the background page/service worker. + * @type {CrossFrameAPI} + */ + get crossFrame() { + if (this._crossFrame === null) { throw new Error('Not prepared'); } + return this._crossFrame; + } + + /** + * Prepares the instance for use. + * @param {boolean} [isBackground=false] Assigns whether this instance is being used from the background page/service worker. + */ + async prepare(isBackground = false) { + this._isBackground = isBackground; + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + + if (!isBackground) { + this._api = new API(this._webExtension); + + await this._webExtension.sendMessagePromise({action: 'requestBackendReadySignal'}); + await this._isBackendReadyPromise; + + this._crossFrame = new CrossFrameAPI(this._api); + await this._crossFrame.prepare(); + + log.on('log', this._onForwardLog.bind(this)); + } + } + + /** + * Sends a message to the backend indicating that the frame is ready and all script + * setup has completed. + */ + ready() { + this._isReady = true; + this._webExtension.sendMessagePromise({action: 'applicationReady'}); + } + + /** + * Checks whether or not a URL is an extension URL. + * @param {string} url The URL to check. + * @returns {boolean} `true` if the URL is an extension URL, `false` otherwise. + */ + isExtensionUrl(url) { + return this._extensionUrlBase !== null && url.startsWith(this._extensionUrlBase); + } + + /** */ + triggerStorageChanged() { + this.trigger('storageChanged', {}); + } + + /** */ + triggerClosePopups() { + this.trigger('closePopups', {}); + } + + // Private + + /** + * @returns {string} + */ + _getUrl() { + return location.href; + } + + /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('application').ApiMessageAny>} */ + _onMessage({action, params}, _sender, callback) { + return invokeApiMapHandler(this._apiMap, action, params, [], callback); + } + + /** @type {import('application').ApiHandler<'applicationIsReady'>} */ + _onMessageIsReady() { + return this._isReady; + } + + /** @type {import('application').ApiHandler<'applicationBackendReady'>} */ + _onMessageBackendReady() { + if (this._isBackendReadyPromiseResolve === null) { return; } + this._isBackendReadyPromiseResolve(); + this._isBackendReadyPromiseResolve = null; + } + + /** @type {import('application').ApiHandler<'applicationGetUrl'>} */ + _onMessageGetUrl() { + return {url: this._getUrl()}; + } + + /** @type {import('application').ApiHandler<'applicationOptionsUpdated'>} */ + _onMessageOptionsUpdated({source}) { + if (source !== 'background') { + this.trigger('optionsUpdated', {source}); + } + } + + /** @type {import('application').ApiHandler<'applicationDatabaseUpdated'>} */ + _onMessageDatabaseUpdated({type, cause}) { + this.trigger('databaseUpdated', {type, cause}); + } + + /** @type {import('application').ApiHandler<'applicationZoomChanged'>} */ + _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { + this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor}); + } + + /** + * @param {{error: unknown, level: import('log').LogLevel, context?: import('log').LogContext}} params + */ + async _onForwardLog({error, level, context}) { + try { + const api = /** @type {API} */ (this._api); + await api.log(ExtensionError.serialize(error), level, context); + } catch (e) { + // NOP + } + } +} |