diff options
Diffstat (limited to 'ext/js/yomitan.js')
| -rw-r--r-- | ext/js/yomitan.js | 241 | 
1 files changed, 241 insertions, 0 deletions
| diff --git a/ext/js/yomitan.js b/ext/js/yomitan.js new file mode 100644 index 00000000..5535aeb6 --- /dev/null +++ b/ext/js/yomitan.js @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2023  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 {EventDispatcher, deferPromise, invokeMessageHandler, log, serializeError} from './core.js'; + +// Set up chrome alias if it's not available (Edge Legacy) +if ((() => { +    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); +})()) { +    chrome = browser; +} + +/** + * The Yomitan class is a core component through which various APIs are handled and invoked. + */ +class Yomitan extends EventDispatcher { +    /** +     * Creates a new instance. The instance should not be used until it has been fully prepare()'d. +     */ +    constructor() { +        super(); + +        try { +            const manifest = chrome.runtime.getManifest(); +            this._extensionName = `${manifest.name} v${manifest.version}`; +        } catch (e) { +            this._extensionName = 'Yomitan'; +        } + +        try { +            this._extensionUrlBase = chrome.runtime.getURL('/'); +        } catch (e) { +            this._extensionUrlBase = null; +        } + +        this._isBackground = null; +        this._api = null; +        this._crossFrame = null; +        this._isExtensionUnloaded = false; +        this._isTriggeringExtensionUnloaded = false; +        this._isReady = false; + +        const {promise, resolve} = deferPromise(); +        this._isBackendReadyPromise = promise; +        this._isBackendReadyPromiseResolve = resolve; + +        this._messageHandlers = new Map([ +            ['Yomitan.isReady',         {async: false, handler: this._onMessageIsReady.bind(this)}], +            ['Yomitan.backendReady',    {async: false, handler: this._onMessageBackendReady.bind(this)}], +            ['Yomitan.getUrl',          {async: false, handler: this._onMessageGetUrl.bind(this)}], +            ['Yomitan.optionsUpdated',  {async: false, handler: this._onMessageOptionsUpdated.bind(this)}], +            ['Yomitan.databaseUpdated', {async: false, handler: this._onMessageDatabaseUpdated.bind(this)}], +            ['Yomitan.zoomChanged',     {async: false, handler: this._onMessageZoomChanged.bind(this)}] +        ]); +    } + +    /** +     * Whether the current frame is the background page/service worker or not. +     * @type {boolean} +     */ +    get isBackground() { +        return this._isBackground; +    } + +    /** +     * Whether or not the extension is unloaded. +     * @type {boolean} +     */ +    get isExtensionUnloaded() { +        return this._isExtensionUnloaded; +    } + +    /** +     * Gets the API instance for communicating with the backend. +     * This value will be null on the background page/service worker. +     * @type {API} +     */ +    get api() { +        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() { +        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); + +            this.sendMessage({action: 'requestBackendReadySignal'}); +            await this._isBackendReadyPromise; + +            this._crossFrame = new CrossFrameAPI(); +            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.sendMessage({action: 'yomitanReady'}); +    } + +    /** +     * 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); +    } + +    /** +     * Runs `chrome.runtime.sendMessage()` with additional exception handling events. +     * @param {...*} args The arguments to be passed to `chrome.runtime.sendMessage()`. +     * @returns {void} The result of the `chrome.runtime.sendMessage()` call. +     * @throws {Error} Errors thrown by `chrome.runtime.sendMessage()` are re-thrown. +     */ +    sendMessage(...args) { +        try { +            return chrome.runtime.sendMessage(...args); +        } catch (e) { +            this.triggerExtensionUnloaded(); +            throw e; +        } +    } + +    triggerExtensionUnloaded() { +        this._isExtensionUnloaded = true; +        if (this._isTriggeringExtensionUnloaded) { return; } +        try { +            this._isTriggeringExtensionUnloaded = true; +            this.trigger('extensionUnloaded'); +        } finally { +            this._isTriggeringExtensionUnloaded = false; +        } +    } + +    // Private + +    _getUrl() { +        return location.href; +    } + +    _getLogContext() { +        return {url: this._getUrl()}; +    } + +    _onMessage({action, params}, sender, callback) { +        const messageHandler = this._messageHandlers.get(action); +        if (typeof messageHandler === 'undefined') { return false; } +        return invokeMessageHandler(messageHandler, params, callback, sender); +    } + +    _onMessageIsReady() { +        return this._isReady; +    } + +    _onMessageBackendReady() { +        if (this._isBackendReadyPromiseResolve === null) { return; } +        this._isBackendReadyPromiseResolve(); +        this._isBackendReadyPromiseResolve = null; +    } + +    _onMessageGetUrl() { +        return {url: this._getUrl()}; +    } + +    _onMessageOptionsUpdated({source}) { +        if (source !== 'background') { +            this.trigger('optionsUpdated', {source}); +        } +    } + +    _onMessageDatabaseUpdated({type, cause}) { +        this.trigger('databaseUpdated', {type, cause}); +    } + +    _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { +        this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor}); +    } + +    async _onForwardLog({error, level, context}) { +        try { +            await this._api.log(serializeError(error), level, context); +        } catch (e) { +            // NOP +        } +    } +} + +/** + * The default Yomitan class instance. + */ +export const yomitan = new Yomitan(); |