diff options
Diffstat (limited to 'ext/js/comm')
-rw-r--r-- | ext/js/comm/anki-connect.js | 312 | ||||
-rw-r--r-- | ext/js/comm/api.js | 356 | ||||
-rw-r--r-- | ext/js/comm/clipboard-monitor.js | 24 | ||||
-rw-r--r-- | ext/js/comm/clipboard-reader.js | 56 | ||||
-rw-r--r-- | ext/js/comm/cross-frame-api.js | 163 | ||||
-rw-r--r-- | ext/js/comm/frame-ancestry-handler.js | 87 | ||||
-rw-r--r-- | ext/js/comm/frame-client.js | 68 | ||||
-rw-r--r-- | ext/js/comm/frame-endpoint.js | 49 | ||||
-rw-r--r-- | ext/js/comm/frame-offset-forwarder.js | 16 | ||||
-rw-r--r-- | ext/js/comm/mecab.js | 84 |
10 files changed, 1033 insertions, 182 deletions
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index 09838ea5..7ff8d0e1 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -16,7 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {isObject} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {AnkiUtil} from '../data/anki-util.js'; /** @@ -27,17 +27,23 @@ export class AnkiConnect { * Creates a new instance. */ constructor() { + /** @type {boolean} */ this._enabled = false; + /** @type {?string} */ this._server = null; + /** @type {number} */ this._localVersion = 2; + /** @type {number} */ this._remoteVersion = 0; + /** @type {?Promise<number>} */ this._versionCheckPromise = null; + /** @type {?string} */ this._apiKey = null; } /** * Gets the URL of the AnkiConnect server. - * @type {string} + * @type {?string} */ get server() { return this._server; @@ -90,7 +96,7 @@ export class AnkiConnect { */ async isConnected() { try { - await this._invoke('version'); + await this._getVersion(); return true; } catch (e) { return false; @@ -99,74 +105,114 @@ export class AnkiConnect { /** * Gets the AnkiConnect API version number. - * @returns {Promise<number>} The version number + * @returns {Promise<?number>} The version number */ async getVersion() { if (!this._enabled) { return null; } await this._checkVersion(); - return await this._invoke('version', {}); + return await this._getVersion(); } + /** + * @param {import('anki').Note} note + * @returns {Promise<?import('anki').NoteId>} + */ async addNote(note) { if (!this._enabled) { return null; } await this._checkVersion(); - return await this._invoke('addNote', {note}); + const result = await this._invoke('addNote', {note}); + if (result !== null && typeof result !== 'number') { + throw this._createUnexpectedResultError('number|null', result); + } + return result; } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<boolean[]>} + */ async canAddNotes(notes) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('canAddNotes', {notes}); + const result = await this._invoke('canAddNotes', {notes}); + return this._normalizeArray(result, notes.length, 'boolean'); } - async notesInfo(notes) { + /** + * @param {import('anki').NoteId[]} noteIds + * @returns {Promise<(?import('anki').NoteInfo)[]>} + */ + async notesInfo(noteIds) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('notesInfo', {notes}); + const result = await this._invoke('notesInfo', {notes: noteIds}); + return this._normalizeNoteInfoArray(result); } + /** + * @returns {Promise<string[]>} + */ async getDeckNames() { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('deckNames'); + const result = await this._invoke('deckNames', {}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @returns {Promise<string[]>} + */ async getModelNames() { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('modelNames'); + const result = await this._invoke('modelNames', {}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @param {string} modelName + * @returns {Promise<string[]>} + */ async getModelFieldNames(modelName) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('modelFieldNames', {modelName}); + const result = await this._invoke('modelFieldNames', {modelName}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @param {string} query + * @returns {Promise<import('anki').CardId[]>} + */ async guiBrowse(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('guiBrowse', {query}); + const result = await this._invoke('guiBrowse', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').NoteId} noteId + * @returns {Promise<import('anki').CardId[]>} + */ async guiBrowseNote(noteId) { return await this.guiBrowse(`nid:${noteId}`); } /** * Opens the note editor GUI. - * @param {number} noteId The ID of the note. - * @returns {Promise<null>} Nothing is returned. + * @param {import('anki').NoteId} noteId The ID of the note. + * @returns {Promise<void>} Nothing is returned. */ async guiEditNote(noteId) { - return await this._invoke('guiEditNote', {note: noteId}); + await this._invoke('guiEditNote', {note: noteId}); } /** * Stores a file with the specified base64-encoded content inside Anki's media folder. * @param {string} fileName The name of the file. * @param {string} content The base64-encoded content of the file. - * @returns {?string} The actual file name used to store the file, which may be different; or `null` if the file was not stored. + * @returns {Promise<?string>} The actual file name used to store the file, which may be different; or `null` if the file was not stored. * @throws {Error} An error is thrown is this object is not enabled. */ async storeMediaFile(fileName, content) { @@ -174,28 +220,39 @@ export class AnkiConnect { throw new Error('AnkiConnect not enabled'); } await this._checkVersion(); - return await this._invoke('storeMediaFile', {filename: fileName, data: content}); + const result = await this._invoke('storeMediaFile', {filename: fileName, data: content}); + if (result !== null && typeof result !== 'string') { + throw this._createUnexpectedResultError('string|null', result); + } + return result; } /** * Finds notes matching a query. * @param {string} query Searches for notes matching a query. - * @returns {number[]} An array of note IDs. + * @returns {Promise<import('anki').NoteId[]>} An array of note IDs. * @see https://docs.ankiweb.net/searching.html */ async findNotes(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('findNotes', {query}); + const result = await this._invoke('findNotes', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<import('anki').NoteId[][]>} + */ async findNoteIds(notes) { if (!this._enabled) { return []; } await this._checkVersion(); const actions = []; const actionsTargetsList = []; + /** @type {Map<string, import('anki').NoteId[][]>} */ const actionsTargetsMap = new Map(); + /** @type {import('anki').NoteId[][]} */ const allNoteIds = []; for (const note of notes) { @@ -207,14 +264,15 @@ export class AnkiConnect { actionsTargetsMap.set(query, actionsTargets); actions.push({action: 'findNotes', params: {query}}); } + /** @type {import('anki').NoteId[]} */ const noteIds = []; allNoteIds.push(noteIds); actionsTargets.push(noteIds); } - const result = await this._invoke('multi', {actions}); + const result = await this._invokeMulti(actions); for (let i = 0, ii = Math.min(result.length, actionsTargetsList.length); i < ii; ++i) { - const noteIds = result[i]; + const noteIds = /** @type {number[]} */ (this._normalizeArray(result[i], -1, 'number')); for (const actionsTargets of actionsTargetsList[i]) { for (const noteId of noteIds) { actionsTargets.push(noteId); @@ -224,18 +282,32 @@ export class AnkiConnect { return allNoteIds; } + /** + * @param {import('anki').CardId[]} cardIds + * @returns {Promise<boolean>} + */ async suspendCards(cardIds) { if (!this._enabled) { return false; } await this._checkVersion(); - return await this._invoke('suspend', {cards: cardIds}); + const result = await this._invoke('suspend', {cards: cardIds}); + return typeof result === 'boolean' && result; } + /** + * @param {string} query + * @returns {Promise<import('anki').CardId[]>} + */ async findCards(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('findCards', {query}); + const result = await this._invoke('findCards', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').NoteId} noteId + * @returns {Promise<import('anki').CardId[]>} + */ async findCardsForNote(noteId) { return await this.findCards(`nid:${noteId}`); } @@ -244,16 +316,26 @@ export class AnkiConnect { * Gets information about the AnkiConnect APIs available. * @param {string[]} scopes A list of scopes to get information about. * @param {?string[]} actions A list of actions to check for - * @returns {object} Information about the APIs. + * @returns {Promise<import('anki').ApiReflectResult>} Information about the APIs. */ async apiReflect(scopes, actions=null) { - return await this._invoke('apiReflect', {scopes, actions}); + const result = await this._invoke('apiReflect', {scopes, actions}); + if (!(typeof result === 'object' && result !== null)) { + throw this._createUnexpectedResultError('object', result); + } + const {scopes: resultScopes, actions: resultActions} = /** @type {import('core').SerializableObject} */ (result); + const resultScopes2 = /** @type {string[]} */ (this._normalizeArray(resultScopes, -1, 'string', ', field scopes')); + const resultActions2 = /** @type {string[]} */ (this._normalizeArray(resultActions, -1, 'string', ', field scopes')); + return { + scopes: resultScopes2, + actions: resultActions2 + }; } /** * Checks whether a specific API action exists. * @param {string} action The action to check for. - * @returns {boolean} Whether or not the action exists. + * @returns {Promise<boolean>} Whether or not the action exists. */ async apiExists(action) { const {actions} = await this.apiReflect(['actions'], [action]); @@ -266,9 +348,9 @@ export class AnkiConnect { * @returns {boolean} Whether or not the error indicates the action is not supported. */ isErrorUnsupportedAction(error) { - if (error instanceof Error) { + if (error instanceof ExtensionError) { const {data} = error; - if (isObject(data) && data.apiError === 'unsupported action') { + if (typeof data === 'object' && data !== null && /** @type {import('core').SerializableObject} */ (data).apiError === 'unsupported action') { return true; } } @@ -277,10 +359,13 @@ export class AnkiConnect { // Private + /** + * @returns {Promise<void>} + */ async _checkVersion() { if (this._remoteVersion < this._localVersion) { if (this._versionCheckPromise === null) { - const promise = this._invoke('version'); + const promise = this._getVersion(); promise .catch(() => {}) .finally(() => { this._versionCheckPromise = null; }); @@ -293,11 +378,18 @@ export class AnkiConnect { } } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @returns {Promise<unknown>} + */ async _invoke(action, params) { + /** @type {import('anki').MessageBody} */ const body = {action, params, version: this._localVersion}; if (this._apiKey !== null) { body.key = this._apiKey; } let response; try { + if (this._server === null) { throw new Error('Server URL is null'); } response = await fetch(this._server, { method: 'POST', mode: 'cors', @@ -311,33 +403,34 @@ export class AnkiConnect { body: JSON.stringify(body) }); } catch (e) { - const error = new Error('Anki connection failure'); + const error = new ExtensionError('Anki connection failure'); error.data = {action, params, originalError: e}; throw error; } if (!response.ok) { - const error = new Error(`Anki connection error: ${response.status}`); + const error = new ExtensionError(`Anki connection error: ${response.status}`); error.data = {action, params, status: response.status}; throw error; } let responseText = null; + /** @type {unknown} */ let result; try { responseText = await response.text(); result = JSON.parse(responseText); } catch (e) { - const error = new Error('Invalid Anki response'); + const error = new ExtensionError('Invalid Anki response'); error.data = {action, params, status: response.status, responseText, originalError: e}; throw error; } - if (isObject(result)) { - const apiError = result.error; + if (typeof result === 'object' && result !== null && !Array.isArray(result)) { + const apiError = /** @type {import('core').SerializableObject} */ (result).error; if (typeof apiError !== 'undefined') { - const error = new Error(`Anki error: ${apiError}`); - error.data = {action, params, status: response.status, apiError}; + const error = new ExtensionError(`Anki error: ${apiError}`); + error.data = {action, params, status: response.status, apiError: typeof apiError === 'string' ? apiError : `${apiError}`}; throw error; } } @@ -345,10 +438,30 @@ export class AnkiConnect { return result; } + /** + * @param {{action: string, params: import('core').SerializableObject}[]} actions + * @returns {Promise<unknown[]>} + */ + async _invokeMulti(actions) { + const result = await this._invoke('multi', {actions}); + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result); + } + return result; + } + + /** + * @param {string} text + * @returns {string} + */ _escapeQuery(text) { return text.replace(/"/g, ''); } + /** + * @param {import('anki').NoteFields} fields + * @returns {string} + */ _fieldsToQuery(fields) { const fieldNames = Object.keys(fields); if (fieldNames.length === 0) { @@ -359,6 +472,10 @@ export class AnkiConnect { return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`; } + /** + * @param {import('anki').Note} note + * @returns {?('collection'|'deck'|'deck-root')} + */ _getDuplicateScopeFromNote(note) { const {options} = note; if (typeof options === 'object' && options !== null) { @@ -370,6 +487,10 @@ export class AnkiConnect { return null; } + /** + * @param {import('anki').Note} note + * @returns {string} + */ _getNoteQuery(note) { let query = ''; switch (this._getDuplicateScopeFromNote(note)) { @@ -383,4 +504,121 @@ export class AnkiConnect { query += this._fieldsToQuery(note.fields); return query; } + + /** + * @returns {Promise<number>} + */ + async _getVersion() { + const version = await this._invoke('version', {}); + return typeof version === 'number' ? version : 0; + } + + /** + * @param {string} message + * @param {unknown} data + * @returns {ExtensionError} + */ + _createError(message, data) { + const error = new ExtensionError(message); + error.data = data; + return error; + } + + /** + * @param {string} expectedType + * @param {unknown} result + * @param {string} [context] + * @returns {ExtensionError} + */ + _createUnexpectedResultError(expectedType, result, context) { + return this._createError(`Unexpected type${typeof context === 'string' ? context : ''}: expected ${expectedType}, received ${this._getTypeName(result)}`, result); + } + + /** + * @param {unknown} value + * @returns {string} + */ + _getTypeName(value) { + if (value === null) { return 'null'; } + return Array.isArray(value) ? 'array' : typeof value; + } + + /** + * @template [T=unknown] + * @param {unknown} result + * @param {number} expectedCount + * @param {'boolean'|'string'|'number'} type + * @param {string} [context] + * @returns {T[]} + * @throws {Error} + */ + _normalizeArray(result, expectedCount, type, context) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError(`${type}[]`, result, context); + } + if (expectedCount < 0) { + expectedCount = result.length; + } else if (expectedCount !== result.length) { + throw this._createError(`Unexpected result array size${context}: expected ${expectedCount}, received ${result.length}`, result); + } + for (let i = 0; i < expectedCount; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (typeof item !== type) { + throw this._createError(`Unexpected result type at index ${i}${context}: expected ${type}, received ${this._getTypeName(item)}`, result); + } + } + return /** @type {T[]} */ (result); + } + + /** + * @param {unknown} result + * @returns {(?import('anki').NoteInfo)[]} + * @throws {Error} + */ + _normalizeNoteInfoArray(result) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result, ''); + } + /** @type {(?import('anki').NoteInfo)[]} */ + const result2 = []; + for (let i = 0, ii = result.length; i < ii; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (item === null || typeof item !== 'object') { + throw this._createError(`Unexpected result type at index ${i}: expected Notes.NoteInfo, received ${this._getTypeName(item)}`, result); + } + const {noteId} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof noteId !== 'number') { + result2.push(null); + continue; + } + + const {tags, fields, modelName, cards} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof modelName !== 'string') { + throw this._createError(`Unexpected result type at index ${i}, field modelName: expected string, received ${this._getTypeName(modelName)}`, result); + } + if (typeof fields !== 'object' || fields === null) { + throw this._createError(`Unexpected result type at index ${i}, field fields: expected string, received ${this._getTypeName(fields)}`, result); + } + const tags2 = /** @type {string[]} */ (this._normalizeArray(tags, -1, 'string', ', field tags')); + const cards2 = /** @type {number[]} */ (this._normalizeArray(cards, -1, 'number', ', field cards')); + /** @type {{[key: string]: import('anki').NoteFieldInfo}} */ + const fields2 = {}; + for (const [key, fieldInfo] of Object.entries(fields)) { + if (typeof fieldInfo !== 'object' || fieldInfo === null) { continue; } + const {value, order} = fieldInfo; + if (typeof value !== 'string' || typeof order !== 'number') { continue; } + fields2[key] = {value, order}; + } + /** @type {import('anki').NoteInfo} */ + const item2 = { + noteId, + tags: tags2, + fields: fields2, + modelName, + cards: cards2 + }; + result2.push(item2); + } + return result2; + } } diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js index 05f95464..26218595 100644 --- a/ext/js/comm/api.js +++ b/ext/js/comm/api.js @@ -16,184 +16,428 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deferPromise, deserializeError, isObject} from '../core.js'; +import {deferPromise} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; export class API { + /** + * @param {import('../yomitan.js').Yomitan} yomitan + */ constructor(yomitan) { + /** @type {import('../yomitan.js').Yomitan} */ this._yomitan = yomitan; } + /** + * @param {import('api').OptionsGetDetails['optionsContext']} optionsContext + * @returns {Promise<import('api').OptionsGetResult>} + */ optionsGet(optionsContext) { - return this._invoke('optionsGet', {optionsContext}); + /** @type {import('api').OptionsGetDetails} */ + const details = {optionsContext}; + return this._invoke('optionsGet', details); } + /** + * @returns {Promise<import('api').OptionsGetFullResult>} + */ optionsGetFull() { return this._invoke('optionsGetFull'); } + /** + * @param {import('api').TermsFindDetails['text']} text + * @param {import('api').TermsFindDetails['details']} details + * @param {import('api').TermsFindDetails['optionsContext']} optionsContext + * @returns {Promise<import('api').TermsFindResult>} + */ termsFind(text, details, optionsContext) { - return this._invoke('termsFind', {text, details, optionsContext}); - } - + /** @type {import('api').TermsFindDetails} */ + const details2 = {text, details, optionsContext}; + return this._invoke('termsFind', details2); + } + + /** + * @param {import('api').ParseTextDetails['text']} text + * @param {import('api').ParseTextDetails['optionsContext']} optionsContext + * @param {import('api').ParseTextDetails['scanLength']} scanLength + * @param {import('api').ParseTextDetails['useInternalParser']} useInternalParser + * @param {import('api').ParseTextDetails['useMecabParser']} useMecabParser + * @returns {Promise<import('api').ParseTextResult>} + */ parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) { - return this._invoke('parseText', {text, optionsContext, scanLength, useInternalParser, useMecabParser}); + /** @type {import('api').ParseTextDetails} */ + const details = {text, optionsContext, scanLength, useInternalParser, useMecabParser}; + return this._invoke('parseText', details); } + /** + * @param {import('api').KanjiFindDetails['text']} text + * @param {import('api').KanjiFindDetails['optionsContext']} optionsContext + * @returns {Promise<import('api').KanjiFindResult>} + */ kanjiFind(text, optionsContext) { - return this._invoke('kanjiFind', {text, optionsContext}); + /** @type {import('api').KanjiFindDetails} */ + const details = {text, optionsContext}; + return this._invoke('kanjiFind', details); } + /** + * @returns {Promise<import('api').IsAnkiConnectedResult>} + */ isAnkiConnected() { return this._invoke('isAnkiConnected'); } + /** + * @returns {Promise<import('api').GetAnkiConnectVersionResult>} + */ getAnkiConnectVersion() { return this._invoke('getAnkiConnectVersion'); } + /** + * @param {import('api').AddAnkiNoteDetails['note']} note + * @returns {Promise<import('api').AddAnkiNoteResult>} + */ addAnkiNote(note) { - return this._invoke('addAnkiNote', {note}); + /** @type {import('api').AddAnkiNoteDetails} */ + const details = {note}; + return this._invoke('addAnkiNote', details); } + /** + * @param {import('api').GetAnkiNoteInfoDetails['notes']} notes + * @param {import('api').GetAnkiNoteInfoDetails['fetchAdditionalInfo']} fetchAdditionalInfo + * @returns {Promise<import('api').GetAnkiNoteInfoResult>} + */ getAnkiNoteInfo(notes, fetchAdditionalInfo) { - return this._invoke('getAnkiNoteInfo', {notes, fetchAdditionalInfo}); - } - + /** @type {import('api').GetAnkiNoteInfoDetails} */ + const details = {notes, fetchAdditionalInfo}; + return this._invoke('getAnkiNoteInfo', details); + } + + /** + * @param {import('api').InjectAnkiNoteMediaDetails['timestamp']} timestamp + * @param {import('api').InjectAnkiNoteMediaDetails['definitionDetails']} definitionDetails + * @param {import('api').InjectAnkiNoteMediaDetails['audioDetails']} audioDetails + * @param {import('api').InjectAnkiNoteMediaDetails['screenshotDetails']} screenshotDetails + * @param {import('api').InjectAnkiNoteMediaDetails['clipboardDetails']} clipboardDetails + * @param {import('api').InjectAnkiNoteMediaDetails['dictionaryMediaDetails']} dictionaryMediaDetails + * @returns {Promise<import('api').InjectAnkiNoteMediaResult>} + */ injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) { - return this._invoke('injectAnkiNoteMedia', {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}); + /** @type {import('api').InjectAnkiNoteMediaDetails} */ + const details = {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}; + return this._invoke('injectAnkiNoteMedia', details); } + /** + * @param {import('api').NoteViewDetails['noteId']} noteId + * @param {import('api').NoteViewDetails['mode']} mode + * @param {import('api').NoteViewDetails['allowFallback']} allowFallback + * @returns {Promise<import('api').NoteViewResult>} + */ noteView(noteId, mode, allowFallback) { - return this._invoke('noteView', {noteId, mode, allowFallback}); + /** @type {import('api').NoteViewDetails} */ + const details = {noteId, mode, allowFallback}; + return this._invoke('noteView', details); } + /** + * @param {import('api').SuspendAnkiCardsForNoteDetails['noteId']} noteId + * @returns {Promise<import('api').SuspendAnkiCardsForNoteResult>} + */ suspendAnkiCardsForNote(noteId) { - return this._invoke('suspendAnkiCardsForNote', {noteId}); + /** @type {import('api').SuspendAnkiCardsForNoteDetails} */ + const details = {noteId}; + return this._invoke('suspendAnkiCardsForNote', details); } + /** + * @param {import('api').GetTermAudioInfoListDetails['source']} source + * @param {import('api').GetTermAudioInfoListDetails['term']} term + * @param {import('api').GetTermAudioInfoListDetails['reading']} reading + * @returns {Promise<import('api').GetTermAudioInfoListResult>} + */ getTermAudioInfoList(source, term, reading) { - return this._invoke('getTermAudioInfoList', {source, term, reading}); + /** @type {import('api').GetTermAudioInfoListDetails} */ + const details = {source, term, reading}; + return this._invoke('getTermAudioInfoList', details); } + /** + * @param {import('api').CommandExecDetails['command']} command + * @param {import('api').CommandExecDetails['params']} [params] + * @returns {Promise<import('api').CommandExecResult>} + */ commandExec(command, params) { - return this._invoke('commandExec', {command, params}); + /** @type {import('api').CommandExecDetails} */ + const details = {command, params}; + return this._invoke('commandExec', details); } + /** + * @param {import('api').SendMessageToFrameDetails['frameId']} frameId + * @param {import('api').SendMessageToFrameDetails['action']} action + * @param {import('api').SendMessageToFrameDetails['params']} [params] + * @returns {Promise<import('api').SendMessageToFrameResult>} + */ sendMessageToFrame(frameId, action, params) { - return this._invoke('sendMessageToFrame', {frameId, action, params}); + /** @type {import('api').SendMessageToFrameDetails} */ + const details = {frameId, action, params}; + return this._invoke('sendMessageToFrame', details); } + /** + * @param {import('api').BroadcastTabDetails['action']} action + * @param {import('api').BroadcastTabDetails['params']} params + * @returns {Promise<import('api').BroadcastTabResult>} + */ broadcastTab(action, params) { - return this._invoke('broadcastTab', {action, params}); + /** @type {import('api').BroadcastTabDetails} */ + const details = {action, params}; + return this._invoke('broadcastTab', details); } + /** + * @returns {Promise<import('api').FrameInformationGetResult>} + */ frameInformationGet() { return this._invoke('frameInformationGet'); } + /** + * @param {import('api').InjectStylesheetDetails['type']} type + * @param {import('api').InjectStylesheetDetails['value']} value + * @returns {Promise<import('api').InjectStylesheetResult>} + */ injectStylesheet(type, value) { - return this._invoke('injectStylesheet', {type, value}); + /** @type {import('api').InjectStylesheetDetails} */ + const details = {type, value}; + return this._invoke('injectStylesheet', details); } + /** + * @param {import('api').GetStylesheetContentDetails['url']} url + * @returns {Promise<import('api').GetStylesheetContentResult>} + */ getStylesheetContent(url) { - return this._invoke('getStylesheetContent', {url}); + /** @type {import('api').GetStylesheetContentDetails} */ + const details = {url}; + return this._invoke('getStylesheetContent', details); } + /** + * @returns {Promise<import('api').GetEnvironmentInfoResult>} + */ getEnvironmentInfo() { return this._invoke('getEnvironmentInfo'); } + /** + * @returns {Promise<import('api').ClipboardGetResult>} + */ clipboardGet() { return this._invoke('clipboardGet'); } + /** + * @returns {Promise<import('api').GetDisplayTemplatesHtmlResult>} + */ getDisplayTemplatesHtml() { return this._invoke('getDisplayTemplatesHtml'); } + /** + * @returns {Promise<import('api').GetZoomResult>} + */ getZoom() { return this._invoke('getZoom'); } + /** + * @returns {Promise<import('api').GetDefaultAnkiFieldTemplatesResult>} + */ getDefaultAnkiFieldTemplates() { return this._invoke('getDefaultAnkiFieldTemplates'); } + /** + * @returns {Promise<import('api').GetDictionaryInfoResult>} + */ getDictionaryInfo() { return this._invoke('getDictionaryInfo'); } + /** + * @returns {Promise<import('api').PurgeDatabaseResult>} + */ purgeDatabase() { return this._invoke('purgeDatabase'); } + /** + * @param {import('api').GetMediaDetails['targets']} targets + * @returns {Promise<import('api').GetMediaResult>} + */ getMedia(targets) { - return this._invoke('getMedia', {targets}); + /** @type {import('api').GetMediaDetails} */ + const details = {targets}; + return this._invoke('getMedia', details); } + /** + * @param {import('api').LogDetails['error']} error + * @param {import('api').LogDetails['level']} level + * @param {import('api').LogDetails['context']} context + * @returns {Promise<import('api').LogResult>} + */ log(error, level, context) { - return this._invoke('log', {error, level, context}); + /** @type {import('api').LogDetails} */ + const details = {error, level, context}; + return this._invoke('log', details); } + /** + * @returns {Promise<import('api').LogIndicatorClearResult>} + */ logIndicatorClear() { return this._invoke('logIndicatorClear'); } + /** + * @param {import('api').ModifySettingsDetails['targets']} targets + * @param {import('api').ModifySettingsDetails['source']} source + * @returns {Promise<import('api').ModifySettingsResult>} + */ modifySettings(targets, source) { - return this._invoke('modifySettings', {targets, source}); + const details = {targets, source}; + return this._invoke('modifySettings', details); } + /** + * @param {import('api').GetSettingsDetails['targets']} targets + * @returns {Promise<import('api').GetSettingsResult>} + */ getSettings(targets) { - return this._invoke('getSettings', {targets}); + /** @type {import('api').GetSettingsDetails} */ + const details = {targets}; + return this._invoke('getSettings', details); } + /** + * @param {import('api').SetAllSettingsDetails['value']} value + * @param {import('api').SetAllSettingsDetails['source']} source + * @returns {Promise<import('api').SetAllSettingsResult>} + */ setAllSettings(value, source) { - return this._invoke('setAllSettings', {value, source}); + /** @type {import('api').SetAllSettingsDetails} */ + const details = {value, source}; + return this._invoke('setAllSettings', details); } + /** + * @param {import('api').GetOrCreateSearchPopupDetails} details + * @returns {Promise<import('api').GetOrCreateSearchPopupResult>} + */ getOrCreateSearchPopup(details) { - return this._invoke('getOrCreateSearchPopup', isObject(details) ? details : {}); + return this._invoke('getOrCreateSearchPopup', details); } + /** + * @param {import('api').IsTabSearchPopupDetails['tabId']} tabId + * @returns {Promise<import('api').IsTabSearchPopupResult>} + */ isTabSearchPopup(tabId) { - return this._invoke('isTabSearchPopup', {tabId}); + /** @type {import('api').IsTabSearchPopupDetails} */ + const details = {tabId}; + return this._invoke('isTabSearchPopup', details); } + /** + * @param {import('api').TriggerDatabaseUpdatedDetails['type']} type + * @param {import('api').TriggerDatabaseUpdatedDetails['cause']} cause + * @returns {Promise<import('api').TriggerDatabaseUpdatedResult>} + */ triggerDatabaseUpdated(type, cause) { - return this._invoke('triggerDatabaseUpdated', {type, cause}); + /** @type {import('api').TriggerDatabaseUpdatedDetails} */ + const details = {type, cause}; + return this._invoke('triggerDatabaseUpdated', details); } + /** + * @returns {Promise<import('api').TestMecabResult>} + */ testMecab() { - return this._invoke('testMecab', {}); + return this._invoke('testMecab'); } + /** + * @param {import('api').TextHasJapaneseCharactersDetails['text']} text + * @returns {Promise<import('api').TextHasJapaneseCharactersResult>} + */ textHasJapaneseCharacters(text) { - return this._invoke('textHasJapaneseCharacters', {text}); + /** @type {import('api').TextHasJapaneseCharactersDetails} */ + const details = {text}; + return this._invoke('textHasJapaneseCharacters', details); } + /** + * @param {import('api').GetTermFrequenciesDetails['termReadingList']} termReadingList + * @param {import('api').GetTermFrequenciesDetails['dictionaries']} dictionaries + * @returns {Promise<import('api').GetTermFrequenciesResult>} + */ getTermFrequencies(termReadingList, dictionaries) { - return this._invoke('getTermFrequencies', {termReadingList, dictionaries}); + /** @type {import('api').GetTermFrequenciesDetails} */ + const details = {termReadingList, dictionaries}; + return this._invoke('getTermFrequencies', details); } + /** + * @param {import('api').FindAnkiNotesDetails['query']} query + * @returns {Promise<import('api').FindAnkiNotesResult>} + */ findAnkiNotes(query) { - return this._invoke('findAnkiNotes', {query}); + /** @type {import('api').FindAnkiNotesDetails} */ + const details = {query}; + return this._invoke('findAnkiNotes', details); } + /** + * @param {import('api').LoadExtensionScriptsDetails['files']} files + * @returns {Promise<import('api').LoadExtensionScriptsResult>} + */ loadExtensionScripts(files) { - return this._invoke('loadExtensionScripts', {files}); + /** @type {import('api').LoadExtensionScriptsDetails} */ + const details = {files}; + return this._invoke('loadExtensionScripts', details); } + /** + * @param {import('api').OpenCrossFramePortDetails['targetTabId']} targetTabId + * @param {import('api').OpenCrossFramePortDetails['targetFrameId']} targetFrameId + * @returns {Promise<import('api').OpenCrossFramePortResult>} + */ openCrossFramePort(targetTabId, targetFrameId) { return this._invoke('openCrossFramePort', {targetTabId, targetFrameId}); } // Utilities - _createActionPort(timeout=5000) { + /** + * @param {number} timeout + * @returns {Promise<chrome.runtime.Port>} + */ + _createActionPort(timeout) { return new Promise((resolve, reject) => { + /** @type {?import('core').Timeout} */ let timer = null; const portDetails = deferPromise(); + /** + * @param {chrome.runtime.Port} port + */ const onConnect = async (port) => { try { const {name: expectedName, id: expectedId} = await portDetails.promise; @@ -210,6 +454,9 @@ export class API { resolve(port); }; + /** + * @param {Error} e + */ const onError = (e) => { if (timer !== null) { clearTimeout(timer); @@ -227,14 +474,24 @@ export class API { }); } - _invokeWithProgress(action, params, onProgress, timeout=5000) { + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} params + * @param {?(...args: unknown[]) => void} onProgress0 + * @param {number} [timeout] + * @returns {Promise<TReturn>} + */ + _invokeWithProgress(action, params, onProgress0, timeout=5000) { return new Promise((resolve, reject) => { + /** @type {?chrome.runtime.Port} */ let port = null; - if (typeof onProgress !== 'function') { - onProgress = () => {}; - } + const onProgress = typeof onProgress0 === 'function' ? onProgress0 : () => {}; + /** + * @param {import('backend').InvokeWithProgressResponseMessage<TReturn>} message + */ const onMessage = (message) => { switch (message.type) { case 'progress': @@ -250,7 +507,7 @@ export class API { break; case 'error': cleanup(); - reject(deserializeError(message.data)); + reject(ExtensionError.deserialize(message.data)); break; } }; @@ -267,7 +524,6 @@ export class API { port.disconnect(); port = null; } - onProgress = null; }; (async () => { @@ -281,20 +537,23 @@ export class API { const fragmentSize = 1e7; // 10 MB for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) { const data = messageString.substring(i, i + fragmentSize); - port.postMessage({action: 'fragment', data}); + port.postMessage(/** @type {import('backend').InvokeWithProgressRequestFragmentMessage} */ ({action: 'fragment', data})); } - port.postMessage({action: 'invoke'}); + port.postMessage(/** @type {import('backend').InvokeWithProgressRequestInvokeMessage} */ ({action: 'invoke'})); } catch (e) { cleanup(); reject(e); - } finally { - action = null; - params = null; } })(); }); } + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} [params] + * @returns {Promise<TReturn>} + */ _invoke(action, params={}) { const data = {action, params}; return new Promise((resolve, reject) => { @@ -303,7 +562,7 @@ export class API { this._checkLastError(chrome.runtime.lastError); if (response !== null && typeof response === 'object') { if (typeof response.error !== 'undefined') { - reject(deserializeError(response.error)); + reject(ExtensionError.deserialize(response.error)); } else { resolve(response.result); } @@ -318,7 +577,10 @@ export class API { }); } - _checkLastError() { + /** + * @param {chrome.runtime.LastError|undefined} _ignore + */ + _checkLastError(_ignore) { // NOP } } diff --git a/ext/js/comm/clipboard-monitor.js b/ext/js/comm/clipboard-monitor.js index c5046046..3b3a56a9 100644 --- a/ext/js/comm/clipboard-monitor.js +++ b/ext/js/comm/clipboard-monitor.js @@ -18,17 +18,32 @@ import {EventDispatcher} from '../core.js'; +/** + * @augments EventDispatcher<import('clipboard-monitor').EventType> + */ export class ClipboardMonitor extends EventDispatcher { + /** + * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil, clipboardReader: import('clipboard-monitor').ClipboardReaderLike}} details + */ constructor({japaneseUtil, clipboardReader}) { super(); + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('clipboard-monitor').ClipboardReaderLike} */ this._clipboardReader = clipboardReader; + /** @type {?import('core').Timeout} */ this._timerId = null; + /** @type {?import('core').TokenObject} */ this._timerToken = null; + /** @type {number} */ this._interval = 250; + /** @type {?string} */ this._previousText = null; } + /** + * @returns {void} + */ start() { this.stop(); @@ -36,6 +51,7 @@ export class ClipboardMonitor extends EventDispatcher { // hasn't been started during the await call. The check below the await call // will exit early if the reference has changed. let canChange = false; + /** @type {?import('core').TokenObject} */ const token = {}; const intervalCallback = async () => { this._timerId = null; @@ -55,7 +71,7 @@ export class ClipboardMonitor extends EventDispatcher { ) { this._previousText = text; if (canChange && this._japaneseUtil.isStringPartiallyJapanese(text)) { - this.trigger('change', {text}); + this.trigger('change', /** @type {import('clipboard-monitor').ChangeEvent} */ ({text})); } } @@ -68,6 +84,9 @@ export class ClipboardMonitor extends EventDispatcher { intervalCallback(); } + /** + * @returns {void} + */ stop() { this._timerToken = null; this._previousText = null; @@ -77,6 +96,9 @@ export class ClipboardMonitor extends EventDispatcher { } } + /** + * @param {?string} text + */ setPreviousText(text) { this._previousText = text; } diff --git a/ext/js/comm/clipboard-reader.js b/ext/js/comm/clipboard-reader.js index 8139cc11..364e31a3 100644 --- a/ext/js/comm/clipboard-reader.js +++ b/ext/js/comm/clipboard-reader.js @@ -24,23 +24,26 @@ import {MediaUtil} from '../media/media-util.js'; export class ClipboardReader { /** * Creates a new instances of a clipboard reader. - * @param {object} details Details about how to set up the instance. - * @param {?Document} details.document The Document object to be used, or null for no support. - * @param {?string} details.pasteTargetSelector The selector for the paste target element. - * @param {?string} details.richContentPasteTargetSelector The selector for the rich content paste target element. + * @param {{document: ?Document, pasteTargetSelector: ?string, richContentPasteTargetSelector: ?string}} details Details about how to set up the instance. */ constructor({document=null, pasteTargetSelector=null, richContentPasteTargetSelector=null}) { + /** @type {?Document} */ this._document = document; + /** @type {?import('environment').Browser} */ this._browser = null; + /** @type {?HTMLTextAreaElement} */ this._pasteTarget = null; + /** @type {?string} */ this._pasteTargetSelector = pasteTargetSelector; + /** @type {?HTMLElement} */ this._richContentPasteTarget = null; + /** @type {?string} */ this._richContentPasteTargetSelector = richContentPasteTargetSelector; } /** * Gets the browser being used. - * @type {?string} + * @type {?import('environment').Browser} */ get browser() { return this._browser; @@ -56,7 +59,7 @@ export class ClipboardReader { /** * Gets the text in the clipboard. * @param {boolean} useRichText Whether or not to use rich text for pasting, when possible. - * @returns {string} A string containing the clipboard text. + * @returns {Promise<string>} A string containing the clipboard text. * @throws {Error} Error if not supported. */ async getText(useRichText) { @@ -90,7 +93,7 @@ export class ClipboardReader { const target = this._getRichContentPasteTarget(); target.focus(); document.execCommand('paste'); - const result = target.textContent; + const result = /** @type {string} */ (target.textContent); this._clearRichContent(target); return result; } else { @@ -106,7 +109,7 @@ export class ClipboardReader { /** * Gets the first image in the clipboard. - * @returns {string} A string containing a data URL of the image file, or null if no image was found. + * @returns {Promise<?string>} A string containing a data URL of the image file, or null if no image was found. * @throws {Error} Error if not supported. */ async getImage() { @@ -155,35 +158,62 @@ export class ClipboardReader { // Private + /** + * @returns {boolean} + */ _isFirefox() { return (this._browser === 'firefox' || this._browser === 'firefox-mobile'); } + /** + * @param {Blob} file + * @returns {Promise<string>} + */ _readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result); + reader.onload = () => resolve(/** @type {string} */ (reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } + /** + * @returns {HTMLTextAreaElement} + */ _getPasteTarget() { - if (this._pasteTarget === null) { this._pasteTarget = this._findPasteTarget(this._pasteTargetSelector); } + if (this._pasteTarget === null) { + this._pasteTarget = /** @type {HTMLTextAreaElement} */ (this._findPasteTarget(this._pasteTargetSelector)); + } return this._pasteTarget; } + /** + * @returns {HTMLElement} + */ _getRichContentPasteTarget() { - if (this._richContentPasteTarget === null) { this._richContentPasteTarget = this._findPasteTarget(this._richContentPasteTargetSelector); } + if (this._richContentPasteTarget === null) { + this._richContentPasteTarget = /** @type {HTMLElement} */ (this._findPasteTarget(this._richContentPasteTargetSelector)); + } return this._richContentPasteTarget; } + /** + * @template {Element} T + * @param {?string} selector + * @returns {T} + * @throws {Error} + */ _findPasteTarget(selector) { - const target = this._document.querySelector(selector); + if (selector === null) { throw new Error('Invalid selector'); } + const target = this._document !== null ? this._document.querySelector(selector) : null; if (target === null) { throw new Error('Clipboard paste target does not exist'); } - return target; + return /** @type {T} */ (target); } + /** + * @param {HTMLElement} element + */ _clearRichContent(element) { for (const image of element.querySelectorAll('img')) { image.removeAttribute('src'); diff --git a/ext/js/comm/cross-frame-api.js b/ext/js/comm/cross-frame-api.js index fe220f21..3ac38cf2 100644 --- a/ext/js/comm/cross-frame-api.js +++ b/ext/js/comm/cross-frame-api.js @@ -16,34 +16,66 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventDispatcher, EventListenerCollection, deserializeError, invokeMessageHandler, log, serializeError} from '../core.js'; +import {EventDispatcher, EventListenerCollection, invokeMessageHandler, log} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {yomitan} from '../yomitan.js'; +/** + * @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEventType> + */ class CrossFrameAPIPort extends EventDispatcher { + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @param {chrome.runtime.Port} port + * @param {import('core').MessageHandlerMap} messageHandlers + */ constructor(otherTabId, otherFrameId, port, messageHandlers) { super(); + /** @type {number} */ this._otherTabId = otherTabId; + /** @type {number} */ this._otherFrameId = otherFrameId; + /** @type {?chrome.runtime.Port} */ this._port = port; + /** @type {import('core').MessageHandlerMap} */ this._messageHandlers = messageHandlers; + /** @type {Map<number, import('cross-frame-api').Invocation>} */ this._activeInvocations = new Map(); + /** @type {number} */ this._invocationId = 0; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** @type {number} */ get otherTabId() { return this._otherTabId; } + /** @type {number} */ get otherFrameId() { return this._otherFrameId; } + /** + * @throws {Error} + */ prepare() { + if (this._port === null) { throw new Error('Invalid state'); } this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); } + /** + * @template [TParams=import('core').SerializableObject] + * @template [TReturn=unknown] + * @param {string} action + * @param {TParams} params + * @param {number} ackTimeout + * @param {number} responseTimeout + * @returns {Promise<TReturn>} + */ invoke(action, params, ackTimeout, responseTimeout) { return new Promise((resolve, reject) => { if (this._port === null) { @@ -52,6 +84,7 @@ class CrossFrameAPIPort extends EventDispatcher { } const id = this._invocationId++; + /** @type {import('cross-frame-api').Invocation} */ const invocation = { id, resolve, @@ -73,19 +106,21 @@ class CrossFrameAPIPort extends EventDispatcher { } try { - this._port.postMessage({type: 'invoke', id, data: {action, params}}); + this._port.postMessage(/** @type {import('cross-frame-api').InvokeMessage} */ ({type: 'invoke', id, data: {action, params}})); } catch (e) { this._onError(id, e); } }); } + /** */ disconnect() { this._onDisconnect(); } // Private + /** */ _onDisconnect() { if (this._port === null) { return; } this._eventListeners.removeAllEventListeners(); @@ -96,22 +131,29 @@ class CrossFrameAPIPort extends EventDispatcher { this.trigger('disconnect', this); } - _onMessage({type, id, data}) { + /** + * @param {import('cross-frame-api').Message} details + */ + _onMessage(details) { + const {type, id} = details; switch (type) { case 'invoke': - this._onInvoke(id, data); + this._onInvoke(id, details.data); break; case 'ack': this._onAck(id); break; case 'result': - this._onResult(id, data); + this._onResult(id, details.data); break; } } // Response handlers + /** + * @param {number} id + */ _onAck(id) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { @@ -141,6 +183,10 @@ class CrossFrameAPIPort extends EventDispatcher { } } + /** + * @param {number} id + * @param {import('core').Response<unknown>} data + */ _onResult(id, data) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { @@ -162,17 +208,21 @@ class CrossFrameAPIPort extends EventDispatcher { const error = data.error; if (typeof error !== 'undefined') { - invocation.reject(deserializeError(error)); + invocation.reject(ExtensionError.deserialize(error)); } else { invocation.resolve(data.result); } } + /** + * @param {number} id + * @param {unknown} error + */ _onError(id, error) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { return; } - if (typeof error === 'string') { + if (!(error instanceof Error)) { error = new Error(`${error} (${invocation.action})`); } @@ -186,6 +236,11 @@ class CrossFrameAPIPort extends EventDispatcher { // Invocation + /** + * @param {number} id + * @param {import('cross-frame-api').InvocationData} details + * @returns {boolean} + */ _onInvoke(id, {action, params}) { const messageHandler = this._messageHandlers.get(action); this._sendAck(id); @@ -194,10 +249,17 @@ class CrossFrameAPIPort extends EventDispatcher { return false; } + /** + * @param {import('core').Response<unknown>} data + * @returns {void} + */ const callback = (data) => this._sendResult(id, data); return invokeMessageHandler(messageHandler, params, callback); } + /** + * @param {import('cross-frame-api').Message} data + */ _sendResponse(data) { if (this._port === null) { return; } try { @@ -207,45 +269,90 @@ class CrossFrameAPIPort extends EventDispatcher { } } + /** + * @param {number} id + */ _sendAck(id) { this._sendResponse({type: 'ack', id}); } + /** + * @param {number} id + * @param {import('core').Response<unknown>} data + */ _sendResult(id, data) { this._sendResponse({type: 'result', id, data}); } + /** + * @param {number} id + * @param {Error} error + */ _sendError(id, error) { - this._sendResponse({type: 'result', id, data: {error: serializeError(error)}}); + this._sendResponse({type: 'result', id, data: {error: ExtensionError.serialize(error)}}); } } export class CrossFrameAPI { constructor() { + /** @type {number} */ this._ackTimeout = 3000; // 3 seconds + /** @type {number} */ this._responseTimeout = 10000; // 10 seconds + /** @type {Map<number, Map<number, CrossFrameAPIPort>>} */ this._commPorts = new Map(); + /** @type {import('core').MessageHandlerMap} */ this._messageHandlers = new Map(); + /** @type {(port: CrossFrameAPIPort) => void} */ this._onDisconnectBind = this._onDisconnect.bind(this); + /** @type {?number} */ this._tabId = null; + /** @type {?number} */ this._frameId = null; } + /** */ async prepare() { chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); - ({tabId: this._tabId, frameId: this._frameId} = await yomitan.api.frameInformationGet()); + ({tabId: this._tabId = null, frameId: this._frameId = null} = await yomitan.api.frameInformationGet()); } - invoke(targetFrameId, action, params={}) { + /** + * @template [TParams=import('core').SerializableObject] + * @template [TReturn=unknown] + * @param {number} targetFrameId + * @param {string} action + * @param {TParams} params + * @returns {Promise<TReturn>} + */ + invoke(targetFrameId, action, params) { return this.invokeTab(null, targetFrameId, action, params); } - async invokeTab(targetTabId, targetFrameId, action, params={}) { - if (typeof targetTabId !== 'number') { targetTabId = this._tabId; } + /** + * @template [TParams=import('core').SerializableObject] + * @template [TReturn=unknown] + * @param {?number} targetTabId + * @param {number} targetFrameId + * @param {string} action + * @param {TParams} params + * @returns {Promise<TReturn>} + */ + async invokeTab(targetTabId, targetFrameId, action, params) { + if (typeof targetTabId !== 'number') { + targetTabId = this._tabId; + if (typeof targetTabId !== 'number') { + throw new Error('Unknown target tab id for invocation'); + } + } const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId); return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); } + /** + * @param {import('core').MessageHandlerArray} messageHandlers + * @throws {Error} + */ registerHandlers(messageHandlers) { for (const [key, value] of messageHandlers) { if (this._messageHandlers.has(key)) { @@ -255,12 +362,19 @@ export class CrossFrameAPI { } } + /** + * @param {string} key + * @returns {boolean} + */ unregisterHandler(key) { return this._messageHandlers.delete(key); } // Private + /** + * @param {chrome.runtime.Port} port + */ _onConnect(port) { try { let details; @@ -280,6 +394,9 @@ export class CrossFrameAPI { } } + /** + * @param {CrossFrameAPIPort} commPort + */ _onDisconnect(commPort) { commPort.off('disconnect', this._onDisconnectBind); const {otherTabId, otherFrameId} = commPort; @@ -292,7 +409,12 @@ export class CrossFrameAPI { } } - _getOrCreateCommPort(otherTabId, otherFrameId) { + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @returns {Promise<CrossFrameAPIPort>} + */ + async _getOrCreateCommPort(otherTabId, otherFrameId) { const tabPorts = this._commPorts.get(otherTabId); if (typeof tabPorts !== 'undefined') { const commPort = tabPorts.get(otherFrameId); @@ -300,9 +422,13 @@ export class CrossFrameAPI { return commPort; } } - return this._createCommPort(otherTabId, otherFrameId); + return await this._createCommPort(otherTabId, otherFrameId); } - + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @returns {Promise<CrossFrameAPIPort>} + */ async _createCommPort(otherTabId, otherFrameId) { await yomitan.api.openCrossFramePort(otherTabId, otherFrameId); @@ -313,8 +439,15 @@ export class CrossFrameAPI { return commPort; } } + throw new Error('Comm port didn\'t open'); } + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @param {chrome.runtime.Port} port + * @returns {CrossFrameAPIPort} + */ _setupCommPort(otherTabId, otherFrameId, port) { const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._messageHandlers); let tabPorts = this._commPorts.get(otherTabId); diff --git a/ext/js/comm/frame-ancestry-handler.js b/ext/js/comm/frame-ancestry-handler.js index eeefac3f..e4d08f28 100644 --- a/ext/js/comm/frame-ancestry-handler.js +++ b/ext/js/comm/frame-ancestry-handler.js @@ -31,11 +31,17 @@ export class FrameAncestryHandler { * @param {number} frameId The frame ID of the current frame the instance is instantiated in. */ constructor(frameId) { + /** @type {number} */ this._frameId = frameId; + /** @type {boolean} */ this._isPrepared = false; + /** @type {string} */ this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo'; + /** @type {string} */ this._responseMessageIdBase = `${this._requestMessageId}.response.`; + /** @type {?Promise<number[]>} */ this._getFrameAncestryInfoPromise = null; + /** @type {Map<number, {window: Window, frameElement: ?(undefined|Element)}>} */ this._childFrameMap = new Map(); } @@ -68,7 +74,7 @@ export class FrameAncestryHandler { * Gets the frame ancestry information for the current frame. If the frame is the * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned, * starting from the nearest ancestor. - * @returns {number[]} An array of frame IDs corresponding to the ancestors of the current frame. + * @returns {Promise<number[]>} An array of frame IDs corresponding to the ancestors of the current frame. */ async getFrameAncestryInfo() { if (this._getFrameAncestryInfoPromise === null) { @@ -82,7 +88,7 @@ export class FrameAncestryHandler { * For this function to work, the `getFrameAncestryInfo` function needs to have * been invoked previously. * @param {number} frameId The frame ID of the child frame to get. - * @returns {HTMLElement} The element corresponding to the frame with ID `frameId`, otherwise `null`. + * @returns {?Element} The element corresponding to the frame with ID `frameId`, otherwise `null`. */ getChildFrameElement(frameId) { const frameInfo = this._childFrameMap.get(frameId); @@ -99,6 +105,10 @@ export class FrameAncestryHandler { // Private + /** + * @param {number} [timeout] + * @returns {Promise<number[]>} + */ _getFrameAncestryInfo(timeout=5000) { return new Promise((resolve, reject) => { const targetWindow = window.parent; @@ -110,7 +120,9 @@ export class FrameAncestryHandler { const uniqueId = generateId(16); let nonce = generateId(16); const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; + /** @type {number[]} */ const results = []; + /** @type {?import('core').Timeout} */ let timer = null; const cleanup = () => { @@ -120,6 +132,10 @@ export class FrameAncestryHandler { } yomitan.crossFrame.unregisterHandler(responseMessageId); }; + /** + * @param {import('frame-ancestry-handler').RequestFrameInfoResponseParams} params + * @returns {?import('frame-ancestry-handler').RequestFrameInfoResponseReturn} + */ const onMessage = (params) => { if (params.nonce !== nonce) { return null; } @@ -155,24 +171,35 @@ export class FrameAncestryHandler { }); } + /** + * @param {MessageEvent<unknown>} event + */ _onWindowMessage(event) { - const {source} = event; - if (source === window || source.parent !== window) { return; } + const source = /** @type {?Window} */ (event.source); + if (source === null || source === window || source.parent !== window) { return; } const {data} = event; - if ( - typeof data === 'object' && - data !== null && - data.action === this._requestMessageId - ) { - this._onRequestFrameInfo(data.params, source); - } + if (typeof data !== 'object' || data === null) { return; } + + const {action} = /** @type {import('core').SerializableObject} */ (data); + if (action !== this._requestMessageId) { return; } + + const {params} = /** @type {import('core').SerializableObject} */ (data); + if (typeof params !== 'object' || params === null) { return; } + + this._onRequestFrameInfo(/** @type {import('core').SerializableObject} */ (params), source); } + /** + * @param {import('core').SerializableObject} params + * @param {Window} source + */ async _onRequestFrameInfo(params, source) { try { let {originFrameId, childFrameId, uniqueId, nonce} = params; if ( + typeof originFrameId !== 'number' || + typeof childFrameId !== 'number' || !this._isNonNegativeInteger(originFrameId) || typeof uniqueId !== 'string' || typeof nonce !== 'string' @@ -183,13 +210,17 @@ export class FrameAncestryHandler { const frameId = this._frameId; const {parent} = window; const more = (window !== parent); + /** @type {import('frame-ancestry-handler').RequestFrameInfoResponseParams} */ const responseParams = {frameId, nonce, more}; const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; try { + /** @type {?import('frame-ancestry-handler').RequestFrameInfoResponseReturn} */ const response = await yomitan.crossFrame.invoke(originFrameId, responseMessageId, responseParams); if (response === null) { return; } - nonce = response.nonce; + const nonce2 = response.nonce; + if (typeof nonce2 !== 'string') { return; } + nonce = nonce2; } catch (e) { return; } @@ -199,13 +230,20 @@ export class FrameAncestryHandler { } if (more) { - this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce); + this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, /** @type {string} */ (nonce)); } } catch (e) { // NOP } } + /** + * @param {Window} targetWindow + * @param {number} originFrameId + * @param {number} childFrameId + * @param {string} uniqueId + * @param {string} nonce + */ _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) { targetWindow.postMessage({ action: this._requestMessageId, @@ -213,15 +251,22 @@ export class FrameAncestryHandler { }, '*'); } + /** + * @param {number} value + * @returns {boolean} + */ _isNonNegativeInteger(value) { return ( - typeof value === 'number' && Number.isFinite(value) && value >= 0 && Math.floor(value) === value ); } + /** + * @param {Window} contentWindow + * @returns {?Element} + */ _findFrameElementWithContentWindow(contentWindow) { // Check frameElement, for non-null same-origin frames try { @@ -232,9 +277,9 @@ export class FrameAncestryHandler { } // Check frames - const frameTypes = ['iframe', 'frame', 'embed']; + const frameTypes = ['iframe', 'frame', 'object']; for (const frameType of frameTypes) { - for (const frame of document.getElementsByTagName(frameType)) { + for (const frame of /** @type {HTMLCollectionOf<import('extension').HtmlElementWithContentWindow>} */ (document.getElementsByTagName(frameType))) { if (frame.contentWindow === contentWindow) { return frame; } @@ -242,20 +287,24 @@ export class FrameAncestryHandler { } // Check for shadow roots + /** @type {Node[]} */ const rootElements = [document.documentElement]; while (rootElements.length > 0) { - const rootElement = rootElements.shift(); + const rootElement = /** @type {Node} */ (rootElements.shift()); const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT); while (walker.nextNode()) { - const element = walker.currentNode; + const element = /** @type {Element} */ (walker.currentNode); + // @ts-expect-error - this is more simple to elide any type checks or casting if (element.contentWindow === contentWindow) { return element; } + /** @type {?ShadowRoot|undefined} */ const shadowRoot = ( element.shadowRoot || - element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + // @ts-expect-error - openOrClosedShadowRoot is available to Firefox 63+ for WebExtensions + element.openOrClosedShadowRoot ); if (shadowRoot) { rootElements.push(shadowRoot); diff --git a/ext/js/comm/frame-client.js b/ext/js/comm/frame-client.js index 0ca37feb..8aa8c6d6 100644 --- a/ext/js/comm/frame-client.js +++ b/ext/js/comm/frame-client.js @@ -20,47 +20,81 @@ import {deferPromise, generateId, isObject} from '../core.js'; export class FrameClient { constructor() { + /** @type {?string} */ this._secret = null; + /** @type {?string} */ this._token = null; + /** @type {?number} */ this._frameId = null; } + /** @type {number} */ get frameId() { + if (this._frameId === null) { throw new Error('Not connected'); } return this._frameId; } + /** + * @param {import('extension').HtmlElementWithContentWindow} frame + * @param {string} targetOrigin + * @param {number} hostFrameId + * @param {import('frame-client').SetupFrameFunction} setupFrame + * @param {number} [timeout] + */ async connect(frame, targetOrigin, hostFrameId, setupFrame, timeout=10000) { - const {secret, token, frameId} = await this._connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout); + const {secret, token, frameId} = await this._connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout); this._secret = secret; this._token = token; this._frameId = frameId; } + /** + * @returns {boolean} + */ isConnected() { return (this._secret !== null); } + /** + * @template T + * @param {T} data + * @returns {import('frame-client').Message<T>} + * @throws {Error} + */ createMessage(data) { if (!this.isConnected()) { throw new Error('Not connected'); } return { - token: this._token, - secret: this._secret, + token: /** @type {string} */ (this._token), + secret: /** @type {string} */ (this._secret), data }; } - _connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) { + /** + * @param {import('extension').HtmlElementWithContentWindow} frame + * @param {string} targetOrigin + * @param {number} hostFrameId + * @param {(frame: import('extension').HtmlElementWithContentWindow) => void} setupFrame + * @param {number} timeout + * @returns {Promise<{secret: string, token: string, frameId: number}>} + */ + _connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) { return new Promise((resolve, reject) => { const tokenMap = new Map(); + /** @type {?import('core').Timeout} */ let timer = null; - let { - promise: frameLoadedPromise, - resolve: frameLoadedResolve, - reject: frameLoadedReject - } = deferPromise(); - + const deferPromiseDetails = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); + const frameLoadedPromise = deferPromiseDetails.promise; + let frameLoadedResolve = /** @type {?() => void} */ (deferPromiseDetails.resolve); + let frameLoadedReject = /** @type {?(reason?: import('core').RejectionReason) => void} */ (deferPromiseDetails.reject); + + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @throws {Error} + */ const postMessage = (action, params) => { const contentWindow = frame.contentWindow; if (contentWindow === null) { throw new Error('Frame missing content window'); } @@ -76,11 +110,15 @@ export class FrameClient { contentWindow.postMessage({action, params}, targetOrigin); }; + /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('extension').ChromeRuntimeMessageWithFrameId>} */ const onMessage = (message) => { onMessageInner(message); return false; }; + /** + * @param {import('extension').ChromeRuntimeMessageWithFrameId} message + */ const onMessageInner = async (message) => { try { if (!isObject(message)) { return; } @@ -92,7 +130,7 @@ export class FrameClient { switch (action) { case 'frameEndpointReady': { - const {secret} = params; + const {secret} = /** @type {import('frame-client').FrameEndpointReadyDetails} */ (params); const token = generateId(16); tokenMap.set(secret, token); postMessage('frameEndpointConnect', {secret, token, hostFrameId}); @@ -100,10 +138,10 @@ export class FrameClient { break; case 'frameEndpointConnected': { - const {secret, token} = params; + const {secret, token} = /** @type {import('frame-client').FrameEndpointConnectedDetails} */ (params); const frameId = message.frameId; const token2 = tokenMap.get(secret); - if (typeof token2 !== 'undefined' && token === token2) { + if (typeof token2 !== 'undefined' && token === token2 && typeof frameId === 'number') { cleanup(); resolve({secret, token, frameId}); } @@ -168,6 +206,10 @@ export class FrameClient { }); } + /** + * @param {import('extension').HtmlElementWithContentWindow} frame + * @returns {boolean} + */ static isFrameAboutBlank(frame) { try { const contentDocument = frame.contentDocument; diff --git a/ext/js/comm/frame-endpoint.js b/ext/js/comm/frame-endpoint.js index 5555e60f..c338e143 100644 --- a/ext/js/comm/frame-endpoint.js +++ b/ext/js/comm/frame-endpoint.js @@ -16,50 +16,73 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventListenerCollection, generateId, isObject} from '../core.js'; +import {EventListenerCollection, generateId} from '../core.js'; import {yomitan} from '../yomitan.js'; export class FrameEndpoint { constructor() { + /** @type {string} */ this._secret = generateId(16); + /** @type {?string} */ this._token = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {boolean} */ this._eventListenersSetup = false; } + /** + * @returns {void} + */ signal() { if (!this._eventListenersSetup) { this._eventListeners.addEventListener(window, 'message', this._onMessage.bind(this), false); this._eventListenersSetup = true; } - yomitan.api.broadcastTab('frameEndpointReady', {secret: this._secret}); + /** @type {import('frame-client').FrameEndpointReadyDetails} */ + const details = {secret: this._secret}; + yomitan.api.broadcastTab('frameEndpointReady', details); } + /** + * @param {unknown} message + * @returns {boolean} + */ authenticate(message) { return ( this._token !== null && - isObject(message) && - this._token === message.token && - this._secret === message.secret + typeof message === 'object' && message !== null && + this._token === /** @type {import('core').SerializableObject} */ (message).token && + this._secret === /** @type {import('core').SerializableObject} */ (message).secret ); } - _onMessage(e) { + /** + * @param {MessageEvent<unknown>} event + */ + _onMessage(event) { if (this._token !== null) { return; } // Already initialized - const data = e.data; - if (!isObject(data) || data.action !== 'frameEndpointConnect') { return; } // Invalid message + const {data} = event; + if (typeof data !== 'object' || data === null) { return; } // Invalid message - const params = data.params; - if (!isObject(params)) { return; } // Invalid data + const {action} = /** @type {import('core').SerializableObject} */ (data); + if (action !== 'frameEndpointConnect') { return; } // Invalid message - const secret = params.secret; + const {params} = /** @type {import('core').SerializableObject} */ (data); + if (typeof params !== 'object' || params === null) { return; } // Invalid data + + const {secret} = /** @type {import('core').SerializableObject} */ (params); if (secret !== this._secret) { return; } // Invalid authentication - const {token, hostFrameId} = params; + const {token, hostFrameId} = /** @type {import('core').SerializableObject} */ (params); + if (typeof token !== 'string' || typeof hostFrameId !== 'number') { return; } // Invalid target + this._token = token; this._eventListeners.removeAllEventListeners(); - yomitan.api.sendMessageToFrame(hostFrameId, 'frameEndpointConnected', {secret, token}); + /** @type {import('frame-client').FrameEndpointConnectedDetails} */ + const details = {secret, token}; + yomitan.api.sendMessageToFrame(hostFrameId, 'frameEndpointConnected', details); } } diff --git a/ext/js/comm/frame-offset-forwarder.js b/ext/js/comm/frame-offset-forwarder.js index ef75f1d0..af9bd268 100644 --- a/ext/js/comm/frame-offset-forwarder.js +++ b/ext/js/comm/frame-offset-forwarder.js @@ -20,11 +20,19 @@ import {yomitan} from '../yomitan.js'; import {FrameAncestryHandler} from './frame-ancestry-handler.js'; export class FrameOffsetForwarder { + /** + * @param {number} frameId + */ constructor(frameId) { + /** @type {number} */ this._frameId = frameId; + /** @type {FrameAncestryHandler} */ this._frameAncestryHandler = new FrameAncestryHandler(frameId); } + /** + * @returns {void} + */ prepare() { this._frameAncestryHandler.prepare(); yomitan.crossFrame.registerHandlers([ @@ -32,6 +40,9 @@ export class FrameOffsetForwarder { ]); } + /** + * @returns {Promise<?[x: number, y: number]>} + */ async getOffset() { if (this._frameAncestryHandler.isRootFrame()) { return [0, 0]; @@ -41,6 +52,7 @@ export class FrameOffsetForwarder { const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo(); let childFrameId = this._frameId; + /** @type {Promise<?import('frame-offset-forwarder').ChildFrameRect>[]} */ const promises = []; for (const frameId of ancestorFrameIds) { promises.push(yomitan.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId})); @@ -64,6 +76,10 @@ export class FrameOffsetForwarder { // Private + /** + * @param {{frameId: number}} event + * @returns {?import('frame-offset-forwarder').ChildFrameRect} + */ _onMessageGetChildFrameRect({frameId}) { const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId); if (frameElement === null) { return null; } diff --git a/ext/js/comm/mecab.js b/ext/js/comm/mecab.js index c7314605..0a87463b 100644 --- a/ext/js/comm/mecab.js +++ b/ext/js/comm/mecab.js @@ -24,32 +24,26 @@ import {EventListenerCollection} from '../core.js'; */ export class Mecab { /** - * The resulting data from an invocation of `parseText`. - * @typedef {object} ParseResult - * @property {string} name The dictionary name for the parsed result. - * @property {ParseTerm[]} lines The resulting parsed terms. - */ - - /** - * A fragment of the parsed text. - * @typedef {object} ParseFragment - * @property {string} term The term. - * @property {string} reading The reading of the term. - * @property {string} source The source text. - */ - - /** * Creates a new instance of the class. */ constructor() { + /** @type {?chrome.runtime.Port} */ this._port = null; + /** @type {number} */ this._sequence = 0; + /** @type {Map<number, {resolve: (value: unknown) => void, reject: (reason?: unknown) => void, timer: import('core').Timeout}>} */ this._invocations = new Map(); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {number} */ this._timeout = 5000; + /** @type {number} */ this._version = 1; + /** @type {?number} */ this._remoteVersion = null; + /** @type {boolean} */ this._enabled = false; + /** @type {?Promise<void>} */ this._setupPortPromise = null; } @@ -107,7 +101,7 @@ export class Mecab { /** * Gets the version of the MeCab component. - * @returns {?number} The version of the MeCab component, or `null` if the component was not found. + * @returns {Promise<?number>} The version of the MeCab component, or `null` if the component was not found. */ async getVersion() { try { @@ -135,17 +129,26 @@ export class Mecab { * ] * ``` * @param {string} text The string to parse. - * @returns {ParseResult[]} A collection of parsing results of the text. + * @returns {Promise<import('mecab').ParseResult[]>} A collection of parsing results of the text. */ async parseText(text) { await this._setupPort(); const rawResults = await this._invoke('parse_text', {text}); - return this._convertParseTextResults(rawResults); + // Note: The format of rawResults is not validated + return this._convertParseTextResults(/** @type {import('mecab').ParseResultRaw} */ (rawResults)); } // Private - _onMessage({sequence, data}) { + /** + * @param {unknown} message + */ + _onMessage(message) { + if (typeof message !== 'object' || message === null) { return; } + + const {sequence, data} = /** @type {import('core').SerializableObject} */ (message); + if (typeof sequence !== 'number') { return; } + const invocation = this._invocations.get(sequence); if (typeof invocation === 'undefined') { return; } @@ -155,6 +158,9 @@ export class Mecab { this._invocations.delete(sequence); } + /** + * @returns {void} + */ _onDisconnect() { if (this._port === null) { return; } const e = chrome.runtime.lastError; @@ -166,10 +172,16 @@ export class Mecab { this._clearPort(); } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @returns {Promise<unknown>} + */ _invoke(action, params) { return new Promise((resolve, reject) => { if (this._port === null) { reject(new Error('Port disconnected')); + return; } const sequence = this._sequence++; @@ -179,15 +191,21 @@ export class Mecab { reject(new Error(`MeCab invoke timed out after ${this._timeout}ms`)); }, this._timeout); - this._invocations.set(sequence, {resolve, reject, timer}, this._timeout); + this._invocations.set(sequence, {resolve, reject, timer}); this._port.postMessage({action, params, sequence}); }); } + /** + * @param {import('mecab').ParseResultRaw} rawResults + * @returns {import('mecab').ParseResult[]} + */ _convertParseTextResults(rawResults) { + /** @type {import('mecab').ParseResult[]} */ const results = []; for (const [name, rawLines] of Object.entries(rawResults)) { + /** @type {import('mecab').ParseFragment[][]} */ const lines = []; for (const rawLine of rawLines) { const line = []; @@ -204,6 +222,9 @@ export class Mecab { return results; } + /** + * @returns {Promise<void>} + */ async _setupPort() { if (!this._enabled) { throw new Error('MeCab not enabled'); @@ -214,10 +235,13 @@ export class Mecab { try { await this._setupPortPromise; } catch (e) { - throw new Error(e.message); + throw new Error(e instanceof Error ? e.message : `${e}`); } } + /** + * @returns {Promise<void>} + */ async _setupPort2() { const port = chrome.runtime.connectNative('yomitan_mecab'); this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this)); @@ -225,7 +249,14 @@ export class Mecab { this._port = port; try { - const {version} = await this._invoke('get_version', {}); + const data = await this._invoke('get_version', {}); + if (typeof data !== 'object' || data === null) { + throw new Error('Invalid version'); + } + const {version} = /** @type {import('core').SerializableObject} */ (data); + if (typeof version !== 'number') { + throw new Error('Invalid version'); + } this._remoteVersion = version; if (version !== this._version) { throw new Error(`Unsupported MeCab native messenger version ${version}. Yomitan supports version ${this._version}.`); @@ -238,9 +269,14 @@ export class Mecab { } } + /** + * @returns {void} + */ _clearPort() { - this._port.disconnect(); - this._port = null; + if (this._port !== null) { + this._port.disconnect(); + this._port = null; + } this._invocations.clear(); this._eventListeners.removeAllEventListeners(); this._sequence = 0; |