aboutsummaryrefslogtreecommitdiff
path: root/ext/js/comm
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/comm')
-rw-r--r--ext/js/comm/anki-connect.js312
-rw-r--r--ext/js/comm/api.js356
-rw-r--r--ext/js/comm/clipboard-monitor.js24
-rw-r--r--ext/js/comm/clipboard-reader.js56
-rw-r--r--ext/js/comm/cross-frame-api.js163
-rw-r--r--ext/js/comm/frame-ancestry-handler.js87
-rw-r--r--ext/js/comm/frame-client.js68
-rw-r--r--ext/js/comm/frame-endpoint.js49
-rw-r--r--ext/js/comm/frame-offset-forwarder.js16
-rw-r--r--ext/js/comm/mecab.js84
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;