summaryrefslogtreecommitdiff
path: root/ext/js/background
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/background')
-rw-r--r--ext/js/background/backend.js1002
-rw-r--r--ext/js/background/offscreen-proxy.js162
-rw-r--r--ext/js/background/offscreen.js126
-rw-r--r--ext/js/background/profile-conditions-util.js155
-rw-r--r--ext/js/background/request-builder.js88
-rw-r--r--ext/js/background/script-manager.js186
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; }