diff options
Diffstat (limited to 'ext/js/background')
-rw-r--r-- | ext/js/background/backend.js | 1001 | ||||
-rw-r--r-- | ext/js/background/profile-conditions-util.js | 155 | ||||
-rw-r--r-- | ext/js/background/request-builder.js | 50 | ||||
-rw-r--r-- | ext/js/background/script-manager.js | 186 |
4 files changed, 1092 insertions, 300 deletions
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index bf4841f8..a8683b3f 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -22,8 +22,10 @@ import {AnkiConnect} from '../comm/anki-connect.js'; import {ClipboardMonitor} from '../comm/clipboard-monitor.js'; import {ClipboardReader} from '../comm/clipboard-reader.js'; import {Mecab} from '../comm/mecab.js'; -import {clone, deferPromise, deserializeError, generateId, invokeMessageHandler, isObject, log, promiseTimeout, serializeError} from '../core.js'; +import {clone, deferPromise, generateId, invokeMessageHandler, isObject, log, promiseTimeout} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {AnkiUtil} from '../data/anki-util.js'; +import {JsonSchema} from '../data/json-schema.js'; import {OptionsUtil} from '../data/options-util.js'; import {PermissionsUtil} from '../data/permissions-util.js'; import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js'; @@ -35,7 +37,7 @@ import {Translator} from '../language/translator.js'; import {AudioDownloader} from '../media/audio-downloader.js'; import {MediaUtil} from '../media/media-util.js'; import {yomitan} from '../yomitan.js'; -import {OffscreenProxy, DictionaryDatabaseProxy, TranslatorProxy, ClipboardReaderProxy} from './offscreen-proxy.js'; +import {ClipboardReaderProxy, DictionaryDatabaseProxy, OffscreenProxy, TranslatorProxy} from './offscreen-proxy.js'; import {ProfileConditionsUtil} from './profile-conditions-util.js'; import {RequestBuilder} from './request-builder.js'; import {ScriptManager} from './script-manager.js'; @@ -49,17 +51,28 @@ export class Backend { * Creates a new instance. */ constructor() { + /** @type {JapaneseUtil} */ this._japaneseUtil = new JapaneseUtil(wanakana); + /** @type {Environment} */ this._environment = new Environment(); + /** + * + */ this._anki = new AnkiConnect(); + /** @type {Mecab} */ this._mecab = new Mecab(); if (!chrome.offscreen) { + /** @type {?OffscreenProxy} */ + this._offscreen = null; + /** @type {DictionaryDatabase|DictionaryDatabaseProxy} */ this._dictionaryDatabase = new DictionaryDatabase(); + /** @type {Translator|TranslatorProxy} */ this._translator = new Translator({ japaneseUtil: this._japaneseUtil, database: this._dictionaryDatabase }); + /** @type {ClipboardReader|ClipboardReaderProxy} */ this._clipboardReader = new ClipboardReader({ // eslint-disable-next-line no-undef document: (typeof document === 'object' && document !== null ? document : null), @@ -67,54 +80,83 @@ export class Backend { richContentPasteTargetSelector: '#clipboard-rich-content-paste-target' }); } else { + /** @type {?OffscreenProxy} */ this._offscreen = new OffscreenProxy(); + /** @type {DictionaryDatabase|DictionaryDatabaseProxy} */ this._dictionaryDatabase = new DictionaryDatabaseProxy(this._offscreen); + /** @type {Translator|TranslatorProxy} */ this._translator = new TranslatorProxy(this._offscreen); + /** @type {ClipboardReader|ClipboardReaderProxy} */ this._clipboardReader = new ClipboardReaderProxy(this._offscreen); } + /** @type {ClipboardMonitor} */ this._clipboardMonitor = new ClipboardMonitor({ japaneseUtil: this._japaneseUtil, clipboardReader: this._clipboardReader }); + /** @type {?import('settings').Options} */ this._options = null; + /** @type {JsonSchema[]} */ this._profileConditionsSchemaCache = []; + /** @type {ProfileConditionsUtil} */ this._profileConditionsUtil = new ProfileConditionsUtil(); + /** @type {?string} */ this._defaultAnkiFieldTemplates = null; + /** @type {RequestBuilder} */ this._requestBuilder = new RequestBuilder(); + /** @type {AudioDownloader} */ this._audioDownloader = new AudioDownloader({ japaneseUtil: this._japaneseUtil, requestBuilder: this._requestBuilder }); + /** @type {OptionsUtil} */ this._optionsUtil = new OptionsUtil(); + /** @type {ScriptManager} */ this._scriptManager = new ScriptManager(); + /** @type {AccessibilityController} */ this._accessibilityController = new AccessibilityController(this._scriptManager); + /** @type {?number} */ this._searchPopupTabId = null; + /** @type {?Promise<{tab: chrome.tabs.Tab, created: boolean}>} */ this._searchPopupTabCreatePromise = null; + /** @type {boolean} */ this._isPrepared = false; + /** @type {boolean} */ this._prepareError = false; + /** @type {?Promise<void>} */ this._preparePromise = null; + /** @type {import('core').DeferredPromiseDetails<void>} */ const {promise, resolve, reject} = deferPromise(); + /** @type {Promise<void>} */ this._prepareCompletePromise = promise; + /** @type {() => void} */ this._prepareCompleteResolve = resolve; + /** @type {(reason?: unknown) => void} */ this._prepareCompleteReject = reject; + /** @type {?string} */ this._defaultBrowserActionTitle = null; + /** @type {?number} */ this._badgePrepareDelayTimer = null; + /** @type {?import('log').LogLevel} */ this._logErrorLevel = null; + /** @type {?chrome.permissions.Permissions} */ this._permissions = null; + /** @type {PermissionsUtil} */ this._permissionsUtil = new PermissionsUtil(); - this._messageHandlers = new Map([ + /** @type {import('backend').MessageHandlerMap} */ + this._messageHandlers = new Map(/** @type {import('backend').MessageHandlerMapInit} */ ([ ['requestBackendReadySignal', {async: false, contentScript: true, handler: this._onApiRequestBackendReadySignal.bind(this)}], ['optionsGet', {async: false, contentScript: true, handler: this._onApiOptionsGet.bind(this)}], ['optionsGetFull', {async: false, contentScript: true, handler: this._onApiOptionsGetFull.bind(this)}], ['kanjiFind', {async: true, contentScript: true, handler: this._onApiKanjiFind.bind(this)}], ['termsFind', {async: true, contentScript: true, handler: this._onApiTermsFind.bind(this)}], ['parseText', {async: true, contentScript: true, handler: this._onApiParseText.bind(this)}], - ['getAnkiConnectVersion', {async: true, contentScript: true, handler: this._onApGetAnkiConnectVersion.bind(this)}], + ['getAnkiConnectVersion', {async: true, contentScript: true, handler: this._onApiGetAnkiConnectVersion.bind(this)}], ['isAnkiConnected', {async: true, contentScript: true, handler: this._onApiIsAnkiConnected.bind(this)}], ['addAnkiNote', {async: true, contentScript: true, handler: this._onApiAddAnkiNote.bind(this)}], ['getAnkiNoteInfo', {async: true, contentScript: true, handler: this._onApiGetAnkiNoteInfo.bind(this)}], @@ -151,17 +193,20 @@ export class Backend { ['findAnkiNotes', {async: true, contentScript: true, handler: this._onApiFindAnkiNotes.bind(this)}], ['loadExtensionScripts', {async: true, contentScript: true, handler: this._onApiLoadExtensionScripts.bind(this)}], ['openCrossFramePort', {async: false, contentScript: true, handler: this._onApiOpenCrossFramePort.bind(this)}] - ]); - this._messageHandlersWithProgress = new Map([ - ]); - - this._commandHandlers = new Map([ + ])); + /** @type {import('backend').MessageHandlerWithProgressMap} */ + this._messageHandlersWithProgress = new Map(/** @type {import('backend').MessageHandlerWithProgressMapInit} */ ([ + // Empty + ])); + + /** @type {Map<string, (params?: import('core').SerializableObject) => void>} */ + this._commandHandlers = new Map(/** @type {[name: string, handler: (params?: import('core').SerializableObject) => void][]} */ ([ ['toggleTextScanning', this._onCommandToggleTextScanning.bind(this)], ['openInfoPage', this._onCommandOpenInfoPage.bind(this)], ['openSettingsPage', this._onCommandOpenSettingsPage.bind(this)], ['openSearchPage', this._onCommandOpenSearchPage.bind(this)], ['openPopupWindow', this._onCommandOpenPopupWindow.bind(this)] - ]); + ])); } /** @@ -172,9 +217,9 @@ export class Backend { if (this._preparePromise === null) { const promise = this._prepareInternal(); promise.then( - (value) => { + () => { this._isPrepared = true; - this._prepareCompleteResolve(value); + this._prepareCompleteResolve(); }, (error) => { this._prepareError = true; @@ -189,6 +234,9 @@ export class Backend { // Private + /** + * @returns {void} + */ _prepareInternalSync() { if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { const onCommand = this._onWebExtensionEventWrapper(this._onCommand.bind(this)); @@ -212,6 +260,9 @@ export class Backend { chrome.runtime.onInstalled.addListener(this._onInstalled.bind(this)); } + /** + * @returns {Promise<void>} + */ async _prepareInternal() { try { this._prepareInternalSync(); @@ -224,11 +275,11 @@ export class Backend { }, 1000); this._updateBadge(); - yomitan.on('log', this._onLog.bind(this)); + log.on('log', this._onLog.bind(this)); await this._requestBuilder.prepare(); await this._environment.prepare(); - if (chrome.offscreen) { + if (this._offscreen !== null) { await this._offscreen.prepare(); } this._clipboardReader.browser = this._environment.getInfo().browser; @@ -239,16 +290,16 @@ export class Backend { log.error(e); } - const deinflectionReasons = await this._fetchAsset('/data/deinflect.json', true); + const deinflectionReasons = /** @type {import('deinflector').ReasonsRaw} */ (await this._fetchJson('/data/deinflect.json')); this._translator.prepare(deinflectionReasons); await this._optionsUtil.prepare(); - this._defaultAnkiFieldTemplates = (await this._fetchAsset('/data/templates/default-anki-field-templates.handlebars')).trim(); + this._defaultAnkiFieldTemplates = (await this._fetchText('/data/templates/default-anki-field-templates.handlebars')).trim(); this._options = await this._optionsUtil.load(); this._applyOptions('background'); - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); if (options.general.showGuide) { this._openWelcomeGuidePageOnce(); } @@ -270,20 +321,30 @@ export class Backend { // Event handlers + /** + * @param {{text: string}} params + */ async _onClipboardTextChange({text}) { - const {clipboard: {maximumSearchLength}} = this._getProfileOptions({current: true}); + const {clipboard: {maximumSearchLength}} = this._getProfileOptions({current: true}, false); if (text.length > maximumSearchLength) { text = text.substring(0, maximumSearchLength); } try { const {tab, created} = await this._getOrCreateSearchPopup(); + const {id} = tab; + if (typeof id !== 'number') { + throw new Error('Tab does not have an id'); + } await this._focusTab(tab); - await this._updateSearchQuery(tab.id, text, !created); + await this._updateSearchQuery(id, text, !created); } catch (e) { // NOP } } + /** + * @param {{level: import('log').LogLevel}} params + */ _onLog({level}) { const levelValue = this._getErrorLevelValue(level); if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } @@ -294,8 +355,13 @@ export class Backend { // WebExtension event handlers (with prepared checks) + /** + * @template {(...args: import('core').SafeAny[]) => void} T + * @param {T} handler + * @returns {T} + */ _onWebExtensionEventWrapper(handler) { - return (...args) => { + return /** @type {T} */ ((...args) => { if (this._isPrepared) { handler(...args); return; @@ -305,9 +371,10 @@ export class Backend { () => { handler(...args); }, () => {} // NOP ); - }; + }); } + /** @type {import('extension').ChromeRuntimeOnMessageCallback} */ _onMessageWrapper(message, sender, sendResponse) { if (this._isPrepared) { return this._onMessage(message, sender, sendResponse); @@ -322,10 +389,19 @@ export class Backend { // WebExtension event handlers + /** + * @param {string} command + */ _onCommand(command) { - this._runCommand(command); + this._runCommand(command, void 0); } + /** + * @param {{action: string, params?: import('core').SerializableObject}} message + * @param {chrome.runtime.MessageSender} sender + * @param {(response?: unknown) => void} callback + * @returns {boolean} + */ _onMessage({action, params}, sender, callback) { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } @@ -334,7 +410,7 @@ export class Backend { try { this._validatePrivilegedMessageSender(sender); } catch (error) { - callback({error: serializeError(error)}); + callback({error: ExtensionError.serialize(error)}); return false; } } @@ -342,14 +418,23 @@ export class Backend { return invokeMessageHandler(messageHandler, params, callback, sender); } + /** + * @param {chrome.tabs.ZoomChangeInfo} event + */ _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) { - this._sendMessageTabIgnoreResponse(tabId, {action: 'Yomitan.zoomChanged', params: {oldZoomFactor, newZoomFactor}}); + this._sendMessageTabIgnoreResponse(tabId, {action: 'Yomitan.zoomChanged', params: {oldZoomFactor, newZoomFactor}}, {}); } + /** + * @returns {void} + */ _onPermissionsChanged() { this._checkPermissions(); } + /** + * @param {chrome.runtime.InstalledDetails} event + */ _onInstalled({reason}) { if (reason !== 'install') { return; } this._requestPersistentStorage(); @@ -357,6 +442,7 @@ export class Backend { // Message handlers + /** @type {import('api').Handler<import('api').RequestBackendReadySignalDetails, import('api').RequestBackendReadySignalResult, true>} */ _onApiRequestBackendReadySignal(_params, sender) { // tab ID isn't set in background (e.g. browser_action) const data = {action: 'Yomitan.backendReady', params: {}}; @@ -364,21 +450,27 @@ export class Backend { this._sendMessageIgnoreResponse(data); return false; } else { - this._sendMessageTabIgnoreResponse(sender.tab.id, data); + const {id} = sender.tab; + if (typeof id === 'number') { + this._sendMessageTabIgnoreResponse(id, data, {}); + } return true; } } + /** @type {import('api').Handler<import('api').OptionsGetDetails, import('api').OptionsGetResult>} */ _onApiOptionsGet({optionsContext}) { - return this._getProfileOptions(optionsContext); + return this._getProfileOptions(optionsContext, false); } + /** @type {import('api').Handler<import('api').OptionsGetFullDetails, import('api').OptionsGetFullResult>} */ _onApiOptionsGetFull() { - return this._getOptionsFull(); + return this._getOptionsFull(false); } + /** @type {import('api').Handler<import('api').KanjiFindDetails, import('api').KanjiFindResult>} */ async _onApiKanjiFind({text, optionsContext}) { - const options = this._getProfileOptions(optionsContext); + const options = this._getProfileOptions(optionsContext, false); const {general: {maxResults}} = options; const findKanjiOptions = this._getTranslatorFindKanjiOptions(options); const dictionaryEntries = await this._translator.findKanji(text, findKanjiOptions); @@ -386,8 +478,9 @@ export class Backend { return dictionaryEntries; } + /** @type {import('api').Handler<import('api').TermsFindDetails, import('api').TermsFindResult>} */ async _onApiTermsFind({text, details, optionsContext}) { - const options = this._getProfileOptions(optionsContext); + const options = this._getProfileOptions(optionsContext, false); const {general: {resultOutputMode: mode, maxResults}} = options; const findTermsOptions = this._getTranslatorFindTermsOptions(mode, details, options); const {dictionaryEntries, originalTextLength} = await this._translator.findTerms(mode, text, findTermsOptions); @@ -395,12 +488,14 @@ export class Backend { return {dictionaryEntries, originalTextLength}; } + /** @type {import('api').Handler<import('api').ParseTextDetails, import('api').ParseTextResult>} */ async _onApiParseText({text, optionsContext, scanLength, useInternalParser, useMecabParser}) { const [internalResults, mecabResults] = await Promise.all([ (useInternalParser ? this._textParseScanning(text, scanLength, optionsContext) : null), (useMecabParser ? this._textParseMecab(text) : null) ]); + /** @type {import('api').ParseTextResultItem[]} */ const results = []; if (internalResults !== null) { @@ -426,20 +521,26 @@ export class Backend { return results; } - async _onApGetAnkiConnectVersion() { + /** @type {import('api').Handler<import('api').GetAnkiConnectVersionDetails, import('api').GetAnkiConnectVersionResult>} */ + async _onApiGetAnkiConnectVersion() { return await this._anki.getVersion(); } + /** @type {import('api').Handler<import('api').IsAnkiConnectedDetails, import('api').IsAnkiConnectedResult>} */ async _onApiIsAnkiConnected() { return await this._anki.isConnected(); } + /** @type {import('api').Handler<import('api').AddAnkiNoteDetails, import('api').AddAnkiNoteResult>} */ async _onApiAddAnkiNote({note}) { return await this._anki.addNote(note); } + /** @type {import('api').Handler<import('api').GetAnkiNoteInfoDetails, import('api').GetAnkiNoteInfoResult>} */ async _onApiGetAnkiNoteInfo({notes, fetchAdditionalInfo}) { + /** @type {import('anki').NoteInfoWrapper[]} */ const results = []; + /** @type {{note: import('anki').Note, info: import('anki').NoteInfoWrapper}[]} */ const cannotAdd = []; const canAddArray = await this._anki.canAddNotes(notes); @@ -472,6 +573,7 @@ export class Backend { return results; } + /** @type {import('api').Handler<import('api').InjectAnkiNoteMediaDetails, import('api').InjectAnkiNoteMediaResult>} */ async _onApiInjectAnkiNoteMedia({timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}) { return await this._injectAnkNoteMedia( this._anki, @@ -484,13 +586,14 @@ export class Backend { ); } + /** @type {import('api').Handler<import('api').NoteViewDetails, import('api').NoteViewResult>} */ async _onApiNoteView({noteId, mode, allowFallback}) { if (mode === 'edit') { try { await this._anki.guiEditNote(noteId); return 'edit'; } catch (e) { - if (!this._anki.isErrorUnsupportedAction(e)) { + if (!(e instanceof Error && this._anki.isErrorUnsupportedAction(e))) { throw e; } else if (!allowFallback) { throw new Error('Mode not supported'); @@ -502,6 +605,7 @@ export class Backend { return 'browse'; } + /** @type {import('api').Handler<import('api').SuspendAnkiCardsForNoteDetails, import('api').SuspendAnkiCardsForNoteResult>} */ async _onApiSuspendAnkiCardsForNote({noteId}) { const cardIds = await this._anki.findCardsForNote(noteId); const count = cardIds.length; @@ -512,76 +616,93 @@ export class Backend { return count; } + /** @type {import('api').Handler<import('api').CommandExecDetails, import('api').CommandExecResult>} */ _onApiCommandExec({command, params}) { return this._runCommand(command, params); } + /** @type {import('api').Handler<import('api').GetTermAudioInfoListDetails, import('api').GetTermAudioInfoListResult>} */ async _onApiGetTermAudioInfoList({source, term, reading}) { return await this._audioDownloader.getTermAudioInfoList(source, term, reading); } + /** @type {import('api').Handler<import('api').SendMessageToFrameDetails, import('api').SendMessageToFrameResult, true>} */ _onApiSendMessageToFrame({frameId: targetFrameId, action, params}, sender) { - if (!(sender && sender.tab)) { - return false; - } - - const tabId = sender.tab.id; + if (!sender) { return false; } + const {tab} = sender; + if (!tab) { return false; } + const {id} = tab; + if (typeof id !== 'number') { return false; } const frameId = sender.frameId; - this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}, {frameId: targetFrameId}); + /** @type {import('extension').ChromeRuntimeMessageWithFrameId} */ + const message = {action, params, frameId}; + this._sendMessageTabIgnoreResponse(id, message, {frameId: targetFrameId}); return true; } + /** @type {import('api').Handler<import('api').BroadcastTabDetails, import('api').BroadcastTabResult, true>} */ _onApiBroadcastTab({action, params}, sender) { - if (!(sender && sender.tab)) { - return false; - } - - const tabId = sender.tab.id; + if (!sender) { return false; } + const {tab} = sender; + if (!tab) { return false; } + const {id} = tab; + if (typeof id !== 'number') { return false; } const frameId = sender.frameId; - this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}); + /** @type {import('extension').ChromeRuntimeMessageWithFrameId} */ + const message = {action, params, frameId}; + this._sendMessageTabIgnoreResponse(id, message, {}); return true; } - _onApiFrameInformationGet(params, sender) { + /** @type {import('api').Handler<import('api').FrameInformationGetDetails, import('api').FrameInformationGetResult, true>} */ + _onApiFrameInformationGet(_params, sender) { const tab = sender.tab; const tabId = tab ? tab.id : void 0; const frameId = sender.frameId; return Promise.resolve({tabId, frameId}); } + /** @type {import('api').Handler<import('api').InjectStylesheetDetails, import('api').InjectStylesheetResult, true>} */ async _onApiInjectStylesheet({type, value}, sender) { const {frameId, tab} = sender; - if (!isObject(tab)) { throw new Error('Invalid tab'); } - return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false); + if (typeof tab !== 'object' || tab === null || typeof tab.id !== 'number') { throw new Error('Invalid tab'); } + return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false, true, 'document_start'); } + /** @type {import('api').Handler<import('api').GetStylesheetContentDetails, import('api').GetStylesheetContentResult>} */ async _onApiGetStylesheetContent({url}) { if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { throw new Error('Invalid URL'); } - return await this._fetchAsset(url); + return await this._fetchText(url); } + /** @type {import('api').Handler<import('api').GetEnvironmentInfoDetails, import('api').GetEnvironmentInfoResult>} */ _onApiGetEnvironmentInfo() { return this._environment.getInfo(); } + /** @type {import('api').Handler<import('api').ClipboardGetDetails, import('api').ClipboardGetResult>} */ async _onApiClipboardGet() { return this._clipboardReader.getText(false); } + /** @type {import('api').Handler<import('api').GetDisplayTemplatesHtmlDetails, import('api').GetDisplayTemplatesHtmlResult>} */ async _onApiGetDisplayTemplatesHtml() { - return await this._fetchAsset('/display-templates.html'); + return await this._fetchText('/display-templates.html'); } - _onApiGetZoom(params, sender) { - if (!sender || !sender.tab) { - return Promise.reject(new Error('Invalid tab')); - } - + /** @type {import('api').Handler<import('api').GetZoomDetails, import('api').GetZoomResult, true>} */ + _onApiGetZoom(_params, sender) { return new Promise((resolve, reject) => { + if (!sender || !sender.tab) { + reject(new Error('Invalid tab')); + return; + } + const tabId = sender.tab.id; if (!( + typeof tabId === 'number' && chrome.tabs !== null && typeof chrome.tabs === 'object' && typeof chrome.tabs.getZoom === 'function' @@ -601,34 +722,41 @@ export class Backend { }); } + /** @type {import('api').Handler<import('api').GetDefaultAnkiFieldTemplatesDetails, import('api').GetDefaultAnkiFieldTemplatesResult>} */ _onApiGetDefaultAnkiFieldTemplates() { - return this._defaultAnkiFieldTemplates; + return /** @type {string} */ (this._defaultAnkiFieldTemplates); } + /** @type {import('api').Handler<import('api').GetDictionaryInfoDetails, import('api').GetDictionaryInfoResult>} */ async _onApiGetDictionaryInfo() { return await this._dictionaryDatabase.getDictionaryInfo(); } + /** @type {import('api').Handler<import('api').PurgeDatabaseDetails, import('api').PurgeDatabaseResult>} */ async _onApiPurgeDatabase() { await this._dictionaryDatabase.purge(); this._triggerDatabaseUpdated('dictionary', 'purge'); } + /** @type {import('api').Handler<import('api').GetMediaDetails, import('api').GetMediaResult>} */ async _onApiGetMedia({targets}) { return await this._getNormalizedDictionaryDatabaseMedia(targets); } + /** @type {import('api').Handler<import('api').LogDetails, import('api').LogResult>} */ _onApiLog({error, level, context}) { - log.log(deserializeError(error), level, context); + log.log(ExtensionError.deserialize(error), level, context); } + /** @type {import('api').Handler<import('api').LogIndicatorClearDetails, import('api').LogIndicatorClearResult>} */ _onApiLogIndicatorClear() { if (this._logErrorLevel === null) { return; } this._logErrorLevel = null; this._updateBadge(); } - _onApiCreateActionPort(params, sender) { + /** @type {import('api').Handler<import('api').CreateActionPortDetails, import('api').CreateActionPortResult, true>} */ + _onApiCreateActionPort(_params, sender) { if (!sender || !sender.tab) { throw new Error('Invalid sender'); } const tabId = sender.tab.id; if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); } @@ -651,10 +779,12 @@ export class Backend { return details; } + /** @type {import('api').Handler<import('api').ModifySettingsDetails, import('api').ModifySettingsResult>} */ _onApiModifySettings({targets, source}) { return this._modifySettings(targets, source); } + /** @type {import('api').Handler<import('api').GetSettingsDetails, import('api').GetSettingsResult>} */ _onApiGetSettings({targets}) { const results = []; for (const target of targets) { @@ -662,39 +792,48 @@ export class Backend { const result = this._getSetting(target); results.push({result: clone(result)}); } catch (e) { - results.push({error: serializeError(e)}); + results.push({error: ExtensionError.serialize(e)}); } } return results; } + /** @type {import('api').Handler<import('api').SetAllSettingsDetails, import('api').SetAllSettingsResult>} */ async _onApiSetAllSettings({value, source}) { this._optionsUtil.validate(value); this._options = clone(value); await this._saveOptions(source); } - async _onApiGetOrCreateSearchPopup({focus=false, text=null}) { + /** @type {import('api').Handler<import('api').GetOrCreateSearchPopupDetails, import('api').GetOrCreateSearchPopupResult>} */ + async _onApiGetOrCreateSearchPopup({focus=false, text}) { const {tab, created} = await this._getOrCreateSearchPopup(); if (focus === true || (focus === 'ifCreated' && created)) { await this._focusTab(tab); } if (typeof text === 'string') { - await this._updateSearchQuery(tab.id, text, !created); + const {id} = tab; + if (typeof id === 'number') { + await this._updateSearchQuery(id, text, !created); + } } - return {tabId: tab.id, windowId: tab.windowId}; + const {id} = tab; + return {tabId: typeof id === 'number' ? id : null, windowId: tab.windowId}; } + /** @type {import('api').Handler<import('api').IsTabSearchPopupDetails, import('api').IsTabSearchPopupResult>} */ async _onApiIsTabSearchPopup({tabId}) { const baseUrl = chrome.runtime.getURL('/search.html'); - const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url.startsWith(baseUrl)) : null; + const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url !== null && url.startsWith(baseUrl)) : null; return (tab !== null); } + /** @type {import('api').Handler<import('api').TriggerDatabaseUpdatedDetails, import('api').TriggerDatabaseUpdatedResult>} */ _onApiTriggerDatabaseUpdated({type, cause}) { this._triggerDatabaseUpdated(type, cause); } + /** @type {import('api').Handler<import('api').TestMecabDetails, import('api').TestMecabResult>} */ async _onApiTestMecab() { if (!this._mecab.isEnabled()) { throw new Error('MeCab not enabled'); @@ -731,18 +870,22 @@ export class Backend { return true; } + /** @type {import('api').Handler<import('api').TextHasJapaneseCharactersDetails, import('api').TextHasJapaneseCharactersResult>} */ _onApiTextHasJapaneseCharacters({text}) { return this._japaneseUtil.isStringPartiallyJapanese(text); } + /** @type {import('api').Handler<import('api').GetTermFrequenciesDetails, import('api').GetTermFrequenciesResult>} */ async _onApiGetTermFrequencies({termReadingList, dictionaries}) { return await this._translator.getTermFrequencies(termReadingList, dictionaries); } + /** @type {import('api').Handler<import('api').FindAnkiNotesDetails, import('api').FindAnkiNotesResult>} */ async _onApiFindAnkiNotes({query}) { return await this._anki.findNotes(query); } + /** @type {import('api').Handler<import('api').LoadExtensionScriptsDetails, import('api').LoadExtensionScriptsResult, true>} */ async _onApiLoadExtensionScripts({files}, sender) { if (!sender || !sender.tab) { throw new Error('Invalid sender'); } const tabId = sender.tab.id; @@ -753,6 +896,13 @@ export class Backend { } } + /** + * + * @param root0 + * @param root0.targetTabId + * @param root0.targetFrameId + * @param sender + */ _onApiOpenCrossFramePort({targetTabId, targetFrameId}, sender) { const sourceTabId = (sender && sender.tab ? sender.tab.id : null); if (typeof sourceTabId !== 'number') { @@ -798,18 +948,30 @@ export class Backend { // Command handlers + /** + * @param {undefined|{mode: 'existingOrNewTab'|'newTab', query?: string}} params + */ async _onCommandOpenSearchPage(params) { - const {mode='existingOrNewTab', query} = params || {}; + /** @type {'existingOrNewTab'|'newTab'} */ + let mode = 'existingOrNewTab'; + let query = ''; + if (typeof params === 'object' && params !== null) { + mode = this._normalizeOpenSettingsPageMode(params.mode, mode); + const paramsQuery = params.query; + if (typeof paramsQuery === 'string') { query = paramsQuery; } + } const baseUrl = chrome.runtime.getURL('/search.html'); + /** @type {{[key: string]: string}} */ const queryParams = {}; - if (query && query.length > 0) { queryParams.query = query; } + if (query.length > 0) { queryParams.query = query; } const queryString = new URLSearchParams(queryParams).toString(); let url = baseUrl; if (queryString.length > 0) { url += `?${queryString}`; } + /** @type {import('backend').FindTabsPredicate} */ const predicate = ({url: url2}) => { if (url2 === null || !url2.startsWith(baseUrl)) { return false; } const parsedUrl = new URL(url2); @@ -819,15 +981,19 @@ export class Backend { }; const openInTab = async () => { - const tabInfo = await this._findTabs(1000, false, predicate, false); + const tabInfo = /** @type {?import('backend').TabInfo} */ (await this._findTabs(1000, false, predicate, false)); if (tabInfo !== null) { const {tab} = tabInfo; - await this._focusTab(tab); - if (queryParams.query) { - await this._updateSearchQuery(tab.id, queryParams.query, true); + const {id} = tab; + if (typeof id === 'number') { + await this._focusTab(tab); + if (queryParams.query) { + await this._updateSearchQuery(id, queryParams.query, true); + } + return true; } - return true; } + return false; }; switch (mode) { @@ -845,46 +1011,73 @@ export class Backend { } } + /** + * @returns {Promise<void>} + */ async _onCommandOpenInfoPage() { await this._openInfoPage(); } + /** + * @param {undefined|{mode: 'existingOrNewTab'|'newTab'}} params + */ async _onCommandOpenSettingsPage(params) { - const {mode='existingOrNewTab'} = params || {}; + /** @type {'existingOrNewTab'|'newTab'} */ + let mode = 'existingOrNewTab'; + if (typeof params === 'object' && params !== null) { + mode = this._normalizeOpenSettingsPageMode(params.mode, mode); + } await this._openSettingsPage(mode); } + /** + * @returns {Promise<void>} + */ async _onCommandToggleTextScanning() { - const options = this._getProfileOptions({current: true}); - await this._modifySettings([{ + const options = this._getProfileOptions({current: true}, false); + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { action: 'set', path: 'general.enable', value: !options.general.enable, scope: 'profile', optionsContext: {current: true} - }], 'backend'); + }; + await this._modifySettings([modification], 'backend'); } + /** + * @returns {Promise<void>} + */ async _onCommandOpenPopupWindow() { await this._onApiGetOrCreateSearchPopup({focus: true}); } // Utilities + /** + * @param {import('settings-modifications').ScopedModification[]} targets + * @param {string} source + * @returns {Promise<import('core').Response<import('settings-modifications').ModificationResult>[]>} + */ async _modifySettings(targets, source) { + /** @type {import('core').Response<import('settings-modifications').ModificationResult>[]} */ const results = []; for (const target of targets) { try { const result = this._modifySetting(target); results.push({result: clone(result)}); } catch (e) { - results.push({error: serializeError(e)}); + results.push({error: ExtensionError.serialize(e)}); } } await this._saveOptions(source); return results; } + /** + * @returns {Promise<{tab: chrome.tabs.Tab, created: boolean}>} + */ _getOrCreateSearchPopup() { if (this._searchPopupTabCreatePromise === null) { const promise = this._getOrCreateSearchPopup2(); @@ -894,9 +1087,16 @@ export class Backend { return this._searchPopupTabCreatePromise; } + /** + * @returns {Promise<{tab: chrome.tabs.Tab, created: boolean}>} + */ async _getOrCreateSearchPopup2() { // Use existing tab const baseUrl = chrome.runtime.getURL('/search.html'); + /** + * @param {?string} url + * @returns {boolean} + */ const urlPredicate = (url) => url !== null && url.startsWith(baseUrl); if (this._searchPopupTabId !== null) { const tab = await this._checkTabUrl(this._searchPopupTabId, urlPredicate); @@ -910,8 +1110,11 @@ export class Backend { const existingTabInfo = await this._findSearchPopupTab(urlPredicate); if (existingTabInfo !== null) { const existingTab = existingTabInfo.tab; - this._searchPopupTabId = existingTab.id; - return {tab: existingTab, created: false}; + const {id} = existingTab; + if (typeof id === 'number') { + this._searchPopupTabId = id; + return {tab: existingTab, created: false}; + } } // chrome.windows not supported (e.g. on Firefox mobile) @@ -920,38 +1123,48 @@ export class Backend { } // Create a new window - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); const createData = this._getSearchPopupWindowCreateData(baseUrl, options); const {popupWindow: {windowState}} = options; const popupWindow = await this._createWindow(createData); - if (windowState !== 'normal') { + if (windowState !== 'normal' && typeof popupWindow.id === 'number') { await this._updateWindow(popupWindow.id, {state: windowState}); } const {tabs} = popupWindow; - if (tabs.length === 0) { + if (!Array.isArray(tabs) || tabs.length === 0) { throw new Error('Created window did not contain a tab'); } const tab = tabs[0]; - await this._waitUntilTabFrameIsReady(tab.id, 0, 2000); + const {id} = tab; + if (typeof id !== 'number') { + throw new Error('Tab does not have an id'); + } + await this._waitUntilTabFrameIsReady(id, 0, 2000); await this._sendMessageTabPromise( - tab.id, + id, {action: 'SearchDisplayController.setMode', params: {mode: 'popup'}}, {frameId: 0} ); - this._searchPopupTabId = tab.id; + this._searchPopupTabId = id; return {tab, created: true}; } + /** + * @param {(url: ?string) => boolean} urlPredicate + * @returns {Promise<?import('backend').TabInfo>} + */ async _findSearchPopupTab(urlPredicate) { + /** @type {import('backend').FindTabsPredicate} */ const predicate = async ({url, tab}) => { - if (!urlPredicate(url)) { return false; } + const {id} = tab; + if (typeof id === 'undefined' || !urlPredicate(url)) { return false; } try { const mode = await this._sendMessageTabPromise( - tab.id, + id, {action: 'SearchDisplayController.getMode', params: {}}, {frameId: 0} ); @@ -960,9 +1173,14 @@ export class Backend { return false; } }; - return await this._findTabs(1000, false, predicate, true); + return /** @type {?import('backend').TabInfo} */ (await this._findTabs(1000, false, predicate, true)); } + /** + * @param {string} url + * @param {import('settings').ProfileOptions} options + * @returns {chrome.windows.CreateData} + */ _getSearchPopupWindowCreateData(url, options) { const {popupWindow: {width, height, left, top, useLeft, useTop, windowType}} = options; return { @@ -976,6 +1194,10 @@ export class Backend { }; } + /** + * @param {chrome.windows.CreateData} createData + * @returns {Promise<chrome.windows.Window>} + */ _createWindow(createData) { return new Promise((resolve, reject) => { chrome.windows.create( @@ -985,13 +1207,18 @@ export class Backend { if (error) { reject(new Error(error.message)); } else { - resolve(result); + resolve(/** @type {chrome.windows.Window} */ (result)); } } ); }); } + /** + * @param {number} windowId + * @param {chrome.windows.UpdateInfo} updateInfo + * @returns {Promise<chrome.windows.Window>} + */ _updateWindow(windowId, updateInfo) { return new Promise((resolve, reject) => { chrome.windows.update( @@ -1009,21 +1236,31 @@ export class Backend { }); } - _updateSearchQuery(tabId, text, animate) { - return this._sendMessageTabPromise( + /** + * @param {number} tabId + * @param {string} text + * @param {boolean} animate + * @returns {Promise<void>} + */ + async _updateSearchQuery(tabId, text, animate) { + await this._sendMessageTabPromise( tabId, {action: 'SearchDisplayController.updateSearchQuery', params: {text, animate}}, {frameId: 0} ); } + /** + * @param {string} source + */ _applyOptions(source) { - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); this._updateBadge(); const enabled = options.general.enable; - let {apiKey} = options.anki; + /** @type {?string} */ + let apiKey = options.anki.apiKey; if (apiKey === '') { apiKey = null; } this._anki.server = options.anki.server; this._anki.enabled = options.anki.enable && enabled; @@ -1042,16 +1279,33 @@ export class Backend { this._sendMessageAllTabsIgnoreResponse('Yomitan.optionsUpdated', {source}); } - _getOptionsFull(useSchema=false) { + /** + * @param {boolean} useSchema + * @returns {import('settings').Options} + * @throws {Error} + */ + _getOptionsFull(useSchema) { const options = this._options; - return useSchema ? this._optionsUtil.createValidatingProxy(options) : options; + if (options === null) { throw new Error('Options is null'); } + return useSchema ? /** @type {import('settings').Options} */ (this._optionsUtil.createValidatingProxy(options)) : options; } - _getProfileOptions(optionsContext, useSchema=false) { + /** + * @param {import('settings').OptionsContext} optionsContext + * @param {boolean} useSchema + * @returns {import('settings').ProfileOptions} + */ + _getProfileOptions(optionsContext, useSchema) { return this._getProfile(optionsContext, useSchema).options; } - _getProfile(optionsContext, useSchema=false) { + /** + * @param {import('settings').OptionsContext} optionsContext + * @param {boolean} useSchema + * @returns {import('settings').Profile} + * @throws {Error} + */ + _getProfile(optionsContext, useSchema) { const options = this._getOptionsFull(useSchema); const profiles = options.profiles; if (!optionsContext.current) { @@ -1077,8 +1331,13 @@ export class Backend { return profiles[profileCurrent]; } + /** + * @param {import('settings').Options} options + * @param {import('settings').OptionsContext} optionsContext + * @returns {?import('settings').Profile} + */ _getProfileFromContext(options, optionsContext) { - optionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); + const normalizedOptionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); let index = 0; for (const profile of options.profiles) { @@ -1092,7 +1351,7 @@ export class Backend { this._profileConditionsSchemaCache.push(schema); } - if (conditionGroups.length > 0 && schema.isValid(optionsContext)) { + if (conditionGroups.length > 0 && schema.isValid(normalizedOptionsContext)) { return profile; } ++index; @@ -1101,20 +1360,36 @@ export class Backend { return null; } + /** + * @param {string} message + * @param {unknown} data + * @returns {ExtensionError} + */ _createDataError(message, data) { - const error = new Error(message); + const error = new ExtensionError(message); error.data = data; return error; } + /** + * @returns {void} + */ _clearProfileConditionsSchemaCache() { this._profileConditionsSchemaCache = []; } - _checkLastError() { + /** + * @param {unknown} _ignore + */ + _checkLastError(_ignore) { // NOP } + /** + * @param {string} command + * @param {import('core').SerializableObject|undefined} params + * @returns {boolean} + */ _runCommand(command, params) { const handler = this._commandHandlers.get(command); if (typeof handler !== 'function') { return false; } @@ -1123,12 +1398,20 @@ export class Backend { return true; } + /** + * @param {string} text + * @param {number} scanLength + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<import('api').ParseTextLine[]>} + */ async _textParseScanning(text, scanLength, optionsContext) { const jp = this._japaneseUtil; + /** @type {import('translator').FindTermsMode} */ const mode = 'simple'; - const options = this._getProfileOptions(optionsContext); - const details = {matchType: 'exact', deinflect: true}; + const options = this._getProfileOptions(optionsContext, false); + const details = {matchType: /** @type {import('translation').FindTermsMatchType} */ ('exact'), deinflect: true}; const findTermsOptions = this._getTranslatorFindTermsOptions(mode, details, options); + /** @type {import('api').ParseTextLine[]} */ const results = []; let previousUngroupedSegment = null; let i = 0; @@ -1139,7 +1422,7 @@ export class Backend { text.substring(i, i + scanLength), findTermsOptions ); - const codePoint = text.codePointAt(i); + const codePoint = /** @type {number} */ (text.codePointAt(i)); const character = String.fromCodePoint(codePoint); if ( dictionaryEntries.length > 0 && @@ -1168,6 +1451,10 @@ export class Backend { return results; } + /** + * @param {string} text + * @returns {Promise<import('backend').MecabParseResults>} + */ async _textParseMecab(text) { const jp = this._japaneseUtil; @@ -1178,8 +1465,10 @@ export class Backend { return []; } + /** @type {import('backend').MecabParseResults} */ const results = []; for (const {name, lines} of parseTextResults) { + /** @type {import('api').ParseTextLine[]} */ const result = []; for (const line of lines) { for (const {term, reading, source} of line) { @@ -1200,30 +1489,43 @@ export class Backend { return results; } + /** + * @param {chrome.runtime.Port} port + * @param {chrome.runtime.MessageSender} sender + * @param {import('backend').MessageHandlerWithProgressMap} handlers + */ _createActionListenerPort(port, sender, handlers) { + let done = false; let hasStarted = false; + /** @type {?string} */ let messageString = ''; + /** + * @param {...unknown} data + */ const onProgress = (...data) => { try { - if (port === null) { return; } - port.postMessage({type: 'progress', data}); + if (done) { return; } + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseProgressMessage} */ ({type: 'progress', data})); } catch (e) { // NOP } }; + /** + * @param {import('backend').InvokeWithProgressRequestMessage} message + */ const onMessage = (message) => { if (hasStarted) { return; } try { - const {action, data} = message; + const {action} = message; switch (action) { case 'fragment': - messageString += data; + messageString += message.data; break; case 'invoke': - { + if (messageString !== null) { hasStarted = true; port.onMessage.removeListener(onMessage); @@ -1238,10 +1540,13 @@ export class Backend { } }; + /** + * @param {{action: string, params?: import('core').SerializableObject}} message + */ const onMessageComplete = async (message) => { try { const {action, params} = message; - port.postMessage({type: 'ack'}); + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseAcknowledgeMessage} */ ({type: 'ack'})); const messageHandler = handlers.get(action); if (typeof messageHandler === 'undefined') { @@ -1255,7 +1560,7 @@ export class Backend { const promiseOrResult = handler(params, sender, onProgress); const result = async ? await promiseOrResult : promiseOrResult; - port.postMessage({type: 'complete', data: result}); + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseCompleteMessage} */ ({type: 'complete', data: result})); } catch (e) { cleanup(e); } @@ -1265,23 +1570,29 @@ export class Backend { cleanup(null); }; + /** + * @param {unknown} error + */ const cleanup = (error) => { - if (port === null) { return; } + if (done) { return; } if (error !== null) { - port.postMessage({type: 'error', data: serializeError(error)}); + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseErrorMessage} */ ({type: 'error', data: ExtensionError.serialize(error)})); } if (!hasStarted) { port.onMessage.removeListener(onMessage); } port.onDisconnect.removeListener(onDisconnect); - port = null; - handlers = null; + done = true; }; port.onMessage.addListener(onMessage); port.onDisconnect.addListener(onDisconnect); } + /** + * @param {?import('log').LogLevel} errorLevel + * @returns {number} + */ _getErrorLevelValue(errorLevel) { switch (errorLevel) { case 'info': return 0; @@ -1292,19 +1603,32 @@ export class Backend { } } + /** + * @param {import('settings-modifications').OptionsScope} target + * @returns {import('settings').Options|import('settings').ProfileOptions} + * @throws {Error} + */ _getModifySettingObject(target) { const scope = target.scope; switch (scope) { case 'profile': - if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); } - return this._getProfileOptions(target.optionsContext, true); + { + const {optionsContext} = target; + if (typeof optionsContext !== 'object' || optionsContext === null) { throw new Error('Invalid optionsContext'); } + return /** @type {import('settings').ProfileOptions} */ (this._getProfileOptions(optionsContext, true)); + } case 'global': - return this._getOptionsFull(true); + return /** @type {import('settings').Options} */ (this._getOptionsFull(true)); default: throw new Error(`Invalid scope: ${scope}`); } } + /** + * @param {import('settings-modifications').OptionsScope&import('settings-modifications').Read} target + * @returns {unknown} + * @throws {Error} + */ _getSetting(target) { const options = this._getModifySettingObject(target); const accessor = new ObjectPropertyAccessor(options); @@ -1313,6 +1637,11 @@ export class Backend { return accessor.get(ObjectPropertyAccessor.getPathArray(path)); } + /** + * @param {import('settings-modifications').ScopedModification} target + * @returns {import('settings-modifications').ModificationResult} + * @throws {Error} + */ _modifySetting(target) { const options = this._getModifySettingObject(target); const accessor = new ObjectPropertyAccessor(options); @@ -1368,10 +1697,14 @@ export class Backend { } } + /** + * @param {chrome.runtime.MessageSender} sender + * @throws {Error} + */ _validatePrivilegedMessageSender(sender) { let {url} = sender; if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } - const {tab} = url; + const {tab} = sender; if (typeof tab === 'object' && tab !== null) { ({url} = tab); if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } @@ -1379,6 +1712,9 @@ export class Backend { throw new Error('Invalid message sender'); } + /** + * @returns {Promise<string>} + */ _getBrowserIconTitle() { return ( isObject(chrome.action) && @@ -1388,6 +1724,9 @@ export class Backend { ); } + /** + * @returns {void} + */ _updateBadge() { let title = this._defaultBrowserActionTitle; if (title === null || !isObject(chrome.action)) { @@ -1423,7 +1762,7 @@ export class Backend { status = 'Loading'; } } else { - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); if (!options.general.enable) { text = 'off'; color = '#555555'; @@ -1453,6 +1792,10 @@ export class Backend { } } + /** + * @param {import('settings').ProfileOptions} options + * @returns {boolean} + */ _isAnyDictionaryEnabled(options) { for (const {enabled} of options.dictionaries) { if (enabled) { @@ -1462,21 +1805,18 @@ export class Backend { return false; } - _anyOptionsMatches(predicate) { - for (const {options} of this._options.profiles) { - const value = predicate(options); - if (value) { return value; } - } - return false; - } - + /** + * @param {number} tabId + * @returns {Promise<?string>} + */ async _getTabUrl(tabId) { try { - const {url} = await this._sendMessageTabPromise( + const response = await this._sendMessageTabPromise( tabId, {action: 'Yomitan.getUrl', params: {}}, {frameId: 0} ); + const url = typeof response === 'object' && response !== null ? /** @type {import('core').SerializableObject} */ (response).url : void 0; if (typeof url === 'string') { return url; } @@ -1486,6 +1826,9 @@ export class Backend { return null; } + /** + * @returns {Promise<chrome.tabs.Tab[]>} + */ _getAllTabs() { return new Promise((resolve, reject) => { chrome.tabs.query({}, (tabs) => { @@ -1499,21 +1842,33 @@ export class Backend { }); } + /** + * @param {number} timeout + * @param {boolean} multiple + * @param {import('backend').FindTabsPredicate} predicate + * @param {boolean} predicateIsAsync + * @returns {Promise<import('backend').TabInfo[]|(?import('backend').TabInfo)>} + */ async _findTabs(timeout, multiple, predicate, predicateIsAsync) { // This function works around the need to have the "tabs" permission to access tab.url. const tabs = await this._getAllTabs(); let done = false; + /** + * @param {chrome.tabs.Tab} tab + * @param {(tabInfo: import('backend').TabInfo) => boolean} add + */ const checkTab = async (tab, add) => { - const url = await this._getTabUrl(tab.id); + const {id} = tab; + const url = typeof id === 'number' ? await this._getTabUrl(id) : null; if (done) { return; } let okay = false; const item = {tab, url}; try { - okay = predicate(item); - if (predicateIsAsync) { okay = await okay; } + const okayOrPromise = predicate(item); + okay = predicateIsAsync ? await okayOrPromise : /** @type {boolean} */ (okayOrPromise); } catch (e) { // NOP } @@ -1526,7 +1881,12 @@ export class Backend { }; if (multiple) { + /** @type {import('backend').TabInfo[]} */ const results = []; + /** + * @param {import('backend').TabInfo} value + * @returns {boolean} + */ const add = (value) => { results.push(value); return false; @@ -1538,8 +1898,13 @@ export class Backend { ]); return results; } else { - const {promise, resolve} = deferPromise(); + const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); + /** @type {?import('backend').TabInfo} */ let result = null; + /** + * @param {import('backend').TabInfo} value + * @returns {boolean} + */ const add = (value) => { result = value; resolve(); @@ -1556,9 +1921,17 @@ export class Backend { } } + /** + * @param {chrome.tabs.Tab} tab + */ async _focusTab(tab) { - await new Promise((resolve, reject) => { - chrome.tabs.update(tab.id, {active: true}, () => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { + const {id} = tab; + if (typeof id !== 'number') { + reject(new Error('Cannot focus a tab without an id')); + return; + } + chrome.tabs.update(id, {active: true}, () => { const e = chrome.runtime.lastError; if (e) { reject(new Error(e.message)); @@ -1566,7 +1939,7 @@ export class Backend { resolve(); } }); - }); + })); if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) { // Windows not supported (e.g. on Firefox mobile) @@ -1585,7 +1958,7 @@ export class Backend { }); }); if (!tabWindow.focused) { - await new Promise((resolve, reject) => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.windows.update(tab.windowId, {focused: true}, () => { const e = chrome.runtime.lastError; if (e) { @@ -1594,23 +1967,31 @@ export class Backend { resolve(); } }); - }); + })); } } catch (e) { // Edge throws exception for no reason here. } } + /** + * @param {number} tabId + * @param {number} frameId + * @param {?number} [timeout=null] + * @returns {Promise<void>} + */ _waitUntilTabFrameIsReady(tabId, frameId, timeout=null) { return new Promise((resolve, reject) => { + /** @type {?number} */ let timer = null; + /** @type {?import('extension').ChromeRuntimeOnMessageCallback} */ let onMessage = (message, sender) => { if ( !sender.tab || sender.tab.id !== tabId || sender.frameId !== frameId || - !isObject(message) || - message.action !== 'yomitanReady' + !(typeof message === 'object' && message !== null) || + /** @type {import('core').SerializableObject} */ (message).action !== 'yomitanReady' ) { return; } @@ -1651,7 +2032,11 @@ export class Backend { }); } - async _fetchAsset(url, json=false) { + /** + * @param {string} url + * @returns {Promise<Response>} + */ + async _fetchAsset(url) { const response = await fetch(chrome.runtime.getURL(url), { method: 'GET', mode: 'no-cors', @@ -1663,30 +2048,71 @@ export class Backend { if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status}`); } - return await (json ? response.json() : response.text()); + return response; + } + + /** + * @param {string} url + * @returns {Promise<string>} + */ + async _fetchText(url) { + const response = await this._fetchAsset(url); + return await response.text(); + } + + /** + * @param {string} url + * @returns {Promise<unknown>} + */ + async _fetchJson(url) { + const response = await this._fetchAsset(url); + return await response.json(); } - _sendMessageIgnoreResponse(...args) { + /** + * @param {{action: string, params: import('core').SerializableObject}} message + */ + _sendMessageIgnoreResponse(message) { const callback = () => this._checkLastError(chrome.runtime.lastError); - chrome.runtime.sendMessage(...args, callback); + chrome.runtime.sendMessage(message, callback); } - _sendMessageTabIgnoreResponse(...args) { + /** + * @param {number} tabId + * @param {{action: string, params?: import('core').SerializableObject, frameId?: number}} message + * @param {chrome.tabs.MessageSendOptions} options + */ + _sendMessageTabIgnoreResponse(tabId, message, options) { const callback = () => this._checkLastError(chrome.runtime.lastError); - chrome.tabs.sendMessage(...args, callback); + chrome.tabs.sendMessage(tabId, message, options, callback); } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + */ _sendMessageAllTabsIgnoreResponse(action, params) { const callback = () => this._checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, (tabs) => { for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, {action, params}, callback); + const {id} = tab; + if (typeof id !== 'number') { continue; } + chrome.tabs.sendMessage(id, {action, params}, callback); } }); } - _sendMessageTabPromise(...args) { + /** + * @param {number} tabId + * @param {{action: string, params?: import('core').SerializableObject}} message + * @param {chrome.tabs.MessageSendOptions} options + * @returns {Promise<unknown>} + */ + _sendMessageTabPromise(tabId, message, options) { return new Promise((resolve, reject) => { + /** + * @param {unknown} response + */ const callback = (response) => { try { resolve(this._getMessageResponseResult(response)); @@ -1695,25 +2121,35 @@ export class Backend { } }; - chrome.tabs.sendMessage(...args, callback); + chrome.tabs.sendMessage(tabId, message, options, callback); }); } + /** + * @param {unknown} response + * @returns {unknown} + * @throws {Error} + */ _getMessageResponseResult(response) { - let error = chrome.runtime.lastError; + const error = chrome.runtime.lastError; if (error) { throw new Error(error.message); } - if (!isObject(response)) { + if (typeof response !== 'object' || response === null) { throw new Error('Tab did not respond'); } - error = response.error; - if (error) { - throw deserializeError(error); + const responseError = /** @type {import('core').SerializedError|undefined} */ (/** @type {import('core').SerializableObject} */ (response).error); + if (typeof responseError === 'object' && responseError !== null) { + throw ExtensionError.deserialize(responseError); } - return response.result; + return /** @type {import('core').SerializableObject} */ (response).result; } + /** + * @param {number} tabId + * @param {(url: ?string) => boolean} urlPredicate + * @returns {Promise<?chrome.tabs.Tab>} + */ async _checkTabUrl(tabId, urlPredicate) { let tab; try { @@ -1727,6 +2163,13 @@ export class Backend { return isValidTab ? tab : null; } + /** + * @param {number} tabId + * @param {number} frameId + * @param {'jpeg'|'png'} format + * @param {number} quality + * @returns {Promise<string>} + */ async _getScreenshot(tabId, frameId, format, quality) { const tab = await this._getTabById(tabId); const {windowId} = tab; @@ -1762,6 +2205,16 @@ export class Backend { } } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {?import('api').InjectAnkiNoteMediaAudioDetails} audioDetails + * @param {?import('api').InjectAnkiNoteMediaScreenshotDetails} screenshotDetails + * @param {?import('api').InjectAnkiNoteMediaClipboardDetails} clipboardDetails + * @param {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} dictionaryMediaDetails + * @returns {Promise<import('api').InjectAnkiNoteMediaResult>} + */ async _injectAnkNoteMedia(ankiConnect, timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) { let screenshotFileName = null; let clipboardImageFileName = null; @@ -1774,7 +2227,7 @@ export class Backend { screenshotFileName = await this._injectAnkiNoteScreenshot(ankiConnect, timestamp, definitionDetails, screenshotDetails); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } try { @@ -1782,7 +2235,7 @@ export class Backend { clipboardImageFileName = await this._injectAnkiNoteClipboardImage(ankiConnect, timestamp, definitionDetails); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } try { @@ -1790,7 +2243,7 @@ export class Backend { clipboardText = await this._clipboardReader.getText(false); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } try { @@ -1798,19 +2251,20 @@ export class Backend { audioFileName = await this._injectAnkiNoteAudio(ankiConnect, timestamp, definitionDetails, audioDetails); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } + /** @type {import('api').InjectAnkiNoteDictionaryMediaResult[]} */ let dictionaryMedia; try { let errors2; ({results: dictionaryMedia, errors: errors2} = await this._injectAnkiNoteDictionaryMedia(ankiConnect, timestamp, definitionDetails, dictionaryMediaDetails)); for (const error of errors2) { - errors.push(serializeError(error)); + errors.push(ExtensionError.serialize(error)); } } catch (e) { dictionaryMedia = []; - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } return { @@ -1823,16 +2277,17 @@ export class Backend { }; } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {import('api').InjectAnkiNoteMediaAudioDetails} details + * @returns {Promise<?string>} + */ async _injectAnkiNoteAudio(ankiConnect, timestamp, definitionDetails, details) { - const {type, term, reading} = definitionDetails; - if ( - type === 'kanji' || - typeof term !== 'string' || - typeof reading !== 'string' || - (term.length === 0 && reading.length === 0) - ) { - return null; - } + if (definitionDetails.type !== 'term') { return null; } + const {term, reading} = definitionDetails; + if (term.length === 0 && reading.length === 0) { return null; } const {sources, preferredAudioIndex, idleTimeout} = details; let data; @@ -1852,15 +2307,20 @@ export class Backend { return null; } - let extension = MediaUtil.getFileExtensionFromAudioMediaType(contentType); + let extension = contentType !== null ? MediaUtil.getFileExtensionFromAudioMediaType(contentType) : null; if (extension === null) { extension = '.mp3'; } let fileName = this._generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp, definitionDetails); fileName = fileName.replace(/\]/g, ''); - fileName = await ankiConnect.storeMediaFile(fileName, data); - - return fileName; + return await ankiConnect.storeMediaFile(fileName, data); } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {import('api').InjectAnkiNoteMediaScreenshotDetails} details + * @returns {Promise<?string>} + */ async _injectAnkiNoteScreenshot(ankiConnect, timestamp, definitionDetails, details) { const {tabId, frameId, format, quality} = details; const dataUrl = await this._getScreenshot(tabId, frameId, format, quality); @@ -1871,12 +2331,16 @@ export class Backend { throw new Error('Unknown media type for screenshot image'); } - let fileName = this._generateAnkiNoteMediaFileName('yomitan_browser_screenshot', extension, timestamp, definitionDetails); - fileName = await ankiConnect.storeMediaFile(fileName, data); - - return fileName; + const fileName = this._generateAnkiNoteMediaFileName('yomitan_browser_screenshot', extension, timestamp, definitionDetails); + return await ankiConnect.storeMediaFile(fileName, data); } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @returns {Promise<?string>} + */ async _injectAnkiNoteClipboardImage(ankiConnect, timestamp, definitionDetails) { const dataUrl = await this._clipboardReader.getImage(); if (dataUrl === null) { @@ -1889,12 +2353,17 @@ export class Backend { throw new Error('Unknown media type for clipboard image'); } - let fileName = this._generateAnkiNoteMediaFileName('yomitan_clipboard_image', extension, timestamp, definitionDetails); - fileName = await ankiConnect.storeMediaFile(fileName, data); - - return fileName; + const fileName = this._generateAnkiNoteMediaFileName('yomitan_clipboard_image', extension, timestamp, definitionDetails); + return await ankiConnect.storeMediaFile(fileName, data); } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} dictionaryMediaDetails + * @returns {Promise<{results: import('api').InjectAnkiNoteDictionaryMediaResult[], errors: unknown[]}>} + */ async _injectAnkiNoteDictionaryMedia(ankiConnect, timestamp, definitionDetails, dictionaryMediaDetails) { const targets = []; const detailsList = []; @@ -1918,6 +2387,7 @@ export class Backend { } const errors = []; + /** @type {import('api').InjectAnkiNoteDictionaryMediaResult[]} */ const results = []; for (let i = 0, ii = detailsList.length; i < ii; ++i) { const {dictionary, path, media} = detailsList[i]; @@ -1925,7 +2395,12 @@ export class Backend { if (media !== null) { const {content, mediaType} = media; const extension = MediaUtil.getFileExtensionFromImageMediaType(mediaType); - fileName = this._generateAnkiNoteMediaFileName(`yomitan_dictionary_media_${i + 1}`, extension, timestamp, definitionDetails); + fileName = this._generateAnkiNoteMediaFileName( + `yomitan_dictionary_media_${i + 1}`, + extension !== null ? extension : '', + timestamp, + definitionDetails + ); try { fileName = await ankiConnect.storeMediaFile(fileName, content); } catch (e) { @@ -1939,18 +2414,27 @@ export class Backend { return {results, errors}; } + /** + * @param {unknown} error + * @returns {?ExtensionError} + */ _getAudioDownloadError(error) { - if (isObject(error.data)) { - const {errors} = error.data; + if (error instanceof ExtensionError && typeof error.data === 'object' && error.data !== null) { + const {errors} = /** @type {import('core').SerializableObject} */ (error.data); if (Array.isArray(errors)) { for (const error2 of errors) { + if (!(error2 instanceof Error)) { continue; } if (error2.name === 'AbortError') { return this._createAudioDownloadError('Audio download was cancelled due to an idle timeout', 'audio-download-idle-timeout', errors); } - if (!isObject(error2.data)) { continue; } - const {details} = error2.data; - if (!isObject(details)) { continue; } - switch (details.error) { + if (!(error2 instanceof ExtensionError)) { continue; } + const {data} = error2; + if (!(typeof data === 'object' && data !== null)) { continue; } + const {details} = /** @type {import('core').SerializableObject} */ (data); + if (!(typeof details === 'object' && details !== null)) { continue; } + const error3 = /** @type {import('core').SerializableObject} */ (details).error; + if (typeof error3 !== 'string') { continue; } + switch (error3) { case 'net::ERR_FAILED': // This is potentially an error due to the extension not having enough URL privileges. // The message logged to the console looks like this: @@ -1967,23 +2451,38 @@ export class Backend { return null; } + /** + * @param {string} message + * @param {?string} issueId + * @param {?(Error[])} errors + * @returns {ExtensionError} + */ _createAudioDownloadError(message, issueId, errors) { - const error = new Error(message); + const error = new ExtensionError(message); const hasErrors = Array.isArray(errors); const hasIssueId = (typeof issueId === 'string'); if (hasErrors || hasIssueId) { + /** @type {{errors?: import('core').SerializedError[], referenceUrl?: string}} */ + const data = {}; error.data = {}; if (hasErrors) { // Errors need to be serialized since they are passed to other frames - error.data.errors = errors.map((e) => serializeError(e)); + data.errors = errors.map((e) => ExtensionError.serialize(e)); } if (hasIssueId) { - error.data.referenceUrl = `/issues.html#${issueId}`; + data.referenceUrl = `/issues.html#${issueId}`; } } return error; } + /** + * @param {string} prefix + * @param {string} extension + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @returns {string} + */ _generateAnkiNoteMediaFileName(prefix, extension, timestamp, definitionDetails) { let fileName = prefix; @@ -2011,11 +2510,19 @@ export class Backend { return fileName; } + /** + * @param {string} fileName + * @returns {string} + */ _replaceInvalidFileNameCharacters(fileName) { // eslint-disable-next-line no-control-regex return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-'); } + /** + * @param {Date} date + * @returns {string} + */ _ankNoteDateToString(date) { const year = date.getUTCFullYear(); const month = date.getUTCMonth().toString().padStart(2, '0'); @@ -2026,6 +2533,11 @@ export class Backend { return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`; } + /** + * @param {string} dataUrl + * @returns {{mediaType: string, data: string}} + * @throws {Error} + */ _getDataUrlInfo(dataUrl) { const match = /^data:([^,]*?)(;base64)?,/.exec(dataUrl); if (match === null) { @@ -2041,28 +2553,35 @@ export class Backend { return {mediaType, data}; } + /** + * @param {import('backend').DatabaseUpdateType} type + * @param {import('backend').DatabaseUpdateCause} cause + */ _triggerDatabaseUpdated(type, cause) { this._translator.clearDatabaseCaches(); this._sendMessageAllTabsIgnoreResponse('Yomitan.databaseUpdated', {type, cause}); } + /** + * @param {string} source + */ async _saveOptions(source) { this._clearProfileConditionsSchemaCache(); - const options = this._getOptionsFull(); + const options = this._getOptionsFull(false); await this._optionsUtil.save(options); this._applyOptions(source); } /** * Creates an options object for use with `Translator.findTerms`. - * @param {string} mode The display mode for the dictionary entries. - * @param {{matchType: string, deinflect: boolean}} details Custom info for finding terms. - * @param {object} options The options. - * @returns {FindTermsOptions} An options object. + * @param {import('translator').FindTermsMode} mode The display mode for the dictionary entries. + * @param {import('api').FindTermsDetails} details Custom info for finding terms. + * @param {import('settings').ProfileOptions} options The options. + * @returns {import('translation').FindTermsOptions} An options object. */ _getTranslatorFindTermsOptions(mode, details, options) { let {matchType, deinflect} = details; - if (typeof matchType !== 'string') { matchType = 'exact'; } + if (typeof matchType !== 'string') { matchType = /** @type {import('translation').FindTermsMatchType} */ ('exact'); } if (typeof deinflect !== 'boolean') { deinflect = true; } const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); const { @@ -2110,14 +2629,18 @@ export class Backend { /** * Creates an options object for use with `Translator.findKanji`. - * @param {object} options The options. - * @returns {FindKanjiOptions} An options object. + * @param {import('settings').ProfileOptions} options The options. + * @returns {import('translation').FindKanjiOptions} An options object. */ _getTranslatorFindKanjiOptions(options) { const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); return {enabledDictionaryMap}; } + /** + * @param {import('settings').ProfileOptions} options + * @returns {Map<string, import('translation').FindTermDictionary>} + */ _getTranslatorEnabledDictionaryMap(options) { const enabledDictionaryMap = new Map(); for (const dictionary of options.dictionaries) { @@ -2131,18 +2654,25 @@ export class Backend { return enabledDictionaryMap; } + /** + * @param {import('settings').TranslationTextReplacementOptions} textReplacementsOptions + * @returns {(?(import('translation').FindTermsTextReplacement[]))[]} + */ _getTranslatorTextReplacements(textReplacementsOptions) { + /** @type {(?(import('translation').FindTermsTextReplacement[]))[]} */ const textReplacements = []; for (const group of textReplacementsOptions.groups) { + /** @type {import('translation').FindTermsTextReplacement[]} */ const textReplacementsEntries = []; - for (let {pattern, ignoreCase, replacement} of group) { + for (const {pattern, ignoreCase, replacement} of group) { + let patternRegExp; try { - pattern = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); + patternRegExp = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); } catch (e) { // Invalid pattern continue; } - textReplacementsEntries.push({pattern, replacement}); + textReplacementsEntries.push({pattern: patternRegExp, replacement}); } if (textReplacementsEntries.length > 0) { textReplacements.push(textReplacementsEntries); @@ -2154,6 +2684,9 @@ export class Backend { return textReplacements; } + /** + * @returns {Promise<void>} + */ async _openWelcomeGuidePageOnce() { chrome.storage.session.get(['openedWelcomePage']).then((result) => { if (!result.openedWelcomePage) { @@ -2163,20 +2696,33 @@ export class Backend { }); } + /** + * @returns {Promise<void>} + */ async _openWelcomeGuidePage() { await this._createTab(chrome.runtime.getURL('/welcome.html')); } + /** + * @returns {Promise<void>} + */ async _openInfoPage() { await this._createTab(chrome.runtime.getURL('/info.html')); } + /** + * @param {'existingOrNewTab'|'newTab'} mode + */ async _openSettingsPage(mode) { const manifest = chrome.runtime.getManifest(); - const url = chrome.runtime.getURL(manifest.options_ui.page); + const optionsUI = manifest.options_ui; + if (typeof optionsUI === 'undefined') { throw new Error('Failed to find options_ui'); } + const {page} = optionsUI; + if (typeof page === 'undefined') { throw new Error('Failed to find options_ui.page'); } + const url = chrome.runtime.getURL(page); switch (mode) { case 'existingOrNewTab': - await new Promise((resolve, reject) => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.runtime.openOptionsPage(() => { const e = chrome.runtime.lastError; if (e) { @@ -2185,7 +2731,7 @@ export class Backend { resolve(); } }); - }); + })); break; case 'newTab': await this._createTab(url); @@ -2193,6 +2739,10 @@ export class Backend { } } + /** + * @param {string} url + * @returns {Promise<chrome.tabs.Tab>} + */ _createTab(url) { return new Promise((resolve, reject) => { chrome.tabs.create({url}, (tab) => { @@ -2206,6 +2756,10 @@ export class Backend { }); } + /** + * @param {number} tabId + * @returns {Promise<chrome.tabs.Tab>} + */ _getTabById(tabId) { return new Promise((resolve, reject) => { chrome.tabs.get( @@ -2222,20 +2776,33 @@ export class Backend { }); } + /** + * @returns {Promise<void>} + */ async _checkPermissions() { this._permissions = await this._permissionsUtil.getAllPermissions(); this._updateBadge(); } + /** + * @returns {boolean} + */ _canObservePermissionsChanges() { return isObject(chrome.permissions) && isObject(chrome.permissions.onAdded) && isObject(chrome.permissions.onRemoved); } + /** + * @param {import('settings').ProfileOptions} options + * @returns {boolean} + */ _hasRequiredPermissionsForSettings(options) { if (!this._canObservePermissionsChanges()) { return true; } return this._permissions === null || this._permissionsUtil.hasRequiredPermissionsForOptions(this._permissions, options); } + /** + * @returns {Promise<void>} + */ async _requestPersistentStorage() { try { if (await navigator.storage.persisted()) { return; } @@ -2257,14 +2824,32 @@ export class Backend { } } + /** + * @param {{path: string, dictionary: string}[]} targets + * @returns {Promise<import('dictionary-database').MediaDataStringContent[]>} + */ async _getNormalizedDictionaryDatabaseMedia(targets) { - const results = await this._dictionaryDatabase.getMedia(targets); - for (const item of results) { - const {content} = item; - if (content instanceof ArrayBuffer) { - item.content = ArrayBufferUtil.arrayBufferToBase64(content); - } + const results = []; + for (const item of await this._dictionaryDatabase.getMedia(targets)) { + const {content, dictionary, height, mediaType, path, width} = item; + const content2 = ArrayBufferUtil.arrayBufferToBase64(content); + results.push({content: content2, dictionary, height, mediaType, path, width}); } return results; } + + /** + * @param {unknown} mode + * @param {'existingOrNewTab'|'newTab'} defaultValue + * @returns {'existingOrNewTab'|'newTab'} + */ + _normalizeOpenSettingsPageMode(mode, defaultValue) { + switch (mode) { + case 'existingOrNewTab': + case 'newTab': + return mode; + default: + return defaultValue; + } + } } diff --git a/ext/js/background/profile-conditions-util.js b/ext/js/background/profile-conditions-util.js index 55b287d7..ceade070 100644 --- a/ext/js/background/profile-conditions-util.js +++ b/ext/js/background/profile-conditions-util.js @@ -23,67 +23,55 @@ import {JsonSchema} from '../data/json-schema.js'; */ export class ProfileConditionsUtil { /** - * A group of conditions. - * @typedef {object} ProfileConditionGroup - * @property {ProfileCondition[]} conditions The list of conditions for this group. - */ - - /** - * A single condition. - * @typedef {object} ProfileCondition - * @property {string} type The type of the condition. - * @property {string} operator The condition operator. - * @property {string} value The value to compare against. - */ - - /** * Creates a new instance. */ constructor() { + /** @type {RegExp} */ this._splitPattern = /[,;\s]+/; + /** @type {Map<string, {operators: Map<string, import('profile-conditions-util').CreateSchemaFunction>}>} */ this._descriptors = new Map([ [ 'popupLevel', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['equal', this._createSchemaPopupLevelEqual.bind(this)], ['notEqual', this._createSchemaPopupLevelNotEqual.bind(this)], ['lessThan', this._createSchemaPopupLevelLessThan.bind(this)], ['greaterThan', this._createSchemaPopupLevelGreaterThan.bind(this)], ['lessThanOrEqual', this._createSchemaPopupLevelLessThanOrEqual.bind(this)], ['greaterThanOrEqual', this._createSchemaPopupLevelGreaterThanOrEqual.bind(this)] - ]) + ])) } ], [ 'url', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['matchDomain', this._createSchemaUrlMatchDomain.bind(this)], ['matchRegExp', this._createSchemaUrlMatchRegExp.bind(this)] - ]) + ])) } ], [ 'modifierKeys', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['are', this._createSchemaModifierKeysAre.bind(this)], ['areNot', this._createSchemaModifierKeysAreNot.bind(this)], ['include', this._createSchemaModifierKeysInclude.bind(this)], ['notInclude', this._createSchemaModifierKeysNotInclude.bind(this)] - ]) + ])) } ], [ 'flags', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['are', this._createSchemaFlagsAre.bind(this)], ['areNot', this._createSchemaFlagsAreNot.bind(this)], ['include', this._createSchemaFlagsInclude.bind(this)], ['notInclude', this._createSchemaFlagsNotInclude.bind(this)] - ]) + ])) } ] ]); @@ -91,7 +79,7 @@ export class ProfileConditionsUtil { /** * Creates a new JSON schema descriptor for the given set of condition groups. - * @param {ProfileConditionGroup[]} conditionGroups An array of condition groups. + * @param {import('settings').ProfileConditionGroup[]} conditionGroups An array of condition groups. * For a profile match, all of the items must return successfully in at least one of the groups. * @returns {JsonSchema} A new `JsonSchema` object. */ @@ -127,11 +115,11 @@ export class ProfileConditionsUtil { /** * Creates a normalized version of the context object to test, * assigning dependent fields as needed. - * @param {object} context A context object which is used during schema validation. - * @returns {object} A normalized context object. + * @param {import('settings').OptionsContext} context A context object which is used during schema validation. + * @returns {import('profile-conditions-util').NormalizedOptionsContext} A normalized context object. */ normalizeContext(context) { - const normalizedContext = Object.assign({}, context); + const normalizedContext = /** @type {import('profile-conditions-util').NormalizedOptionsContext} */ (Object.assign({}, context)); const {url} = normalizedContext; if (typeof url === 'string') { try { @@ -149,10 +137,18 @@ export class ProfileConditionsUtil { // Private + /** + * @param {string} value + * @returns {string[]} + */ _split(value) { return value.split(this._splitPattern); } + /** + * @param {string} value + * @returns {number} + */ _stringToNumber(value) { const number = Number.parseFloat(value); return Number.isFinite(number) ? number : 0; @@ -160,64 +156,94 @@ export class ProfileConditionsUtil { // popupLevel schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelEqual(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {const: value} + depth: {const: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelNotEqual(value) { return { - not: [this._createSchemaPopupLevelEqual(value)] + not: { + anyOf: [this._createSchemaPopupLevelEqual(value)] + } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelLessThan(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', exclusiveMaximum: value} + depth: {type: 'number', exclusiveMaximum: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelGreaterThan(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', exclusiveMinimum: value} + depth: {type: 'number', exclusiveMinimum: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelLessThanOrEqual(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', maximum: value} + depth: {type: 'number', maximum: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelGreaterThanOrEqual(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', minimum: value} + depth: {type: 'number', minimum: number} } }; } // url schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaUrlMatchDomain(value) { const oneOf = []; for (let domain of this._split(value)) { @@ -233,6 +259,10 @@ export class ProfileConditionsUtil { }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaUrlMatchRegExp(value) { return { required: ['url'], @@ -244,47 +274,91 @@ export class ProfileConditionsUtil { // modifierKeys schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysAre(value) { return this._createSchemaArrayCheck('modifierKeys', value, true, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysAreNot(value) { return { - not: [this._createSchemaArrayCheck('modifierKeys', value, true, false)] + not: { + anyOf: [this._createSchemaArrayCheck('modifierKeys', value, true, false)] + } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysInclude(value) { return this._createSchemaArrayCheck('modifierKeys', value, false, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysNotInclude(value) { return this._createSchemaArrayCheck('modifierKeys', value, false, true); } // modifierKeys schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsAre(value) { return this._createSchemaArrayCheck('flags', value, true, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsAreNot(value) { return { - not: [this._createSchemaArrayCheck('flags', value, true, false)] + not: { + anyOf: [this._createSchemaArrayCheck('flags', value, true, false)] + } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsInclude(value) { return this._createSchemaArrayCheck('flags', value, false, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsNotInclude(value) { return this._createSchemaArrayCheck('flags', value, false, true); } // Generic + /** + * @param {string} key + * @param {string} value + * @param {boolean} exact + * @param {boolean} none + * @returns {import('json-schema').Schema} + */ _createSchemaArrayCheck(key, value, exact, none) { + /** @type {import('json-schema').Schema[]} */ const containsList = []; for (const item of this._split(value)) { if (item.length === 0) { continue; } @@ -295,6 +369,7 @@ export class ProfileConditionsUtil { }); } const containsListCount = containsList.length; + /** @type {import('json-schema').Schema} */ const schema = { type: 'array' }; @@ -303,7 +378,7 @@ export class ProfileConditionsUtil { } if (none) { if (containsListCount > 0) { - schema.not = containsList; + schema.not = {anyOf: containsList}; } } else { schema.minItems = containsListCount; diff --git a/ext/js/background/request-builder.js b/ext/js/background/request-builder.js index f4f685be..48fe2dd9 100644 --- a/ext/js/background/request-builder.js +++ b/ext/js/background/request-builder.js @@ -31,7 +31,13 @@ export class RequestBuilder { * Creates a new instance. */ constructor() { + /** + * + */ this._textEncoder = new TextEncoder(); + /** + * + */ this._ruleIds = new Set(); } @@ -160,6 +166,9 @@ export class RequestBuilder { // Private + /** + * + */ async _clearSessionRules() { const rules = await this._getSessionRules(); @@ -173,6 +182,9 @@ export class RequestBuilder { await this._updateSessionRules({removeRuleIds}); } + /** + * + */ _getSessionRules() { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.getSessionRules((result) => { @@ -186,6 +198,10 @@ export class RequestBuilder { }); } + /** + * + * @param options + */ _updateSessionRules(options) { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.updateSessionRules(options, () => { @@ -199,6 +215,10 @@ export class RequestBuilder { }); } + /** + * + * @param options + */ async _tryUpdateSessionRules(options) { try { await this._updateSessionRules(options); @@ -208,6 +228,9 @@ export class RequestBuilder { } } + /** + * + */ async _clearDynamicRules() { const rules = await this._getDynamicRules(); @@ -221,6 +244,9 @@ export class RequestBuilder { await this._updateDynamicRules({removeRuleIds}); } + /** + * + */ _getDynamicRules() { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.getDynamicRules((result) => { @@ -234,6 +260,10 @@ export class RequestBuilder { }); } + /** + * + * @param options + */ _updateDynamicRules(options) { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.updateDynamicRules(options, () => { @@ -247,6 +277,9 @@ export class RequestBuilder { }); } + /** + * + */ _getNewRuleId() { let id = 1; while (this._ruleIds.has(id)) { @@ -257,15 +290,27 @@ export class RequestBuilder { return id; } + /** + * + * @param url + */ _getOriginURL(url) { const url2 = new URL(url); return `${url2.protocol}//${url2.host}`; } + /** + * + * @param url + */ _escapeDnrUrl(url) { return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char)); } + /** + * + * @param text + */ _urlEncodeUtf8(text) { const array = this._textEncoder.encode(text); let result = ''; @@ -275,6 +320,11 @@ export class RequestBuilder { return result; } + /** + * + * @param items + * @param totalLength + */ static _joinUint8Arrays(items, totalLength) { if (items.length === 1) { const {array, length} = items[0]; diff --git a/ext/js/background/script-manager.js b/ext/js/background/script-manager.js index 3671b854..98f67bb0 100644 --- a/ext/js/background/script-manager.js +++ b/ext/js/background/script-manager.js @@ -17,6 +17,7 @@ */ import {isObject} from '../core.js'; + /** * This class is used to manage script injection into content tabs. */ @@ -25,18 +26,19 @@ export class ScriptManager { * Creates a new instance of the class. */ constructor() { + /** @type {Map<string, ?browser.contentScripts.RegisteredContentScript>} */ this._contentScriptRegistrations = new Map(); } /** * Injects a stylesheet into a tab. - * @param {string} type The type of content to inject; either 'file' or 'code'. + * @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'. * @param {string} content The content to inject. * If type is 'file', this argument should be a path to a file. * If type is 'code', this argument should be the CSS content. * @param {number} tabId The id of the tab to inject into. - * @param {number} [frameId] The id of the frame to inject into. - * @param {boolean} [allFrames] Whether or not the stylesheet should be injected into all frames. + * @param {number|undefined} frameId The id of the frame to inject into. + * @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames. * @returns {Promise<void>} */ injectStylesheet(type, content, tabId, frameId, allFrames) { @@ -51,9 +53,9 @@ export class ScriptManager { * Injects a script into a tab. * @param {string} file The path to a file to inject. * @param {number} tabId The id of the tab to inject into. - * @param {number} [frameId] The id of the frame to inject into. - * @param {boolean} [allFrames] Whether or not the script should be injected into all frames. - * @returns {Promise<{frameId: number, result: object}>} The id of the frame and the result of the script injection. + * @param {number|undefined} frameId The id of the frame to inject into. + * @param {boolean} allFrames Whether or not the script should be injected into all frames. + * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection. */ injectScript(file, tabId, frameId, allFrames) { if (isObject(chrome.scripting) && typeof chrome.scripting.executeScript === 'function') { @@ -98,16 +100,7 @@ export class ScriptManager { * there is a possibility that the script can be injected more than once due to the events used. * Therefore, a reentrant check may need to be performed by the content script. * @param {string} id A unique identifier for the registration. - * @param {object} details The script registration details. - * @param {boolean} [details.allFrames] Same as `all_frames` in the `content_scripts` manifest key. - * @param {string[]} [details.css] List of CSS paths. - * @param {string[]} [details.excludeMatches] Same as `exclude_matches` in the `content_scripts` manifest key. - * @param {string[]} [details.js] List of script paths. - * @param {boolean} [details.matchAboutBlank] Same as `match_about_blank` in the `content_scripts` manifest key. - * @param {string[]} details.matches Same as `matches` in the `content_scripts` manifest key. - * @param {string} [details.urlMatches] Regex match pattern to use as a fallback - * when native content script registration isn't supported. Should be equivalent to `matches`. - * @param {string} [details.runAt] Same as `run_at` in the `content_scripts` manifest key. + * @param {import('script-manager').RegistrationDetails} details The script registration details. * @throws An error is thrown if the id is already in use. */ async registerContentScript(id, details) { @@ -116,8 +109,8 @@ export class ScriptManager { } if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') { - const details2 = this._convertContentScriptRegistrationDetails(details, id, false); - await new Promise((resolve, reject) => { + const details2 = this._createContentScriptRegistrationOptionsChrome(details, id); + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.scripting.registerContentScripts([details2], () => { const e = chrome.runtime.lastError; if (e) { @@ -126,7 +119,7 @@ export class ScriptManager { resolve(); } }); - }); + })); this._contentScriptRegistrations.set(id, null); return; } @@ -155,7 +148,7 @@ export class ScriptManager { const registration = this._contentScriptRegistrations.get(id); if (typeof registration === 'undefined') { return false; } this._contentScriptRegistrations.delete(id); - if (isObject(registration) && typeof registration.unregister === 'function') { + if (registration !== null && typeof registration.unregister === 'function') { await registration.unregister(); } return true; @@ -176,17 +169,27 @@ export class ScriptManager { // Private + /** + * @param {'file'|'code'} type + * @param {string} content + * @param {number} tabId + * @param {number|undefined} frameId + * @param {boolean} allFrames + * @returns {Promise<void>} + */ _injectStylesheetMV3(type, content, tabId, frameId, allFrames) { return new Promise((resolve, reject) => { - const details = ( - type === 'file' ? - {origin: 'AUTHOR', files: [content]} : - {origin: 'USER', css: content} - ); - details.target = { + /** @type {chrome.scripting.InjectionTarget} */ + const target = { tabId, allFrames }; + /** @type {chrome.scripting.CSSInjection} */ + const details = ( + type === 'file' ? + {origin: 'AUTHOR', files: [content], target} : + {origin: 'USER', css: content, target} + ); if (!allFrames && typeof frameId === 'number') { details.target.frameIds = [frameId]; } @@ -201,8 +204,16 @@ export class ScriptManager { }); } + /** + * @param {string} file + * @param {number} tabId + * @param {number|undefined} frameId + * @param {boolean} allFrames + * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection. + */ _injectScriptMV3(file, tabId, frameId, allFrames) { return new Promise((resolve, reject) => { + /** @type {chrome.scripting.ScriptInjection<unknown[], unknown>} */ const details = { injectImmediately: true, files: [file], @@ -223,6 +234,10 @@ export class ScriptManager { }); } + /** + * @param {string} id + * @returns {Promise<void>} + */ _unregisterContentScriptMV3(id) { return new Promise((resolve, reject) => { chrome.scripting.unregisterContentScripts({ids: [id]}, () => { @@ -236,73 +251,132 @@ export class ScriptManager { }); } - _convertContentScriptRegistrationDetails(details, id, firefoxConvention) { - const {allFrames, css, excludeMatches, js, matchAboutBlank, matches, runAt} = details; - const details2 = {}; - if (!firefoxConvention) { - details2.id = id; - details2.persistAcrossSessions = true; + /** + * @param {import('script-manager').RegistrationDetails} details + * @returns {browser.contentScripts.RegisteredContentScriptOptions} + */ + _createContentScriptRegistrationOptionsFirefox(details) { + const {css, js, matchAboutBlank} = details; + /** @type {browser.contentScripts.RegisteredContentScriptOptions} */ + const options = {}; + if (typeof matchAboutBlank !== 'undefined') { + options.matchAboutBlank = matchAboutBlank; } + if (Array.isArray(css)) { + options.css = css.map((file) => ({file})); + } + if (Array.isArray(js)) { + options.js = js.map((file) => ({file})); + } + this._initializeContentScriptRegistrationOptionsGeneric(details, options); + return options; + } + + /** + * @param {import('script-manager').RegistrationDetails} details + * @param {string} id + * @returns {chrome.scripting.RegisteredContentScript} + */ + _createContentScriptRegistrationOptionsChrome(details, id) { + const {css, js} = details; + /** @type {chrome.scripting.RegisteredContentScript} */ + const options = { + id: id, + persistAcrossSessions: true + }; + if (Array.isArray(css)) { + options.css = [...css]; + } + if (Array.isArray(js)) { + options.js = [...js]; + } + this._initializeContentScriptRegistrationOptionsGeneric(details, options); + return options; + } + + /** + * @param {import('script-manager').RegistrationDetails} details + * @param {chrome.scripting.RegisteredContentScript|browser.contentScripts.RegisteredContentScriptOptions} options + */ + _initializeContentScriptRegistrationOptionsGeneric(details, options) { + const {allFrames, excludeMatches, matches, runAt} = details; if (typeof allFrames !== 'undefined') { - details2.allFrames = allFrames; + options.allFrames = allFrames; } if (Array.isArray(excludeMatches)) { - details2.excludeMatches = [...excludeMatches]; + options.excludeMatches = [...excludeMatches]; } if (Array.isArray(matches)) { - details2.matches = [...matches]; + options.matches = [...matches]; } if (typeof runAt !== 'undefined') { - details2.runAt = runAt; - } - if (firefoxConvention && typeof matchAboutBlank !== 'undefined') { - details2.matchAboutBlank = matchAboutBlank; + options.runAt = runAt; } - if (Array.isArray(css)) { - details2.css = this._convertFileArray(css, firefoxConvention); - } - if (Array.isArray(js)) { - details2.js = this._convertFileArray(js, firefoxConvention); - } - return details2; } + /** + * @param {string[]} array + * @param {boolean} firefoxConvention + * @returns {string[]|browser.extensionTypes.ExtensionFileOrCode[]} + */ _convertFileArray(array, firefoxConvention) { return firefoxConvention ? array.map((file) => ({file})) : [...array]; } + /** + * @param {string} id + * @param {import('script-manager').RegistrationDetails} details + */ _registerContentScriptFallback(id, details) { const {allFrames, css, js, matchAboutBlank, runAt, urlMatches} = details; - const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex: null}; + /** @type {import('script-manager').ContentScriptInjectionDetails} */ + const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex: /** @type {?RegExp} */ (null)}; + /** @type {() => Promise<void>} */ let unregister; const webNavigationEvent = this._getWebNavigationEvent(runAt); - if (isObject(webNavigationEvent)) { + if (typeof webNavigationEvent === 'object' && webNavigationEvent !== null) { + /** + * @param {chrome.webNavigation.WebNavigationFramedCallbackDetails} details + */ const onTabCommitted = ({url, tabId, frameId}) => { this._injectContentScript(true, details2, null, url, tabId, frameId); }; const filter = {url: [{urlMatches}]}; webNavigationEvent.addListener(onTabCommitted, filter); - unregister = () => webNavigationEvent.removeListener(onTabCommitted); + unregister = async () => webNavigationEvent.removeListener(onTabCommitted); } else { + /** + * @param {number} tabId + * @param {chrome.tabs.TabChangeInfo} changeInfo + * @param {chrome.tabs.Tab} tab + */ const onTabUpdated = (tabId, {status}, {url}) => { if (typeof status === 'string' && typeof url === 'string') { this._injectContentScript(false, details2, status, url, tabId, void 0); } }; - const extraParameters = {url: [urlMatches], properties: ['status']}; try { // Firefox - chrome.tabs.onUpdated.addListener(onTabUpdated, extraParameters); + /** @type {browser.tabs.UpdateFilter} */ + const extraParameters = {urls: [urlMatches], properties: ['status']}; + browser.tabs.onUpdated.addListener( + /** @type {(tabId: number, changeInfo: browser.tabs._OnUpdatedChangeInfo, tab: browser.tabs.Tab) => void} */ (onTabUpdated), + extraParameters + ); } catch (e) { // Chrome details2.urlRegex = new RegExp(urlMatches); chrome.tabs.onUpdated.addListener(onTabUpdated); } - unregister = () => chrome.tabs.onUpdated.removeListener(onTabUpdated); + unregister = async () => chrome.tabs.onUpdated.removeListener(onTabUpdated); } this._contentScriptRegistrations.set(id, {unregister}); } + /** + * @param {import('script-manager').RunAt} runAt + * @returns {?(chrome.webNavigation.WebNavigationFramedEvent|chrome.webNavigation.WebNavigationTransitionalEvent)} + */ _getWebNavigationEvent(runAt) { const {webNavigation} = chrome; if (!isObject(webNavigation)) { return null; } @@ -316,6 +390,14 @@ export class ScriptManager { } } + /** + * @param {boolean} isWebNavigation + * @param {import('script-manager').ContentScriptInjectionDetails} details + * @param {?string} status + * @param {string} url + * @param {number} tabId + * @param {number|undefined} frameId + */ async _injectContentScript(isWebNavigation, details, status, url, tabId, frameId) { const {urlRegex} = details; if (urlRegex !== null && !urlRegex.test(url)) { return; } |