/*
* 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 .
*/
import {API} from './comm/api.js';
import {CrossFrameAPI} from './comm/cross-frame-api.js';
import {EventDispatcher, deferPromise, invokeMessageHandler, log} from './core.js';
import {ExtensionError} from './core/extension-error.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);
})()) {
// @ts-expect-error - objects should have roughly the same interface
chrome = browser;
}
/**
* The Yomitan class is a core component through which various APIs are handled and invoked.
* @augments EventDispatcher
*/
export class Yomitan extends EventDispatcher {
/**
* Creates a new instance. The instance should not be used until it has been fully prepare()'d.
*/
constructor() {
super();
/** @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 = chrome.runtime.getURL('/');
} catch (e) {
// NOP
}
/** @type {?boolean} */
this._isBackground = null;
/** @type {?API} */
this._api = null;
/** @type {?CrossFrameAPI} */
this._crossFrame = null;
/** @type {boolean} */
this._isExtensionUnloaded = false;
/** @type {boolean} */
this._isTriggeringExtensionUnloaded = false;
/** @type {boolean} */
this._isReady = false;
const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails} */ (deferPromise());
/** @type {Promise} */
this._isBackendReadyPromise = promise;
/** @type {?(() => void)} */
this._isBackendReadyPromiseResolve = resolve;
/* eslint-disable no-multi-spaces */
/** @type {import('core').MessageHandlerMap} */
this._messageHandlers = new Map(/** @type {import('core').MessageHandlerArray} */ ([
['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)}]
]));
/* eslint-enable no-multi-spaces */
}
/**
* 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);
}
/**
* 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() {
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);
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 {import('extension').ChromeRuntimeSendMessageArgs} args The arguments to be passed to `chrome.runtime.sendMessage()`.
* @throws {Error} Errors thrown by `chrome.runtime.sendMessage()` are re-thrown.
*/
sendMessage(...args) {
try {
// @ts-expect-error - issue with type conversion, somewhat difficult to resolve in pure JS
chrome.runtime.sendMessage(...args);
} catch (e) {
this.triggerExtensionUnloaded();
throw e;
}
}
/**
* Triggers the extensionUnloaded event.
*/
triggerExtensionUnloaded() {
this._isExtensionUnloaded = true;
if (this._isTriggeringExtensionUnloaded) { return; }
try {
this._isTriggeringExtensionUnloaded = true;
this.trigger('extensionUnloaded');
} finally {
this._isTriggeringExtensionUnloaded = false;
}
}
// Private
/**
* @returns {string}
*/
_getUrl() {
return location.href;
}
/** @type {import('extension').ChromeRuntimeOnMessageCallback} */
_onMessage({action, params}, sender, callback) {
const messageHandler = this._messageHandlers.get(action);
if (typeof messageHandler === 'undefined') { return false; }
return invokeMessageHandler(messageHandler, params, callback, sender);
}
/**
* @returns {boolean}
*/
_onMessageIsReady() {
return this._isReady;
}
/**
* @returns {void}
*/
_onMessageBackendReady() {
if (this._isBackendReadyPromiseResolve === null) { return; }
this._isBackendReadyPromiseResolve();
this._isBackendReadyPromiseResolve = null;
}
/**
* @returns {{url: string}}
*/
_onMessageGetUrl() {
return {url: this._getUrl()};
}
/**
* @param {{source: string}} params
*/
_onMessageOptionsUpdated({source}) {
if (source !== 'background') {
this.trigger('optionsUpdated', {source});
}
}
/**
* @param {{type: string, cause: string}} params
*/
_onMessageDatabaseUpdated({type, cause}) {
this.trigger('databaseUpdated', {type, cause});
}
/**
* @param {{oldZoomFactor: number, newZoomFactor: number}} params
*/
_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
}
}
}
/**
* The default Yomitan class instance.
*/
export const yomitan = new Yomitan();