diff options
Diffstat (limited to 'ext/js/background')
| -rw-r--r-- | ext/js/background/backend.js | 1002 | ||||
| -rw-r--r-- | ext/js/background/offscreen-proxy.js | 162 | ||||
| -rw-r--r-- | ext/js/background/offscreen.js | 126 | ||||
| -rw-r--r-- | ext/js/background/profile-conditions-util.js | 155 | ||||
| -rw-r--r-- | ext/js/background/request-builder.js | 88 | ||||
| -rw-r--r-- | ext/js/background/script-manager.js | 186 | 
6 files changed, 1308 insertions, 411 deletions
| diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index bf4841f8..3eefed53 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -22,7 +22,8 @@ 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 {OptionsUtil} from '../data/options-util.js';  import {PermissionsUtil} from '../data/permissions-util.js'; @@ -35,7 +36,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 +50,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 +79,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 {import('../data/json-schema.js').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 {?import('core').Timeout} */          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 +192,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 +216,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 +233,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 +259,9 @@ export class Backend {          chrome.runtime.onInstalled.addListener(this._onInstalled.bind(this));      } +    /** +     * @returns {Promise<void>} +     */      async _prepareInternal() {          try {              this._prepareInternalSync(); @@ -224,11 +274,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 +289,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 +320,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 +354,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 +370,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 +388,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 +409,7 @@ export class Backend {              try {                  this._validatePrivilegedMessageSender(sender);              } catch (error) { -                callback({error: serializeError(error)}); +                callback({error: ExtensionError.serialize(error)});                  return false;              }          } @@ -342,14 +417,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 +441,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 +449,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 +477,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 +487,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 +520,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 +572,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 +585,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 +604,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 +615,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'); } +        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);      } +    /** @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 +721,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 +778,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 +791,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 +869,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 +895,7 @@ export class Backend {          }      } +    /** @type {import('api').Handler<import('api').OpenCrossFramePortDetails, import('api').OpenCrossFramePortResult, true>} */      _onApiOpenCrossFramePort({targetTabId, targetFrameId}, sender) {          const sourceTabId = (sender && sender.tab ? sender.tab.id : null);          if (typeof sourceTabId !== 'number') { @@ -773,7 +916,9 @@ export class Backend {              otherTabId: sourceTabId,              otherFrameId: sourceFrameId          }; +        /** @type {?chrome.runtime.Port} */          let sourcePort = chrome.tabs.connect(sourceTabId, {frameId: sourceFrameId, name: JSON.stringify(sourceDetails)}); +        /** @type {?chrome.runtime.Port} */          let targetPort = chrome.tabs.connect(targetTabId, {frameId: targetFrameId, name: JSON.stringify(targetDetails)});          const cleanup = () => { @@ -788,8 +933,12 @@ export class Backend {              }          }; -        sourcePort.onMessage.addListener((message) => { targetPort.postMessage(message); }); -        targetPort.onMessage.addListener((message) => { sourcePort.postMessage(message); }); +        sourcePort.onMessage.addListener((message) => { +            if (targetPort !== null) { targetPort.postMessage(message); } +        }); +        targetPort.onMessage.addListener((message) => { +            if (sourcePort !== null) { sourcePort.postMessage(message); } +        });          sourcePort.onDisconnect.addListener(cleanup);          targetPort.onDisconnect.addListener(cleanup); @@ -798,18 +947,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 +980,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 +1010,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 +1086,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 +1109,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 +1122,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 +1172,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 +1193,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 +1206,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 +1235,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 +1278,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 +1330,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 +1350,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 +1359,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 +1397,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 +1421,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 +1450,10 @@ export class Backend {          return results;      } +    /** +     * @param {string} text +     * @returns {Promise<import('backend').MecabParseResults>} +     */      async _textParseMecab(text) {          const jp = this._japaneseUtil; @@ -1178,8 +1464,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 +1488,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 +1539,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 +1559,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 +1569,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 +1602,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 +1636,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 +1696,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 +1711,9 @@ export class Backend {          throw new Error('Invalid message sender');      } +    /** +     * @returns {Promise<string>} +     */      _getBrowserIconTitle() {          return (              isObject(chrome.action) && @@ -1388,6 +1723,9 @@ export class Backend {          );      } +    /** +     * @returns {void} +     */      _updateBadge() {          let title = this._defaultBrowserActionTitle;          if (title === null || !isObject(chrome.action)) { @@ -1423,7 +1761,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 +1791,10 @@ export class Backend {          }      } +    /** +     * @param {import('settings').ProfileOptions} options +     * @returns {boolean} +     */      _isAnyDictionaryEnabled(options) {          for (const {enabled} of options.dictionaries) {              if (enabled) { @@ -1462,21 +1804,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 +1825,9 @@ export class Backend {          return null;      } +    /** +     * @returns {Promise<chrome.tabs.Tab[]>} +     */      _getAllTabs() {          return new Promise((resolve, reject) => {              chrome.tabs.query({}, (tabs) => { @@ -1499,21 +1841,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 +1880,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 +1897,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 +1920,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 +1938,7 @@ export class Backend {                      resolve();                  }              }); -        }); +        }));          if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) {              // Windows not supported (e.g. on Firefox mobile) @@ -1585,7 +1957,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 +1966,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 {?import('core').Timeout} */              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 +2031,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 +2047,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 +2120,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 +2162,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 +2204,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 +2226,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 +2234,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 +2242,7 @@ export class Backend {                  clipboardText = await this._clipboardReader.getText(false);              }          } catch (e) { -            errors.push(serializeError(e)); +            errors.push(ExtensionError.serialize(e));          }          try { @@ -1798,19 +2250,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 +2276,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 +2306,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 +2330,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 +2352,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 +2386,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 +2394,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 +2413,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 +2450,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 +2509,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 +2532,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 +2552,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 +2628,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 +2653,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 +2683,9 @@ export class Backend {          return textReplacements;      } +    /** +     * @returns {Promise<void>} +     */      async _openWelcomeGuidePageOnce() {          chrome.storage.session.get(['openedWelcomePage']).then((result) => {              if (!result.openedWelcomePage) { @@ -2163,20 +2695,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 +2730,7 @@ export class Backend {                              resolve();                          }                      }); -                }); +                }));                  break;              case 'newTab':                  await this._createTab(url); @@ -2193,6 +2738,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 +2755,10 @@ export class Backend {          });      } +    /** +     * @param {number} tabId +     * @returns {Promise<chrome.tabs.Tab>} +     */      _getTabById(tabId) {          return new Promise((resolve, reject) => {              chrome.tabs.get( @@ -2222,20 +2775,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 +2823,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/offscreen-proxy.js b/ext/js/background/offscreen-proxy.js index c01f523d..63f619fa 100644 --- a/ext/js/background/offscreen-proxy.js +++ b/ext/js/background/offscreen-proxy.js @@ -16,15 +16,19 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -import {deserializeError, isObject} from '../core.js'; +import {isObject} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js';  import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';  export class OffscreenProxy {      constructor() { +        /** @type {?Promise<void>} */          this._creatingOffscreen = null;      } -    // https://developer.chrome.com/docs/extensions/reference/offscreen/ +    /** +     * @see https://developer.chrome.com/docs/extensions/reference/offscreen/ +     */      async prepare() {          if (await this._hasOffscreenDocument()) {              return; @@ -36,20 +40,30 @@ export class OffscreenProxy {          this._creatingOffscreen = chrome.offscreen.createDocument({              url: 'offscreen.html', -            reasons: ['CLIPBOARD'], +            reasons: [ +                /** @type {chrome.offscreen.Reason} */ ('CLIPBOARD') +            ],              justification: 'Access to the clipboard'          });          await this._creatingOffscreen;          this._creatingOffscreen = null;      } +    /** +     * @returns {Promise<boolean>} +     */      async _hasOffscreenDocument() {          const offscreenUrl = chrome.runtime.getURL('offscreen.html'); -        if (!chrome.runtime.getContexts) { // chrome version <116 +        // @ts-expect-error - API not defined yet +        if (!chrome.runtime.getContexts) { // chrome version below 116 +            // Clients: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/clients +            // @ts-expect-error - Types not set up for service workers yet              const matchedClients = await clients.matchAll(); +            // @ts-expect-error - Types not set up for service workers yet              return await matchedClients.some((client) => client.url === offscreenUrl);          } +        // @ts-expect-error - API not defined yet          const contexts = await chrome.runtime.getContexts({              contextTypes: ['OFFSCREEN_DOCUMENT'],              documentUrls: [offscreenUrl] @@ -57,116 +71,186 @@ export class OffscreenProxy {          return !!contexts.length;      } -    sendMessagePromise(...args) { +    /** +     * @template {import('offscreen').MessageType} TMessageType +     * @param {import('offscreen').Message<TMessageType>} message +     * @returns {Promise<import('offscreen').MessageReturn<TMessageType>>} +     */ +    sendMessagePromise(message) {          return new Promise((resolve, reject) => { -            const callback = (response) => { +            chrome.runtime.sendMessage(message, (response) => {                  try {                      resolve(this._getMessageResponseResult(response));                  } catch (error) {                      reject(error);                  } -            }; - -            chrome.runtime.sendMessage(...args, callback); +            });          });      } +    /** +     * @template [TReturn=unknown] +     * @param {import('core').Response<TReturn>} response +     * @returns {TReturn} +     * @throws {Error} +     */      _getMessageResponseResult(response) { -        let error = chrome.runtime.lastError; +        const error = chrome.runtime.lastError;          if (error) {              throw new Error(error.message);          }          if (!isObject(response)) {              throw new Error('Offscreen document did not respond');          } -        error = response.error; -        if (error) { -            throw deserializeError(error); +        const error2 = response.error; +        if (error2) { +            throw ExtensionError.deserialize(error2);          }          return response.result;      }  }  export class DictionaryDatabaseProxy { +    /** +     * @param {OffscreenProxy} offscreen +     */      constructor(offscreen) { +        /** @type {OffscreenProxy} */          this._offscreen = offscreen;      } -    prepare() { -        return this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'}); +    /** +     * @returns {Promise<void>} +     */ +    async prepare() { +        await this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'});      } -    getDictionaryInfo() { +    /** +     * @returns {Promise<import('dictionary-importer').Summary[]>} +     */ +    async getDictionaryInfo() {          return this._offscreen.sendMessagePromise({action: 'getDictionaryInfoOffscreen'});      } -    purge() { -        return this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'}); +    /** +     * @returns {Promise<boolean>} +     */ +    async purge() { +        return await this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'});      } +    /** +     * @param {import('dictionary-database').MediaRequest[]} targets +     * @returns {Promise<import('dictionary-database').Media[]>} +     */      async getMedia(targets) { -        const serializedMedia = await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}}); +        const serializedMedia = /** @type {import('dictionary-database').Media<string>[]} */ (await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}}));          const media = serializedMedia.map((m) => ({...m, content: ArrayBufferUtil.base64ToArrayBuffer(m.content)}));          return media;      }  }  export class TranslatorProxy { +    /** +     * @param {OffscreenProxy} offscreen +     */      constructor(offscreen) { +        /** @type {OffscreenProxy} */          this._offscreen = offscreen;      } -    prepare(deinflectionReasons) { -        return this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen', params: {deinflectionReasons}}); +    /** +     * @param {import('deinflector').ReasonsRaw} deinflectionReasons +     */ +    async prepare(deinflectionReasons) { +        await this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen', params: {deinflectionReasons}});      } -    async findKanji(text, findKanjiOptions) { -        const enabledDictionaryMapList = [...findKanjiOptions.enabledDictionaryMap]; -        const modifiedKanjiOptions = { -            ...findKanjiOptions, +    /** +     * @param {string} text +     * @param {import('translation').FindKanjiOptions} options +     * @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>} +     */ +    async findKanji(text, options) { +        const enabledDictionaryMapList = [...options.enabledDictionaryMap]; +        /** @type {import('offscreen').FindKanjiOptionsOffscreen} */ +        const modifiedOptions = { +            ...options,              enabledDictionaryMap: enabledDictionaryMapList          }; -        return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, findKanjiOptions: modifiedKanjiOptions}}); +        return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, options: modifiedOptions}});      } -    async findTerms(mode, text, findTermsOptions) { -        const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = findTermsOptions; +    /** +     * @param {import('translator').FindTermsMode} mode +     * @param {string} text +     * @param {import('translation').FindTermsOptions} options +     * @returns {Promise<import('translator').FindTermsResult>} +     */ +    async findTerms(mode, text, options) { +        const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = options;          const enabledDictionaryMapList = [...enabledDictionaryMap];          const excludeDictionaryDefinitionsList = excludeDictionaryDefinitions ? [...excludeDictionaryDefinitions] : null;          const textReplacementsSerialized = textReplacements.map((group) => { -            if (!group) { -                return group; -            } -            return group.map((opt) => ({...opt, pattern: opt.pattern.toString()})); +            return group !== null ? group.map((opt) => ({...opt, pattern: opt.pattern.toString()})) : null;          }); -        const modifiedFindTermsOptions = { -            ...findTermsOptions, +        /** @type {import('offscreen').FindTermsOptionsOffscreen} */ +        const modifiedOptions = { +            ...options,              enabledDictionaryMap: enabledDictionaryMapList,              excludeDictionaryDefinitions: excludeDictionaryDefinitionsList,              textReplacements: textReplacementsSerialized          }; -        return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, findTermsOptions: modifiedFindTermsOptions}}); +        return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, options: modifiedOptions}});      } +    /** +     * @param {import('translator').TermReadingList} termReadingList +     * @param {string[]} dictionaries +     * @returns {Promise<import('translator').TermFrequencySimple[]>} +     */      async getTermFrequencies(termReadingList, dictionaries) {          return this._offscreen.sendMessagePromise({action: 'getTermFrequenciesOffscreen', params: {termReadingList, dictionaries}});      } -    clearDatabaseCaches() { -        return this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'}); +    /** */ +    async clearDatabaseCaches() { +        await this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'});      }  }  export class ClipboardReaderProxy { +    /** +     * @param {OffscreenProxy} offscreen +     */      constructor(offscreen) { +        /** @type {?import('environment').Browser} */ +        this._browser = null; +        /** @type {OffscreenProxy} */          this._offscreen = offscreen;      } +    /** @type {?import('environment').Browser} */ +    get browser() { return this._browser; } +    set browser(value) { +        if (this._browser === value) { return; } +        this._browser = value; +        this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffscreen', params: {value}}); +    } + +    /** +     * @param {boolean} useRichText +     * @returns {Promise<string>} +     */      async getText(useRichText) { -        return this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}}); +        return await this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}});      } +    /** +     * @returns {Promise<?string>} +     */      async getImage() { -        return this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'}); +        return await this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'});      }  } diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js index 27cee8c4..4b57514d 100644 --- a/ext/js/background/offscreen.js +++ b/ext/js/background/offscreen.js @@ -23,7 +23,6 @@ import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';  import {DictionaryDatabase} from '../language/dictionary-database.js';  import {JapaneseUtil} from '../language/sandbox/japanese-util.js';  import {Translator} from '../language/translator.js'; -import {yomitan} from '../yomitan.js';  /**   * This class controls the core logic of the extension, including API calls @@ -34,12 +33,16 @@ export class Offscreen {       * Creates a new instance.       */      constructor() { +        /** @type {JapaneseUtil} */          this._japaneseUtil = new JapaneseUtil(wanakana); +        /** @type {DictionaryDatabase} */          this._dictionaryDatabase = new DictionaryDatabase(); +        /** @type {Translator} */          this._translator = new Translator({              japaneseUtil: this._japaneseUtil,              database: this._dictionaryDatabase          }); +        /** @type {ClipboardReader} */          this._clipboardReader = new ClipboardReader({              // eslint-disable-next-line no-undef              document: (typeof document === 'object' && document !== null ? document : null), @@ -47,35 +50,47 @@ export class Offscreen {              richContentPasteTargetSelector: '#clipboard-rich-content-paste-target'          }); -        this._messageHandlers = new Map([ -            ['clipboardGetTextOffscreen',    {async: true,  contentScript: true,  handler: this._getTextHandler.bind(this)}], -            ['clipboardGetImageOffscreen',   {async: true,  contentScript: true,  handler: this._getImageHandler.bind(this)}], -            ['databasePrepareOffscreen',     {async: true,  contentScript: true,  handler: this._prepareDatabaseHandler.bind(this)}], -            ['getDictionaryInfoOffscreen',   {async: true,  contentScript: true,  handler: this._getDictionaryInfoHandler.bind(this)}], -            ['databasePurgeOffscreen',       {async: true,  contentScript: true,  handler: this._purgeDatabaseHandler.bind(this)}], -            ['databaseGetMediaOffscreen',    {async: true,  contentScript: true,  handler: this._getMediaHandler.bind(this)}], -            ['translatorPrepareOffscreen',   {async: false,  contentScript: true,  handler: this._prepareTranslatorHandler.bind(this)}], -            ['findKanjiOffscreen',           {async: true,  contentScript: true,  handler: this._findKanjiHandler.bind(this)}], -            ['findTermsOffscreen',           {async: true,  contentScript: true,  handler: this._findTermsHandler.bind(this)}], -            ['getTermFrequenciesOffscreen',  {async: true,  contentScript: true,  handler: this._getTermFrequenciesHandler.bind(this)}], -            ['clearDatabaseCachesOffscreen', {async: false,  contentScript: true,  handler: this._clearDatabaseCachesHandler.bind(this)}] - +        /** @type {import('offscreen').MessageHandlerMap} */ +        const messageHandlers = new Map([ +            ['clipboardGetTextOffscreen',    {async: true,  handler: this._getTextHandler.bind(this)}], +            ['clipboardGetImageOffscreen',   {async: true,  handler: this._getImageHandler.bind(this)}], +            ['clipboardSetBrowserOffscreen', {async: false, handler: this._setClipboardBrowser.bind(this)}], +            ['databasePrepareOffscreen',     {async: true,  handler: this._prepareDatabaseHandler.bind(this)}], +            ['getDictionaryInfoOffscreen',   {async: true,  handler: this._getDictionaryInfoHandler.bind(this)}], +            ['databasePurgeOffscreen',       {async: true,  handler: this._purgeDatabaseHandler.bind(this)}], +            ['databaseGetMediaOffscreen',    {async: true,  handler: this._getMediaHandler.bind(this)}], +            ['translatorPrepareOffscreen',   {async: false, handler: this._prepareTranslatorHandler.bind(this)}], +            ['findKanjiOffscreen',           {async: true,  handler: this._findKanjiHandler.bind(this)}], +            ['findTermsOffscreen',           {async: true,  handler: this._findTermsHandler.bind(this)}], +            ['getTermFrequenciesOffscreen',  {async: true,  handler: this._getTermFrequenciesHandler.bind(this)}], +            ['clearDatabaseCachesOffscreen', {async: false, handler: this._clearDatabaseCachesHandler.bind(this)}]          ]); +        /** @type {import('offscreen').MessageHandlerMap<string>} */ +        this._messageHandlers = messageHandlers;          const onMessage = this._onMessage.bind(this);          chrome.runtime.onMessage.addListener(onMessage); +        /** @type {?Promise<void>} */          this._prepareDatabasePromise = null;      } -    _getTextHandler({useRichText}) { -        return this._clipboardReader.getText(useRichText); +    /** @type {import('offscreen').MessageHandler<'clipboardGetTextOffscreen', true>} */ +    async _getTextHandler({useRichText}) { +        return await this._clipboardReader.getText(useRichText); +    } + +    /** @type {import('offscreen').MessageHandler<'clipboardGetImageOffscreen', true>} */ +    async _getImageHandler() { +        return await this._clipboardReader.getImage();      } -    _getImageHandler() { -        return this._clipboardReader.getImage(); +    /** @type {import('offscreen').MessageHandler<'clipboardSetBrowserOffscreen', false>} */ +    _setClipboardBrowser({value}) { +        this._clipboardReader.browser = value;      } +    /** @type {import('offscreen').MessageHandler<'databasePrepareOffscreen', true>} */      _prepareDatabaseHandler() {          if (this._prepareDatabasePromise !== null) {              return this._prepareDatabasePromise; @@ -84,70 +99,79 @@ export class Offscreen {          return this._prepareDatabasePromise;      } -    _getDictionaryInfoHandler() { -        return this._dictionaryDatabase.getDictionaryInfo(); +    /** @type {import('offscreen').MessageHandler<'getDictionaryInfoOffscreen', true>} */ +    async _getDictionaryInfoHandler() { +        return await this._dictionaryDatabase.getDictionaryInfo();      } -    _purgeDatabaseHandler() { -        return this._dictionaryDatabase.purge(); +    /** @type {import('offscreen').MessageHandler<'databasePurgeOffscreen', true>} */ +    async _purgeDatabaseHandler() { +        return await this._dictionaryDatabase.purge();      } +    /** @type {import('offscreen').MessageHandler<'databaseGetMediaOffscreen', true>} */      async _getMediaHandler({targets}) {          const media = await this._dictionaryDatabase.getMedia(targets);          const serializedMedia = media.map((m) => ({...m, content: ArrayBufferUtil.arrayBufferToBase64(m.content)}));          return serializedMedia;      } +    /** @type {import('offscreen').MessageHandler<'translatorPrepareOffscreen', false>} */      _prepareTranslatorHandler({deinflectionReasons}) { -        return this._translator.prepare(deinflectionReasons); +        this._translator.prepare(deinflectionReasons);      } -    _findKanjiHandler({text, findKanjiOptions}) { -        findKanjiOptions.enabledDictionaryMap = new Map(findKanjiOptions.enabledDictionaryMap); -        return this._translator.findKanji(text, findKanjiOptions); +    /** @type {import('offscreen').MessageHandler<'findKanjiOffscreen', true>} */ +    async _findKanjiHandler({text, options}) { +        /** @type {import('translation').FindKanjiOptions} */ +        const modifiedOptions = { +            ...options, +            enabledDictionaryMap: new Map(options.enabledDictionaryMap) +        }; +        return await this._translator.findKanji(text, modifiedOptions);      } -    _findTermsHandler({mode, text, findTermsOptions}) { -        findTermsOptions.enabledDictionaryMap = new Map(findTermsOptions.enabledDictionaryMap); -        if (findTermsOptions.excludeDictionaryDefinitions) { -            findTermsOptions.excludeDictionaryDefinitions = new Set(findTermsOptions.excludeDictionaryDefinitions); -        } -        findTermsOptions.textReplacements = findTermsOptions.textReplacements.map((group) => { -            if (!group) { -                return group; -            } +    /** @type {import('offscreen').MessageHandler<'findTermsOffscreen', true>} */ +    _findTermsHandler({mode, text, options}) { +        const enabledDictionaryMap = new Map(options.enabledDictionaryMap); +        const excludeDictionaryDefinitions = ( +            options.excludeDictionaryDefinitions !== null ? +            new Set(options.excludeDictionaryDefinitions) : +            null +        ); +        const textReplacements = options.textReplacements.map((group) => { +            if (group === null) { return null; }              return group.map((opt) => { -                const [, pattern, flags] = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i); // https://stackoverflow.com/a/33642463 +                // https://stackoverflow.com/a/33642463 +                const match = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i); +                const [, pattern, flags] = match !== null ? match : ['', '', ''];                  return {...opt, pattern: new RegExp(pattern, flags ?? '')};              });          }); -        return this._translator.findTerms(mode, text, findTermsOptions); +        /** @type {import('translation').FindTermsOptions} */ +        const modifiedOptions = { +            ...options, +            enabledDictionaryMap, +            excludeDictionaryDefinitions, +            textReplacements +        }; +        return this._translator.findTerms(mode, text, modifiedOptions);      } +    /** @type {import('offscreen').MessageHandler<'getTermFrequenciesOffscreen', true>} */      _getTermFrequenciesHandler({termReadingList, dictionaries}) {          return this._translator.getTermFrequencies(termReadingList, dictionaries);      } +    /** @type {import('offscreen').MessageHandler<'clearDatabaseCachesOffscreen', false>} */      _clearDatabaseCachesHandler() { -        return this._translator.clearDatabaseCaches(); +        this._translator.clearDatabaseCaches();      } +    /** @type {import('extension').ChromeRuntimeOnMessageCallback} */      _onMessage({action, params}, sender, callback) {          const messageHandler = this._messageHandlers.get(action);          if (typeof messageHandler === 'undefined') { return false; } -        this._validatePrivilegedMessageSender(sender); -          return invokeMessageHandler(messageHandler, params, callback, sender);      } - -    _validatePrivilegedMessageSender(sender) { -        let {url} = sender; -        if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } -        const {tab} = url; -        if (typeof tab === 'object' && tab !== null) { -            ({url} = tab); -            if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } -        } -        throw new Error('Invalid message sender'); -    }  } 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..23f10ed3 100644 --- a/ext/js/background/request-builder.js +++ b/ext/js/background/request-builder.js @@ -22,16 +22,12 @@   */  export class RequestBuilder {      /** -     * A progress callback for a fetch read. -     * @callback ProgressCallback -     * @param {boolean} complete Whether or not the data has been completely read. -     */ - -    /**       * Creates a new instance.       */      constructor() { +        /** @type {TextEncoder} */          this._textEncoder = new TextEncoder(); +        /** @type {Set<number>} */          this._ruleIds = new Set();      } @@ -60,29 +56,32 @@ export class RequestBuilder {          this._ruleIds.add(id);          try { +            /** @type {chrome.declarativeNetRequest.Rule[]} */              const addRules = [{                  id,                  priority: 1,                  condition: {                      urlFilter: `|${this._escapeDnrUrl(url)}|`, -                    resourceTypes: ['xmlhttprequest'] +                    resourceTypes: [ +                        /** @type {chrome.declarativeNetRequest.ResourceType} */ ('xmlhttprequest') +                    ]                  },                  action: { -                    type: 'modifyHeaders', +                    type: /** @type {chrome.declarativeNetRequest.RuleActionType} */ ('modifyHeaders'),                      requestHeaders: [                          { -                            operation: 'remove', +                            operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'),                              header: 'Cookie'                          },                          { -                            operation: 'set', +                            operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('set'),                              header: 'Origin',                              value: originUrl                          }                      ],                      responseHeaders: [                          { -                            operation: 'remove', +                            operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'),                              header: 'Set-Cookie'                          }                      ] @@ -103,14 +102,18 @@ export class RequestBuilder {      /**       * Reads the array buffer body of a fetch response, with an optional `onProgress` callback.       * @param {Response} response The response of a `fetch` call. -     * @param {ProgressCallback} onProgress The progress callback +     * @param {?(done: boolean) => void} onProgress The progress callback.       * @returns {Promise<Uint8Array>} The resulting binary data.       */      static async readFetchResponseArrayBuffer(response, onProgress) { +        /** @type {ReadableStreamDefaultReader<Uint8Array>|undefined} */          let reader;          try { -            if (typeof onProgress === 'function') { -                reader = response.body.getReader(); +            if (onProgress !== null) { +                const {body} = response; +                if (body !== null) { +                    reader = body.getReader(); +                }              }          } catch (e) {              // Not supported @@ -118,15 +121,15 @@ export class RequestBuilder {          if (typeof reader === 'undefined') {              const result = await response.arrayBuffer(); -            if (typeof onProgress === 'function') { +            if (onProgress !== null) {                  onProgress(true);              } -            return result; +            return new Uint8Array(result);          }          const contentLengthString = response.headers.get('Content-Length');          const contentLength = contentLengthString !== null ? Number.parseInt(contentLengthString, 10) : null; -        let target = Number.isFinite(contentLength) ? new Uint8Array(contentLength) : null; +        let target = contentLength !== null && Number.isFinite(contentLength) ? new Uint8Array(contentLength) : null;          let targetPosition = 0;          let totalLength = 0;          const targets = []; @@ -134,7 +137,9 @@ export class RequestBuilder {          while (true) {              const {done, value} = await reader.read();              if (done) { break; } -            onProgress(false); +            if (onProgress !== null) { +                onProgress(false); +            }              if (target === null) {                  targets.push({array: value, length: value.length});              } else if (targetPosition + value.length > target.length) { @@ -153,13 +158,16 @@ export class RequestBuilder {              target = target.slice(0, totalLength);          } -        onProgress(true); +        if (onProgress !== null) { +            onProgress(true); +        } -        return target; +        return /** @type {Uint8Array} */ (target);      }      // Private +    /** */      async _clearSessionRules() {          const rules = await this._getSessionRules(); @@ -173,6 +181,9 @@ export class RequestBuilder {          await this._updateSessionRules({removeRuleIds});      } +    /** +     * @returns {Promise<chrome.declarativeNetRequest.Rule[]>} +     */      _getSessionRules() {          return new Promise((resolve, reject) => {              chrome.declarativeNetRequest.getSessionRules((result) => { @@ -186,6 +197,10 @@ export class RequestBuilder {          });      } +    /** +     * @param {chrome.declarativeNetRequest.UpdateRuleOptions} options +     * @returns {Promise<void>} +     */      _updateSessionRules(options) {          return new Promise((resolve, reject) => {              chrome.declarativeNetRequest.updateSessionRules(options, () => { @@ -199,6 +214,10 @@ export class RequestBuilder {          });      } +    /** +     * @param {chrome.declarativeNetRequest.UpdateRuleOptions} options +     * @returns {Promise<boolean>} +     */      async _tryUpdateSessionRules(options) {          try {              await this._updateSessionRules(options); @@ -208,6 +227,7 @@ export class RequestBuilder {          }      } +    /** */      async _clearDynamicRules() {          const rules = await this._getDynamicRules(); @@ -221,6 +241,9 @@ export class RequestBuilder {          await this._updateDynamicRules({removeRuleIds});      } +    /** +     * @returns {Promise<chrome.declarativeNetRequest.Rule[]>} +     */      _getDynamicRules() {          return new Promise((resolve, reject) => {              chrome.declarativeNetRequest.getDynamicRules((result) => { @@ -234,6 +257,10 @@ export class RequestBuilder {          });      } +    /** +     * @param {chrome.declarativeNetRequest.UpdateRuleOptions} options +     * @returns {Promise<void>} +     */      _updateDynamicRules(options) {          return new Promise((resolve, reject) => {              chrome.declarativeNetRequest.updateDynamicRules(options, () => { @@ -247,6 +274,10 @@ export class RequestBuilder {          });      } +    /** +     * @returns {number} +     * @throws {Error} +     */      _getNewRuleId() {          let id = 1;          while (this._ruleIds.has(id)) { @@ -257,15 +288,27 @@ export class RequestBuilder {          return id;      } +    /** +     * @param {string} url +     * @returns {string} +     */      _getOriginURL(url) {          const url2 = new URL(url);          return `${url2.protocol}//${url2.host}`;      } +    /** +     * @param {string} url +     * @returns {string} +     */      _escapeDnrUrl(url) {          return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char));      } +    /** +     * @param {string} text +     * @returns {string} +     */      _urlEncodeUtf8(text) {          const array = this._textEncoder.encode(text);          let result = ''; @@ -275,6 +318,11 @@ export class RequestBuilder {          return result;      } +    /** +     * @param {{array: Uint8Array, length: number}[]} items +     * @param {number} totalLength +     * @returns {Uint8Array} +     */      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; } |