import { encodeBase64 } from "./base64.js"; import { httpsUrl, assertRecommendedDictionary, MANAGED_DICTIONARY_CHANGED, managedDictionaryFingerprint, managedDictionaryMatches, recommendedDictionarySource, recommendedDownloadUrlMatches, } from "./managed-dictionary-source.js"; import { CUSTOM_DICTIONARY_ID, CUSTOM_DICTIONARY_TITLE, appendCustomDictionaryEntry, buildCustomDictionaryZip, customDictionarySemanticRevision, customDictionaryMetadataMatches, normaliseCustomDictionaryDocument, parseCustomDictionary, } from "./custom-dictionary.js"; import { sameJsonValue } from "./json-value.js"; import { boundResponseFailure, isBoundedRequest, responseFits, responseLimitError, validResponseRequestId, } from "./response-limits.js"; import { dictionaryArchiveIdentity, dictionaryImportTarget, } from "./dictionary-import.js"; /* * Owns the single hoshidicts engine instance inside a dedicated Web Worker. * * Everything that touches the engine runs on one promise chain: the engine is * not reentrant, and an import must never interleave with a lookup. Blocking the * worker for the length of an import is safe: pthread joins and synchronous * OPFS access must not block the offscreen document's browser main thread. * * SPDX-License-Identifier: GPL-3.0-or-later */ const WORKER_TARGET = "hoshidicts-worker"; const DICT_ROOT = "/dicts"; const REMOVAL_ROOT = `${DICT_ROOT}/.hdw-remove`; const GENERATION_PREFIX = ".hdw-generation-"; const GENERATION_NAME = /^\.hdw-generation-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; const IMPORT_ZIP = "/.hdw-archive.zip"; // An MDX dictionary is staged under its own file name because the engine finds // its MDD resource files as siblings named after the .mdx stem and falls back // to that stem when the header has no title. const IMPORT_MDX_DIR = "/.hdw-mdx"; const OPFS_IMPORT_ZIP = `${DICT_ROOT}/.hdw-archive.zip`; // Index into this array is the `kind` argument of hdw_add_dict. const KINDS = ["term", "freq", "pitch", "kanji"]; // A directory holding one of these is an imported dictionary; anything else // under /dicts is debris. _6 and _4 mean the importer trained a zstd dictionary // for the term banks and wrote a dict.zstd alongside; _5 and _3 mean it did not, // which is also how every dictionary imported by an older engine looks. _5 and // _6 store the term score as a double, _4 and older as an int32. All load, so // the presence of dict.zstd is deliberately not part of the test. const MARKER_FILES = [".hoshidicts_6", ".hoshidicts_5", ".hoshidicts_4", ".hoshidicts_3", ".hoshidicts_2", ".hoshidicts_1"]; const FREQUENCY_ORDERS = ["auto", "ascending", "descending", "disabled"]; const DEFAULT_MAX_RESULTS = 32; const DEFAULT_SCAN_LENGTH = 16; const MAX_LOOKUP_TEXT_BYTES = 4 * 1024; const MAX_MEDIA_DICTIONARY_BYTES = 1024; const MAX_MEDIA_PATH_BYTES = 4 * 1024; const UTF8 = new TextEncoder(); const MEDIA_TYPES = { avif: "image/avif", webp: "image/webp", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", }; // Status and release do not touch the loaded dictionaries. Imports stage their // network body outside the engine queue, then explicitly serialize only the // revalidation and native installation phase. // Dictionary download reads serve an archive already built by its open, so // they need no turn in the queue either. const UNQUEUED = new Set(["hd_status", "hd_backup_release", "hd_import", "hd_api_dictionary_read", "hd_api_dictionary_close"]); // A storage read-modify-write spans two messages, so another context can write // in between; the worker refuses the write when that happens and the change is // recomputed from what is there now. const STORAGE_ATTEMPTS = 3; let engine = null; // Set only when the engine itself is unusable -- the wasm did not compile, or // its filesystem did not mount -- which nothing in this worker can undo. let bootError = null; // Set when the engine is fine but the dictionary list could not be read or // written, which leaves nothing loaded. Recoverable, so it is reported and // retried rather than latched. let reloadError = null; let ready = false; let busy = 0; let stagingImports = 0; let generation = 0; let dictionaryCount = 0; // Committed packages the engine could not load into its current set. let loadFailures = []; let hostRequest = null; let started = false; let createHoshidicts = null; let storageBackend = "memory"; // The single-thread runtime imports on one thread with small read-ahead; the // pthread runtimes (OPFS or IDBFS) use the bounded worker group. let lowRam = true; // Optional sink for import download/installation phases, keyed by request ID. let reportProgress = null; // Download progress is a transient UI signal; one report per chunk would flood // the host bridge on a fast connection. const PROGRESS_INTERVAL_MS = 100; export function configureEngineService(request, options = {}) { if (hostRequest !== null) { throw new Error("the engine service is already configured"); } hostRequest = request; createHoshidicts = options.createHoshidicts; storageBackend = options.storageBackend ?? "memory"; lowRam = options.lowRam !== false; reportProgress = typeof options.reportProgress === "function" ? options.reportProgress : null; } function describe(error) { if (error instanceof Error) { return error.message || String(error); } if (typeof error === "string") { return error; } return error?.message ? String(error.message) : JSON.stringify(error); } function asError(error) { return error instanceof Error ? error : new Error(describe(error)); } class UnknownDictionaryStateCommitError extends Error { constructor(commitError, readError, subject = "dictionary state") { super( `${subject} commit outcome is unknown: ${describe(commitError)}; ` + `readback failed: ${describe(readError)}`, ); this.name = "UnknownDictionaryStateCommitError"; this.cause = commitError; } } function text(value) { return typeof value === "string" ? value : ""; } function usableDictionaryTitle(title) { return ( title !== "" && title !== "." && title !== ".." && title !== ".hdw-import" && title !== ".hdw-remove" && !title.includes("/") && !title.includes("\\") && !title.includes("\0") ); } function clampInt(value, min, max, fallback) { const number = Math.trunc(Number(value)); if (!Number.isFinite(number)) { return fallback; } return Math.min(max, Math.max(min, number)); } function parseJson(json, source) { try { return JSON.parse(json); } catch (error) { throw new Error(`${source} returned malformed JSON: ${describe(error)}`); } } function boundedText(value, label, maxBytes, cString = true) { const result = text(value); if (cString && result.includes("\0")) throw new Error(`${label} contains NUL`); // Three UTF-8 bytes per UTF-16 code unit is a conservative upper bound. if (result.length * 3 > maxBytes && UTF8.encode(result).byteLength > maxBytes) { throw new Error(`${label} exceeds the ${maxBytes}-byte limit`); } return result; } function lookupArguments(message) { return [ boundedText(message.text, "lookup text", MAX_LOOKUP_TEXT_BYTES), clampInt(message.maxResults, 1, 256, DEFAULT_MAX_RESULTS), clampInt(message.scanLength, 1, 64, DEFAULT_SCAN_LENGTH), JSON.stringify({ frequencyDictionary: boundedText(message.options?.frequencyDictionary, "frequency dictionary", MAX_LOOKUP_TEXT_BYTES, false), frequencyOrder: FREQUENCY_ORDERS.includes(message.options?.frequencyOrder) ? message.options.frequencyOrder : "auto", primaryReading: boundedText(message.options?.primaryReading, "primary reading", MAX_LOOKUP_TEXT_BYTES, false), }), ]; } function termLookupReply(json, source) { const parsed = parseJson(json, source); if (!Array.isArray(parsed?.results) || !Number.isSafeInteger(parsed?.dictionaryCount) || parsed.dictionaryCount < 0) { throw new Error(`${source} returned a malformed lookup response`); } return { results: parsed.results, dictionaryCount: parsed.dictionaryCount, nativeJsonLength: json.length }; } let tail = Promise.resolve(); function serialise(job) { busy += 1; const run = tail.then(() => job(), () => job()); run.then( () => { busy -= 1; }, () => { busy -= 1; }, ); tail = run.then( () => undefined, () => undefined, ); return run; } function requireEngine() { if (!ready) { throw bootError ?? new Error("the dictionary engine is still starting"); } return engine; } function lastError() { return engine.ccall("hdw_last_error", "string", [], []) || "unknown engine error"; } // The engine's failure fallbacks are shape-valid and indistinguishable from a // genuine empty answer -- hdw_lookup returns dictionaryCount 0 even with // dictionaries loaded, which the content script would render as "no dictionaries // imported". hdw_last_error is cleared on entry to every entry point, so a // non-empty value here means the call we just made is the one that failed. function throwIfEngineFailed(name) { const message = engine.ccall("hdw_last_error", "string", [], []); if (message !== "") { throw new Error(`${name}: ${message}`); } } function syncfs(populate) { return new Promise((resolve, reject) => { engine.FS.syncfs(populate, (error) => { if (error) { reject(asError(error)); } else { resolve(); } }); }); } async function persistFilesystem() { if (storageBackend === "idbfs") { // Trim first: IDBFS stores each file as a view of its array, and a // structured clone of a view carries the whole backing buffer. trimMemfsFiles(DICT_ROOT); await syncfs(false); } } // The classic FS keeps every file as a JavaScript array that grows by 12.5% per // write once it passes 1 MiB, so streaming an 80 MB blobs.bin copies about nine // times its size (expandFileStorage was 340 ms of a Jitendex import in // Electron). Doubling instead copies about twice its size; the slack is given // back by trimMemfsFiles before the import is persisted. function speedUpMemfsGrowth() { const memfs = engine.FS?.filesystems?.MEMFS; if (typeof memfs?.expandFileStorage !== "function" || typeof memfs.getFileDataAsTypedArray !== "function") { return; } memfs.expandFileStorage = (node, newCapacity) => { const prevCapacity = node.contents.length; if (prevCapacity >= newCapacity) return; const capacity = Math.max(newCapacity, prevCapacity * 2, 256) >>> 0; const oldContents = memfs.getFileDataAsTypedArray(node); node.contents = new Uint8Array(capacity); node.contents.set(oldContents); }; } // IDBFS persists each file as an IndexedDB record whose `contents` is a // Uint8Array. Chromium serialises such a value through the renderer on every // put and deserialises it on every get, and the cost grew with the size of the // database (Electron: 400, 630 and 880 ms for three imports of 70–100 MB). // A Blob value is handed to the browser's blob storage once and read back with // one copy; the same three imports persist in 230, 280 and 340 ms, and restart // to ready loses about 300 ms. Files under 1 MiB stay arrays. Records of either // shape load; Blobs are only written where FileReaderSync can read them back. const IDBFS_BLOB_THRESHOLD = 1024 * 1024; function storeLargeIdbfsFilesAsBlobs() { const idbfs = engine.FS?.filesystems?.IDBFS; if (typeof idbfs?.storeRemoteEntry !== "function" || typeof idbfs.loadRemoteEntry !== "function" || typeof FileReaderSync !== "function" || typeof Blob !== "function") { return; } const storeRemoteEntry = idbfs.storeRemoteEntry; const loadRemoteEntry = idbfs.loadRemoteEntry; idbfs.storeRemoteEntry = (store, path, entry, callback) => { if (entry?.contents instanceof Uint8Array && entry.contents.byteLength >= IDBFS_BLOB_THRESHOLD) { entry = { ...entry, contents: new Blob([entry.contents]) }; } return storeRemoteEntry(store, path, entry, callback); }; idbfs.loadRemoteEntry = (store, path, callback) => loadRemoteEntry(store, path, (error, entry) => { if (!error && entry?.contents instanceof Blob) { try { entry.contents = new Uint8Array(new FileReaderSync().readAsArrayBuffer(entry.contents)); } catch (readError) { callback(readError); return; } } callback(error, entry); }); } // Reallocate over-allocated classic-FS files under `root` to their exact size. function trimMemfsFiles(root) { let node; try { node = engine.FS.lookupPath(root).node; } catch { return; } const stack = [node]; while (stack.length > 0) { const current = stack.pop(); if (engine.FS.isDir(current.mode)) { for (const child of Object.values(current.contents ?? {})) stack.push(child); } else if (engine.FS.isFile(current.mode) && current.contents instanceof Uint8Array && current.contents.length > current.usedBytes) { current.contents = current.contents.slice(0, current.usedBytes); } } } function exists(path) { try { engine.FS.stat(path); return true; } catch (error) { return false; } } function createGenerationRoot() { const path = `${DICT_ROOT}/${GENERATION_PREFIX}${globalThis.crypto.randomUUID()}`; if (exists(path)) { throw new Error("the new dictionary generation path already exists"); } return path; } function isGenerationRoot(path) { const prefix = `${DICT_ROOT}/`; return path.startsWith(prefix) && GENERATION_NAME.test(path.slice(prefix.length)); } function dictionaryRoot(dictionary) { const title = text(dictionary?.title); const path = text(dictionary?.path); if (title === "" || title === "." || title === ".." || title === ".hdw-import" || title.includes("/") || title.includes("\\") || title.includes("\0")) { return null; } if (path === `${DICT_ROOT}/${title}`) { return path; } const separator = path.lastIndexOf("/"); const root = path.slice(0, separator); return separator > DICT_ROOT.length && path === `${root}/${title}` && isGenerationRoot(root) ? root : null; } function isDirectory(stat) { return (stat.mode & 0o170000) === 0o040000; } function removeTree(path) { const FS = engine.FS; let stat; try { stat = FS.stat(path); } catch (error) { return; } if (!isDirectory(stat)) { FS.unlink(path); return; } for (const name of FS.readdir(path)) { if (name !== "." && name !== "..") { removeTree(`${path}/${name}`); } } FS.rmdir(path); } function removeEmptyDirectory(path) { if (exists(path) && engine.FS.readdir(path).every((name) => name === "." || name === "..")) { engine.FS.rmdir(path); } } function hasDictionaryMarker(path) { return MARKER_FILES.some((marker) => exists(`${path}/${marker}`)); } function removeUnreferencedDictionaryRoot(name, referencedRoots) { if (name === "." || name === "..") { return false; } const path = `${DICT_ROOT}/${name}`; let stat; try { stat = engine.FS.stat(path); } catch (error) { return false; } if (!isDirectory(stat)) { return false; } if (GENERATION_NAME.test(name)) { if (referencedRoots.has(path)) { return false; } } else if (!hasDictionaryMarker(path) || referencedRoots.has(path)) { return false; } removeTree(path); return true; } async function cleanupUnreferencedDictionaries(dictionaries) { const referencedRoots = new Set(dictionaries.map((dictionary) => dictionaryRoot(dictionary))); if (referencedRoots.has(null)) { throw new Error("the committed dictionary state contains an invalid path"); } let changed = false; for (const name of engine.FS.readdir(DICT_ROOT)) { changed = removeUnreferencedDictionaryRoot(name, referencedRoots) || changed; } if (changed) { await persistFilesystem(); } } async function discardGeneration(path) { if (!isGenerationRoot(path)) { throw new Error("refusing to discard a path outside the dictionary generation namespace"); } if (exists(path)) { removeTree(path); await persistFilesystem(); } } function moveDictionaryFiles(source, destination, markerLast) { const FS = engine.FS; if (!exists(destination)) { FS.mkdir(destination); } const files = FS.readdir(source).filter((name) => name !== "." && name !== ".."); for (const name of files) { if (isDirectory(FS.stat(`${source}/${name}`))) { throw new Error("an imported dictionary contains an unsupported nested path"); } } const markers = files.filter((name) => MARKER_FILES.includes(name)); const data = files.filter((name) => !MARKER_FILES.includes(name)); for (const name of markerLast ? [...data, ...markers] : [...markers, ...data]) { FS.rename(`${source}/${name}`, `${destination}/${name}`); } removeEmptyDirectory(source); } function settleStagedRemoval(title, restore) { const stagedPath = `${REMOVAL_ROOT}/${title}`; if (!exists(stagedPath)) { return false; } if (restore) { moveDictionaryFiles(stagedPath, `${DICT_ROOT}/${title}`, true); } else { removeTree(stagedPath); } removeEmptyDirectory(REMOVAL_ROOT); return true; } function count(value) { const number = Math.trunc(Number(value)); return Number.isFinite(number) && number > 0 ? number : 0; } function optionalText(value) { return typeof value === "string" && value !== "" ? value : null; } function recommendedSourceForImport(message) { const sourceId = optionalText(message?.sourceId); const finalUrl = optionalText(message?.finalUrl); if (sourceId === null && finalUrl === null) { return null; } const source = recommendedDictionarySource(sourceId); if (!source) { throw new Error("the recommended import names an unknown catalogue source"); } if (finalUrl !== null && !recommendedDownloadUrlMatches(source, finalUrl)) { throw new Error(`${source.name} downloaded from an unexpected final URL`); } return source; } function capabilities(value) { return [ ["term", value.termCount], ["freq", value.frequencyCount], ["pitch", value.pitchCount], ["kanji", value.kanjiCount], ["media", value.mediaCount], ].filter(([, count]) => Number(count) > 0).map(([kind]) => kind); } function withRecommendedSource(dictionary, source) { return { ...dictionary, sourceId: source.sourceId, isUpdatable: source.indexUrl !== null, indexUrl: source.indexUrl, downloadUrl: source.downloadUrl, }; } function validateRecommendedImport(source, report, generated) { assertRecommendedDictionary(source, { ...report, indexUrl: generated.indexUrl, revision: generated.revision }); } function installedAt(importDate, path) { if (typeof importDate === "number" && Number.isFinite(importDate)) { return new Date(importDate).toISOString(); } const mtime = engine.FS.stat(`${path}/index.json`).mtime; return new Date(mtime instanceof Date ? mtime.getTime() : Number(mtime) * 1000).toISOString(); } async function stableDictionaryId(title) { const bytes = new TextEncoder().encode(title); const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes)); return Array.from(digest.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Longest key, in code points, that the dictionary's long-key scan index // records: the importer lists every key longer than 16 code points by its // first eight, and the engine extends a lookup past its scan length only when // the text begins like one of them (hoshidicts src/scan_index.hpp). The reader // adds this to the page text it collects so such a key can be seen at all. // Header: u32 magic "HDSI", u32 version, u32 count, u16 longest key. 0 for a // dictionary imported before the index existed or with no long key. const SCAN_INDEX_MAGIC = 0x49534448; const SCAN_INDEX_VERSION = 1; const SCAN_INDEX_HEADER_BYTES = 16; function longKeyLengthFromScanIndex(path) { const file = `${path}/scan.idx`; if (!exists(file)) return 0; const header = new Uint8Array(SCAN_INDEX_HEADER_BYTES); let read = 0; try { const stream = engine.FS.open(file, "r"); try { read = engine.FS.read(stream, header, 0, SCAN_INDEX_HEADER_BYTES, 0); } finally { engine.FS.close(stream); } } catch { // An unreadable index only costs the long-key window; the dictionary // itself still loads and lookups stay at the configured scan length. return 0; } if (read < SCAN_INDEX_HEADER_BYTES) return 0; const view = new DataView(header.buffer, header.byteOffset, header.byteLength); if (view.getUint32(0, true) !== SCAN_INDEX_MAGIC || view.getUint32(4, true) !== SCAN_INDEX_VERSION) return 0; return view.getUint16(12, true); } function readDictionaryIndex(path) { const json = new TextDecoder().decode(engine.FS.readFile(`${path}/index.json`)); return parseJson(json, `${path}/index.json`); } function importedIdentityFromIndex(path) { return dictionaryArchiveIdentity(readDictionaryIndex(path)); } async function packageFromIndex(path) { const index = readDictionaryIndex(path); const title = text(index?.title); if (title === "") { throw new Error(`${path}/index.json has no dictionary title`); } return { id: await stableDictionaryId(title), title, displayName: null, path, enabled: true, favorite: false, revision: text(index?.revision), isUpdatable: index?.isUpdatable === true, indexUrl: optionalText(index?.indexUrl), downloadUrl: optionalText(index?.downloadUrl), language: optionalText(index?.sourceLanguage), frequencyMode: optionalText(index?.frequencyMode), termCount: count(index?.counts?.terms?.total), frequencyCount: count(index?.counts?.termMeta?.freq), pitchCount: count(index?.counts?.termMeta?.pitch) + count(index?.counts?.termMeta?.ipa), kanjiCount: count(index?.counts?.kanji?.total), mediaCount: count(index?.counts?.media?.total), longKeyLength: longKeyLengthFromScanIndex(path), installedAt: installedAt(index?.importDate, path), lastUpdateCheck: null, }; } async function listLegacyImported(legacy) { const FS = engine.FS; const dictionaries = []; const expectedTitles = new Set( legacy.map((row) => text(row?.title)).filter((title) => title !== ""), ); for (const title of expectedTitles) { const path = `${DICT_ROOT}/${title}`; if (dictionaryRoot({ title, path }) === null) { continue; } let stat; try { stat = FS.stat(path); } catch (error) { continue; } if (isDirectory(stat) && hasDictionaryMarker(path)) { const dictionary = await packageFromIndex(path); if (dictionary.title !== title) { throw new Error(`${path}/index.json does not match its legacy dictionary title`); } dictionaries.push(dictionary); } } return dictionaries; } async function ask(type, fields = {}) { if (hostRequest === null) { throw new Error("the engine service has no host bridge"); } const reply = await hostRequest({ target: WORKER_TARGET, type, ...fields }); // A message nothing answers resolves with undefined rather than rejecting, and // an unanswered dictionary read is indistinguishable from empty storage -- // reconcile() would adopt every directory on disk as a term dictionary and // write that back over the user's choices. Never degrade to a default here. if (reply === undefined || reply === null) { throw new Error(`the service worker did not answer ${type}`); } return reply; } async function readDictionaryStorage() { const reply = await ask("hd_state_read"); if (reply.ok !== true) { throw new Error(reply.error || "the service worker could not read dictionary state"); } if (reply.state !== null && ( reply.state?.schemaVersion !== 1 || !Number.isInteger(reply.state?.revision) || reply.state.revision < 0 || !Array.isArray(reply.state?.dictionaries) )) { throw new Error("the service worker returned invalid dictionary state"); } if (reply.legacyDictionaries !== null && !Array.isArray(reply.legacyDictionaries)) { throw new Error("the service worker returned an invalid legacy dictionary list"); } return { state: reply.state, legacyDictionaries: reply.legacyDictionaries, }; } async function readStoredDictionaries() { const { state } = await readDictionaryStorage(); return state?.dictionaries ?? []; } async function readCustomStorage() { const reply = await ask("hd_custom_read"); if (reply.ok !== true) { throw new Error(reply.error || "the service worker could not read the custom dictionary"); } if (reply.state !== null && ( reply.state?.schemaVersion !== 1 || !Number.isInteger(reply.state?.revision) || reply.state.revision < 0 || !Array.isArray(reply.state?.dictionaries) || !Array.isArray(reply.state?.groups) )) { throw new Error("the service worker returned invalid custom dictionary state"); } return { document: normaliseCustomDictionaryDocument(reply.document), state: reply.state, }; } function sameDictionaries(left, right) { return sameJsonValue(left, right); } function groupsForDictionaries(groups, dictionaries) { const installed = new Set(dictionaries.map((dictionary) => dictionary?.id)); return (groups ?? []).map((group) => ({ ...group, dictionaryIds: (group?.dictionaryIds ?? []).filter((id, index, values) => installed.has(id) && values.indexOf(id) === index), })); } function expectedCustomDocument(document, source, semanticRevision) { if (document.text === source && document.semanticRevision === semanticRevision) { return document; } return { schemaVersion: 1, revision: document.revision + 1, semanticRevision, text: source, }; } async function commitCustomStorage(snapshot, source, semanticRevision, dictionaries) { if (snapshot.state === null) { throw new Error("the dictionary state is unavailable"); } const document = expectedCustomDocument(snapshot.document, source, semanticRevision); const groups = dictionaries === undefined ? snapshot.state.groups : groupsForDictionaries(snapshot.state.groups, dictionaries); const changesState = dictionaries !== undefined && (!sameDictionaries(snapshot.state.dictionaries, dictionaries) || !sameJsonValue(snapshot.state.groups, groups)); const state = changesState ? { schemaVersion: 1, revision: snapshot.state.revision + 1, dictionaries, groups, } : snapshot.state; const fields = { baseDocumentRevision: snapshot.document.revision, baseRevision: snapshot.state.revision, text: source, semanticRevision, ...(changesState ? { dictionaries, groups } : {}), }; try { const reply = await ask("hd_custom_cas", fields); // The host reply's envelope belongs to this internal CAS request. Let // handleEngineMessage apply the public save/append envelope instead of // allowing these fields to overwrite its type and request ID. const result = { ...reply }; delete result.type; delete result.requestId; delete result.generation; return result; } catch (commitError) { let current; try { current = await readCustomStorage(); } catch (readError) { throw new UnknownDictionaryStateCommitError( commitError, readError, "custom dictionary", ); } if (sameJsonValue(current.document, document) && sameJsonValue(current.state, state)) { return { ok: true, document, state }; } if (sameJsonValue(current.document, document)) { throw new UnknownDictionaryStateCommitError( commitError, new Error("readback did not match the exact source and dictionary-state pair"), "custom dictionary", ); } return { ok: false, stale: current.document.revision !== snapshot.document.revision, conflict: current.state?.revision !== snapshot.state.revision, error: describe(commitError), ...current, }; } } // A service worker can commit the CAS and disappear before its reply reaches // this worker. Read back that one exact revision so callers do not report a // failed change which storage already accepted. async function commitDictionaryState(baseRevision, dictionaries) { try { return await ask("hd_state_cas", { baseRevision, dictionaries }); } catch (commitError) { let state; try { ({ state } = await readDictionaryStorage()); } catch (readError) { throw new UnknownDictionaryStateCommitError(commitError, readError); } if (state?.revision === baseRevision + 1 && sameDictionaries(state.dictionaries, dictionaries)) { return { ok: true, state }; } const currentRevision = state?.revision ?? 0; return { ok: false, conflict: currentRevision !== baseRevision, error: describe(commitError), state, }; } } async function recoverPendingRemovals(snapshot) { if (!exists(REMOVAL_ROOT)) { return; } const stored = snapshot.state?.dictionaries ?? snapshot.legacyDictionaries ?? []; const retainedTitles = new Set(stored.map((dictionary) => text(dictionary?.title))); let changed = false; for (const title of engine.FS.readdir(REMOVAL_ROOT)) { const stagedPath = `${REMOVAL_ROOT}/${title}`; if (title === "." || title === ".." || !usableDictionaryTitle(title) || !isDirectory(engine.FS.stat(stagedPath))) { continue; } changed = settleStagedRemoval(title, retainedTitles.has(title)) || changed; } if (changed) { await persistFilesystem(); } } // `buildCandidate` must derive its list from the supplied snapshot: a refused // write is rebuilt against the authoritative state before it is attempted again. // Loading happens before the CAS so a disabled corrupt package cannot be // published merely because the live engine would otherwise skip it. async function commitDictionaryCandidate(buildCandidate) { for (let attempt = 0; ; attempt += 1) { const snapshot = await readDictionaryStorage(); const next = await buildCandidate(snapshot); const loadedCount = loadDictionaries(next, { committed: snapshot.state?.dictionaries ?? [] }); if (snapshot.state !== null && sameDictionaries(next, snapshot.state.dictionaries)) { return { state: snapshot.state, loadedCount }; } const baseRevision = snapshot.state?.revision ?? 0; const reply = await commitDictionaryState(baseRevision, next); if (reply.ok === true) { if (reply.state?.schemaVersion !== 1 || !Number.isInteger(reply.state?.revision) || !Array.isArray(reply.state?.dictionaries)) { throw new Error("the service worker returned invalid committed dictionary state"); } return { state: reply.state, loadedCount }; } if (reply.conflict !== true || attempt + 1 >= STORAGE_ATTEMPTS) { throw new Error(reply.error || "the service worker could not save dictionary state"); } } } function withStoredPresentation(generated, stored) { const sourceId = optionalText(stored?.sourceId); return { // Preserve fields introduced by newer Hachidori versions or integrations. // Generated engine metadata below replaces only values owned by this import. ...stored, ...generated, id: optionalText(stored?.id) ?? generated.id, displayName: typeof stored?.displayName === "string" ? stored.displayName : null, enabled: stored?.enabled !== false, favorite: stored?.favorite === true, isUpdatable: stored?.isUpdatable === true || generated.isUpdatable, indexUrl: stored?.indexUrl ?? generated.indexUrl, downloadUrl: stored?.downloadUrl ?? generated.downloadUrl, lastUpdateCheck: stored?.lastUpdateCheck ?? null, ...(stored?.updateScheduleOverride === undefined ? {} : { updateScheduleOverride: stored.updateScheduleOverride }), ...(sourceId === null ? {} : { sourceId }), }; } // Once revisioned state exists, its paths are the commit record. Refresh // generated metadata from those exact paths, but never discover another path // and silently publish it. async function refreshReferencedPackages(stored) { const entries = []; for (const storedPackage of stored) { const path = text(storedPackage?.path); if (dictionaryRoot(storedPackage) === null) { throw new Error(`the committed dictionary state contains an invalid path: ${path}`); } let generated; try { generated = await packageFromIndex(path); } catch (error) { // Keep the committed row; loading reports the package if it cannot load. console.warn(`hoshidicts: could not refresh ${path}: ${describe(error)}`); entries.push(storedPackage); continue; } if (generated.title !== storedPackage?.title) { throw new Error(`${path}/index.json does not match its committed dictionary title`); } entries.push(withStoredPresentation(generated, storedPackage)); } return entries; } function migrateLegacyPackages(legacy, onDisk) { const orderedTitles = []; const enabledByTitle = new Map(); for (const row of legacy) { const title = text(row?.title); if (title === "" || !onDisk.has(title)) { continue; } if (!enabledByTitle.has(title)) { orderedTitles.push(title); enabledByTitle.set(title, false); } if (row?.enabled !== false) { enabledByTitle.set(title, true); } } const entries = []; const listed = new Set(); for (const title of orderedTitles) { listed.add(title); entries.push({ ...onDisk.get(title), enabled: enabledByTitle.get(title) }); } for (const [title, generated] of onDisk) { if (!listed.has(title)) { entries.push(generated); } } return entries; } async function reconcile() { await recoverPendingRemovals(await readDictionaryStorage()); return commitDictionaryCandidate(async (snapshot) => { if (snapshot.state !== null) { return refreshReferencedPackages(snapshot.state.dictionaries); } const legacy = snapshot.legacyDictionaries ?? []; const onDisk = new Map( (await listLegacyImported(legacy)).map((dictionary) => [dictionary.title, dictionary]), ); return migrateLegacyPackages(legacy, onDisk); }); } function kindsForPackage(dictionary) { const kinds = [ ["term", dictionary.termCount], ["freq", dictionary.frequencyCount], ["pitch", dictionary.pitchCount], ["kanji", dictionary.kanjiCount], ] .filter(([, capabilityCount]) => Number(capabilityCount) > 0) .map(([kind]) => kind); return kinds.length === 0 ? ["term"] : kinds; } class DictionaryLoadError extends Error { constructor(dictionary, kindName) { super(`could not load ${dictionary.path} as ${kindName}: ${lastError()}`); this.name = "DictionaryLoadError"; this.dictionary = dictionary; } } function addDictionaries(dictionaries, includeDisabled) { let loadedCount = 0; for (const dictionary of dictionaries) { if (!includeDisabled && dictionary.enabled === false) { continue; } for (const kindName of kindsForPackage(dictionary)) { const kind = KINDS.indexOf(kindName); if (!engine.ccall("hdw_add_dict", "number", ["string", "number"], [dictionary.path, kind])) { throw new DictionaryLoadError(dictionary, kindName); } loadedCount += 1; } } return loadedCount; } // Every package, enabled or not, must load. A package a mutation introduces is // never published unless it does, but one already at a `committed` path that // stops loading is left out and reported in `loadFailures`: otherwise a single // broken package would stop lookups in every other dictionary. // // Loading a package copies its files into the wasm heap (about 1 ms per MB, so // 100-150 ms for Jitendex or Pixiv), and the engine cannot reorder or drop a // loaded package on its own, so the first version of this rebuilt the whole // set on every change. `loadedPackages` and `verifiedPackages` let a change // that only reorders, disables, or re-enables packages this session already // loaded be applied in place through hdw_remove_dict / hdw_add_dict / // hdw_set_dict_order instead. Package paths are generation-scoped and their // files never change once loaded, so a package that loaded once this session // loads again; anything else falls back to the full rebuild below, which also // discards whatever an interrupted incremental step left behind. let loadedPackages = null; const verifiedPackages = new Map(); function packageKinds(dictionary) { return kindsForPackage(dictionary).join(","); } function resetEngine() { engine.ccall("hdw_reset", null, [], []); loadedPackages = null; } function trackLoaded(dictionaries) { loadedPackages = dictionaries.map((dictionary) => ({ path: dictionary.path, kinds: packageKinds(dictionary), })); for (const entry of loadedPackages) verifiedPackages.set(entry.path, entry.kinds); } function retainVerified(dictionaries) { const requested = new Set(dictionaries.map((dictionary) => dictionary.path)); for (const path of [...verifiedPackages.keys()]) { if (!requested.has(path)) verifiedPackages.delete(path); } } function isVerified(dictionary) { return verifiedPackages.get(dictionary.path) === packageKinds(dictionary); } // Returns the loaded count, or null when the change needs the full rebuild. function loadDictionariesIncrementally(dictionaries) { if (loadedPackages === null || !dictionaries.every(isVerified)) { return null; } const enabled = dictionaries.filter((dictionary) => dictionary.enabled !== false); const wanted = new Map(enabled.map((dictionary) => [dictionary.path, packageKinds(dictionary)])); const present = new Map(loadedPackages.map((entry) => [entry.path, entry.kinds])); try { for (const entry of loadedPackages) { if (wanted.get(entry.path) === entry.kinds) continue; if (!engine.ccall("hdw_remove_dict", "number", ["string"], [entry.path])) return null; present.delete(entry.path); } for (const dictionary of enabled) { if (present.has(dictionary.path)) continue; addDictionaries([dictionary], true); present.set(dictionary.path, packageKinds(dictionary)); } const order = JSON.stringify(enabled.map((dictionary) => dictionary.path)); if (!engine.ccall("hdw_set_dict_order", "number", ["string"], [order])) return null; } catch (error) { if (!(error instanceof DictionaryLoadError)) throw error; return null; } finally { // Until the rebuild below or trackLoaded() describes it, the loaded set is // unknown; the fast path must not trust an interrupted step. loadedPackages = null; } trackLoaded(enabled); retainVerified(dictionaries); loadFailures = []; return enabled.reduce((count, dictionary) => count + kindsForPackage(dictionary).length, 0); } function loadDictionaries(dictionaries, { committed = [] } = {}) { for (const dictionary of dictionaries) { if (dictionaryRoot(dictionary) === null) { throw new Error(`refusing to load an invalid dictionary path: ${text(dictionary?.path)}`); } } const incremental = loadDictionariesIncrementally(dictionaries); if (incremental !== null) { return incremental; } const tolerated = new Set(committed.map((dictionary) => text(dictionary?.path))); const failed = []; const skipped = new Set(); const recordFailure = (error) => { if (!(error instanceof DictionaryLoadError) || !tolerated.has(error.dictionary.path)) { throw error; } skipped.add(error.dictionary.path); failed.push({ id: optionalText(error.dictionary.id), title: text(error.dictionary.title), error: error.message, }); }; for (const dictionary of dictionaries) { if (dictionary.enabled !== false || isVerified(dictionary)) { continue; } resetEngine(); try { addDictionaries([dictionary], true); verifiedPackages.set(dictionary.path, packageKinds(dictionary)); } catch (error) { recordFailure(error); } } // An enabled package can fail after some of its kinds were added, so reload // without it rather than keep part of it. for (;;) { resetEngine(); const candidates = dictionaries.filter((dictionary) => !skipped.has(dictionary.path)); try { const loadedCount = addDictionaries(candidates, false); loadFailures = failed; trackLoaded(candidates.filter((dictionary) => dictionary.enabled !== false)); retainVerified(dictionaries); return loadedCount; } catch (error) { recordFailure(error); } } } // The first hdw_lookup after the module starts runs 15-30x slower than the // steady state (about 7 ms against 0.3-0.5 ms for Jitendex: V8 tiers the wasm // up on first execution). Spend it here, still inside the serialised load, so a // reader's first hover after a browser start or an import gets a warm engine. // Best effort: the engine's failure fallback for a lookup is shape-valid. const WARM_LOOKUP_TEXT = "食べました"; function warmLookup() { try { engine.ccall( "hdw_lookup", "string", ["string", "number", "number", "string"], [WARM_LOOKUP_TEXT, 1, 4, ""], ); } catch { // A failed warm-up only forfeits the speedup; the next lookup reports it. } } function publishLoadedDictionaries(loadedCount) { dictionaryCount = loadedCount; generation += 1; if (loadedCount > 0) { warmLookup(); } } async function restoreCommittedDictionaries(state = null, { publish = true } = {}) { const committed = state ?? (await readDictionaryStorage()).state; if (committed === null) { throw new Error("the committed dictionary state is unavailable"); } const loadedCount = loadDictionaries(committed.dictionaries, { committed: committed.dictionaries }); if (publish) publishLoadedDictionaries(loadedCount); else dictionaryCount = loadedCount; reloadError = null; return committed; } // Reconciliation loads a fully validated candidate before publishing it. Keep // any failure for hd_status to report and for the next request to retry; public // count/generation state still describes the last published load set. async function reloadFromStorage() { try { const committed = await reconcile(); publishLoadedDictionaries(committed.loadedCount); reloadError = null; await cleanupCommittedDictionaries(); } catch (error) { reloadError = asError(error); throw reloadError; } } // A lookup served while reloadError is set would answer dictionaryCount 0, which // the content script renders as "no dictionaries imported" -- a lie the reader // cannot act on. Retry instead: the usual cause is a service worker that died // mid-message, and the next request reaches a fresh one. async function ensureLoaded() { requireEngine(); if (reloadError !== null) { await reloadFromStorage(); } } // Single-flight: hd_status is polled every second while the engine is loading, // and each retry unloads and reloads every dictionary. let reloadRetry = null; function retryReload() { if (reloadRetry !== null || preparedBackup !== null) { return; } // Through serialise(), or this would call hdw_reset underneath a running // import. The rejection is already recorded in reloadError. const run = serialise(reloadFromStorage); reloadRetry = run; const done = () => { if (reloadRetry === run) { reloadRetry = null; } }; run.then(done, done); } async function boot() { try { if (typeof createHoshidicts !== "function") { throw new Error("the engine service has no WASM factory"); } engine = await createHoshidicts(); loadedPackages = null; verifiedPackages.clear(); if (storageBackend === "idbfs") { speedUpMemfsGrowth(); storeLargeIdbfsFilesAsBlobs(); if (!exists(DICT_ROOT)) { engine.FS.mkdir(DICT_ROOT); } engine.FS.mount(engine.IDBFS, {}, DICT_ROOT); await syncfs(true); } const initialized = engine.ccall( "hdw_init_storage", "number", ["number"], [storageBackend === "opfs" ? 1 : 0], ); if (initialized !== 1) { throwIfEngineFailed("hdw_init_storage"); throw new Error("hdw_init_storage failed"); } if (storageBackend === "idbfs") { // The populated filesystem may contain a transaction written by the old // canonical-path importer; persist native recovery before reconciliation. await persistFilesystem(); } else if (storageBackend === "opfs") { // Earlier versions staged the archive in OPFS; remove one left by an // interrupted import there. try { engine.FS.unlink(OPFS_IMPORT_ZIP); } catch { // No archive was left by an interrupted import. } } ready = true; } catch (error) { ready = false; bootError = asError(error); console.error(`hoshidicts: the engine failed to start: ${bootError.message}`); return; } // Outside the try above on purpose: a failed dictionary list is not a failed // engine, and latching bootError here would leave a working engine refusing // every lookup and every import until the browser restarts, since nothing // recreates this document. try { await reloadFromStorage(); } catch (error) { console.error(`hoshidicts: could not load the dictionaries at startup: ${describe(error)}`); } } function emptyReport(error) { return { success: false, title: "", termCount: 0, metaCount: 0, frequencyCount: 0, pitchCount: 0, kanjiCount: 0, mediaCount: 0, error, }; } function normaliseReport(raw) { const report = emptyReport(text(raw?.error)); report.success = raw?.success === true; report.title = text(raw?.title); for (const key of ["termCount", "metaCount", "frequencyCount", "pitchCount", "kanjiCount", "mediaCount"]) { const count = Number(raw?.[key]); report[key] = Number.isFinite(count) ? count : 0; } return report; } function withCustomDictionary(stored, generated) { const current = stored.find((dictionary) => dictionary?.id === CUSTOM_DICTIONARY_ID); if (stored.some((dictionary) => dictionary?.id !== CUSTOM_DICTIONARY_ID && text(dictionary?.title) === CUSTOM_DICTIONARY_TITLE)) { throw new Error(`a dictionary named ${CUSTOM_DICTIONARY_TITLE} is already installed`); } const custom = { ...generated, id: CUSTOM_DICTIONARY_ID, title: CUSTOM_DICTIONARY_TITLE, displayName: typeof current?.displayName === "string" ? current.displayName : null, enabled: true, favorite: current?.favorite === true, isUpdatable: false, indexUrl: null, downloadUrl: null, lastUpdateCheck: null, }; return [custom, ...stored.filter((dictionary) => dictionary?.id !== CUSTOM_DICTIONARY_ID)]; } async function customPackageSatisfies(state, semanticRevision, entryCount) { const custom = state?.dictionaries?.[0]; if (custom?.id !== CUSTOM_DICTIONARY_ID || custom?.title !== CUSTOM_DICTIONARY_TITLE || custom?.enabled !== true || custom?.revision !== semanticRevision || custom?.termCount !== entryCount || !isGenerationRoot(dictionaryRoot(custom) ?? "")) { return false; } try { const generated = await packageFromIndex(custom.path); if (!customDictionaryMetadataMatches(generated, semanticRevision, entryCount) || !sameDictionaries( state.dictionaries, withCustomDictionary(state.dictionaries, generated), )) { return false; } loadDictionaries(state.dictionaries, { committed: state.dictionaries.filter((dictionary) => dictionary?.id !== CUSTOM_DICTIONARY_ID), }); return true; } catch { return false; } } function importReplacementIndex(stored, generated, recommendedSource, managedSource) { let existingIndex = managedSource === null ? -1 : stored.findIndex((dictionary) => dictionary?.id === managedSource.fingerprint.id); if (managedSource !== null) { const existing = stored[existingIndex]; if (existingIndex < 0 || !managedDictionaryMatches(existing, managedSource.fingerprint)) { throw new Error(MANAGED_DICTIONARY_CHANGED); } } if (existingIndex < 0 && recommendedSource !== null) { existingIndex = stored.findIndex((dictionary) => optionalText(dictionary?.sourceId) === recommendedSource.sourceId); } if (existingIndex < 0) { existingIndex = stored.findIndex((dictionary) => dictionary?.id === generated.id || text(dictionary?.title) === generated.title); } return existingIndex; } function withImport(stored, generated, recommendedSource, managedSource) { if (generated.title === CUSTOM_DICTIONARY_TITLE) { throw new Error(`${CUSTOM_DICTIONARY_TITLE} is reserved for the managed custom dictionary`); } const existingIndex = importReplacementIndex( stored, generated, recommendedSource, managedSource, ); if (existingIndex < 0) { return [...stored, recommendedSource === null ? generated : withRecommendedSource(generated, recommendedSource)]; } const collision = stored.some((dictionary, index) => index !== existingIndex && (dictionary?.id === generated.id || text(dictionary?.title) === generated.title)); if (collision) { throw new Error(`a dictionary named ${generated.title} is already installed`); } const next = [...stored]; let replacement = { ...withStoredPresentation(generated, stored[existingIndex]), lastUpdateCheck: null, }; if (managedSource !== null) { replacement = { ...replacement, isUpdatable: true, indexUrl: managedSource.fingerprint.source.indexUrl, downloadUrl: managedSource.fingerprint.source.downloadUrl, lastUpdateCheck: { checkedAt: managedSource.checkedAt, status: "up-to-date", remoteRevision: generated.revision, error: null, }, }; } next[existingIndex] = recommendedSource === null ? replacement : withRecommendedSource(replacement, recommendedSource); return next; } function sameImportedIdentity(left, right) { return left.title === right.title && left.revision === right.revision && left.indexUrl === right.indexUrl && left.downloadUrl === right.downloadUrl; } function interactiveTargetIndex(stored, decision) { const existingIndex = stored.findIndex(dictionary => dictionary?.id === decision.target.id); const existing = stored[existingIndex]; if (existingIndex < 0 || !sameJsonValue(dictionaryImportTarget(existing), decision.target)) { throw new Error("the chosen dictionary changed while the archive was being imported"); } if (decision.matchKind === "title") { if (existing.title !== decision.identity.title) { throw new Error("the chosen dictionary no longer matches the imported title"); } } else { if (decision.identity.indexUrl === null || optionalText(existing.indexUrl) !== decision.identity.indexUrl || stored.some(dictionary => dictionary?.title === decision.identity.title)) { throw new Error("the chosen dictionary no longer matches the imported source"); } } return existingIndex; } function storedImportIdentityIndex(stored, identity) { const titleIndex = stored.findIndex(dictionary => dictionary?.title === identity.title); if (titleIndex >= 0 || identity.indexUrl === null) return titleIndex; return stored.findIndex(dictionary => optionalText(dictionary?.indexUrl) === identity.indexUrl); } function withInteractiveReplacement(stored, generated, existingIndex) { const current = stored[existingIndex]; const collision = stored.some((dictionary, index) => index !== existingIndex && (dictionary?.id === current.id || dictionary?.title === generated.title)); if (collision) { throw new Error(`a dictionary named ${generated.title} is already installed`); } const replacement = { ...withStoredPresentation(generated, current), // A local archive cannot acquire or redirect update ownership while // replacing a package. Keep the target's source contract verbatim. isUpdatable: current?.isUpdatable === true, indexUrl: optionalText(current?.indexUrl), downloadUrl: optionalText(current?.downloadUrl), lastUpdateCheck: null, }; if (current?.sourceId === undefined) delete replacement.sourceId; const next = [...stored]; next[existingIndex] = replacement; return next; } async function separateDictionaryTitle(title, stored) { for (let suffix = 2; ; suffix += 1) { const candidate = `${title} (${suffix})`; if (!usableDictionaryTitle(candidate) || stored.some(dictionary => dictionary?.title === candidate)) { continue; } const id = await stableDictionaryId(candidate); if (!stored.some(dictionary => dictionary?.id === id)) return candidate; } } function retitleImportedPackage(generationRoot, currentTitle, nextTitle) { if (currentTitle === nextTitle) return; if (!usableDictionaryTitle(nextTitle)) { throw new Error("the separate dictionary title cannot be used as a filesystem path"); } const currentPath = `${generationRoot}/${currentTitle}`; const nextPath = `${generationRoot}/${nextTitle}`; if (!exists(currentPath) || exists(nextPath)) { throw new Error("the staged dictionary title changed unexpectedly"); } const index = readDictionaryIndex(currentPath); if (index?.title !== currentTitle) { throw new Error("the staged dictionary index does not match its directory"); } moveDictionaryFiles(currentPath, nextPath, true); index.title = nextTitle; const indexPath = `${nextPath}/index.json`; // WasmFS's OPFS writeFile does not reliably truncate an existing file. This // serialized rewrite can be shorter than the engine-generated index, leaving // trailing JSON bytes if it overwrites the moved file in place. engine.FS.unlink(indexPath); engine.FS.writeFile(indexPath, new TextEncoder().encode(JSON.stringify(index))); } async function cleanupCommittedDictionaries() { try { const { state } = await readDictionaryStorage(); if (state === null) { return; } const retained = await ask("hd_backup_auto_roots"); if (!retained.ok) throw new Error(retained.error || "Could not read automatic backup roots."); if (retained.complete !== true) { console.warn("hoshidicts: automatic backup metadata is corrupt; retaining unreferenced dictionary generations"); return; } if (!Array.isArray(retained.dictionaries)) { throw new TypeError("The automatic backup root list is invalid."); } await cleanupUnreferencedDictionaries([ ...state.dictionaries, ...retained.dictionaries, ...(preparedBackup?.dictionaries ?? []), ]); } catch (error) { console.warn(`hoshidicts: could not remove unreferenced dictionaries: ${describe(error)}`); } } async function commitImportedGeneration( generationRoot, report, recommendedSource, managedSource, expectedRevision, importDecision, ) { let generated = await packageFromIndex(`${generationRoot}/${report.title}`); if (generated.title !== report.title) { throw new Error("the imported dictionary title changed while it was being committed"); } const importedIdentity = importedIdentityFromIndex(generated.path); if (importDecision !== null && !sameImportedIdentity(importedIdentity, importDecision.identity)) { throw new Error("the imported dictionary metadata did not match the reviewed archive"); } if (expectedRevision !== null && generated.revision !== expectedRevision) { throw new Error("the downloaded dictionary revision did not match its update index"); } if (managedSource !== null && httpsUrl(generated.indexUrl) !== managedSource.fingerprint.source.indexUrl) { throw new Error("the downloaded dictionary did not match its update source"); } if (recommendedSource !== null) { validateRecommendedImport(recommendedSource, report, generated); } if (generated.title === CUSTOM_DICTIONARY_TITLE) { throw new Error(`${CUSTOM_DICTIONARY_TITLE} is reserved for the managed custom dictionary`); } let candidateTitle = generated.title; const committed = await commitDictionaryCandidate(async (snapshot) => { const stored = snapshot.state?.dictionaries ?? []; if (importDecision === null) { return withImport(stored, generated, recommendedSource, managedSource); } if (importDecision.action === "install") { if (storedImportIdentityIndex(stored, importDecision.identity) >= 0) { throw new Error("the dictionary library changed; review this import again"); } return [...stored, generated]; } const existingIndex = interactiveTargetIndex(stored, importDecision); if (importDecision.action === "replace") { return withInteractiveReplacement(stored, generated, existingIndex); } const nextTitle = await separateDictionaryTitle(importDecision.identity.title, stored); if (candidateTitle !== nextTitle) { retitleImportedPackage(generationRoot, candidateTitle, nextTitle); candidateTitle = nextTitle; await persistFilesystem(); generated = await packageFromIndex(`${generationRoot}/${candidateTitle}`); } return [...stored, generated]; }); report.title = generated.title; publishLoadedDictionaries(committed.loadedCount); reloadError = null; await cleanupCommittedDictionaries(); return committed.state; } async function rollbackImportedGenerations(generationRoots, failure) { let committed = null; let restoreError = null; try { committed = await restoreCommittedDictionaries(); } catch (error) { restoreError = error; reloadError = asError(error); } const retained = new Set(committed?.dictionaries.map(dictionaryRoot)); if (committed !== null) { try { await discardGenerations(generationRoots.filter(root => !retained.has(root))); } catch (error) { console.warn(`hoshidicts: could not discard failed dictionary generations: ${describe(error)}`); } } if (restoreError !== null) { throw new Error(`${describe(failure)}; dictionary rollback failed: ${describe(restoreError)}`); } } function rollbackImportedGeneration(generationRoot, failure) { return rollbackImportedGenerations([generationRoot], failure); } // The engine matches a term bank `path` to a ZIP entry name byte for byte. // Archives built on macOS store decomposed (NFD) Japanese names while the // term bank keeps the composed (NFC) form, and some converters percent-encode // the path; in both cases the same file is meant. Try the spelled path first, // then those equivalents. Names in another byte encoding cannot be recovered // here. function mediaPathCandidates(path) { const candidates = [path]; let decoded = path; if (path.includes("%")) { try { decoded = decodeURIComponent(path); candidates.push(decoded); } catch { // Not percent-encoded; keep the raw path only. } } for (const source of new Set([path, decoded])) { candidates.push(source.normalize("NFC"), source.normalize("NFD")); } return [...new Set(candidates)]; } function mediaType(path) { const dot = path.lastIndexOf("."); const extension = dot < 0 ? "" : path.slice(dot + 1).toLowerCase(); return MEDIA_TYPES[extension] ?? "application/octet-stream"; } // A declared length is only comparable to the received bytes when the body is // not transformed in flight; a content-encoded response counts decoded bytes // against an encoded total, so it reports no total at all. export function declaredResponseLength(response) { const headers = response?.headers; if (typeof headers?.get !== "function") return null; const encoding = headers.get("content-encoding"); if (encoding !== null && encoding !== "" && encoding.trim().toLowerCase() !== "identity") return null; const length = Number(headers.get("content-length")); return Number.isSafeInteger(length) && length > 0 ? length : null; } async function consumeResponse(response, consume, onProgress = null) { const reader = response.body?.getReader?.(); const totalBytes = declaredResponseLength(response); const report = (receivedBytes) => { onProgress?.({ phase: "downloading", receivedBytes, totalBytes: totalBytes !== null && receivedBytes <= totalBytes ? totalBytes : null }); }; if (reader === undefined) { const bytes = new Uint8Array(await response.arrayBuffer()); consume(bytes); report(bytes.byteLength); return bytes.byteLength; } let received = 0; let reportedAt = -Infinity; try { for (;;) { const { done, value } = await reader.read(); if (done) break; const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); if (bytes.byteLength === 0) continue; consume(bytes); received += bytes.byteLength; if (onProgress !== null && performance.now() - reportedAt >= PROGRESS_INTERVAL_MS) { reportedAt = performance.now(); report(received); } } } finally { reader.releaseLock?.(); } report(received); return received; } const PROT_READ_WRITE = 0x1 | 0x2; const MAP_SHARED = 0x01; // Writes one buffer as the whole file. WasmFS's FS.write copies from JavaScript // one byte at a time (about 25 ns per byte: a full second for the 39 MiB // Jitendex archive), and its FS.writeFile on the OPFS backend appends to an // existing file and leaves it undeletable until the next start. A shared // writable mapping gives a single typed-array copy and one write-back through // the OPFS proxy. The legacy FS (single-thread IDBFS build) has no munmap and // its FS.write is already a typed-array copy, so it takes the direct path. function writeFileBytes(FS, path, data) { const stream = FS.open(path, "w+"); try { if (data.byteLength === 0 || typeof FS.mmap !== "function" || typeof FS.munmap !== "function") { for (let offset = 0; offset < data.byteLength;) { const written = FS.write(stream, data, offset, data.byteLength - offset); if (!(written > 0)) { throw new Error(`could not write ${path}`); } offset += written; } return; } FS.ftruncate(stream.fd, data.byteLength); const mapping = FS.mmap(stream, data.byteLength, 0, PROT_READ_WRITE, MAP_SHARED); try { // Module.HEAPU8 is swapped out after memory growth only once some glue // touches the heap; FS.stat does, so a view too short for the mapping is // refreshed before the copy. let heap = engine.HEAPU8; if (heap.byteLength < mapping.ptr + data.byteLength) { FS.stat(path); heap = engine.HEAPU8; } heap.set(data, mapping.ptr); FS.msync(stream, mapping.ptr, 0, data.byteLength, MAP_SHARED); } finally { FS.munmap(mapping.ptr, data.byteLength); } } finally { FS.close(stream); } } // The stream is collected and written once; the importer maps the whole file // into the heap anyway, so holding the bytes in JavaScript until the stream ends // does not change the largest archive that can be imported. function concatenateParts(parts, byteLength) { if (parts.length === 1) { return parts[0]; } const data = new Uint8Array(byteLength); let offset = 0; for (const part of parts) { data.set(part, offset); offset += part.byteLength; } parts.length = 0; return data; } async function collectResponse(response, onProgress = null) { const parts = []; let byteLength = 0; await consumeResponse( response, (bytes) => { parts.push(bytes); byteLength += bytes.byteLength; }, onProgress, ); return { bytes: concatenateParts(parts, byteLength), byteLength }; } // `source` is a Response, or bytes already collected by stageImportArchive. export async function streamResponseToFile(FS, source, path, onProgress = null) { if (source instanceof Uint8Array) { writeFileBytes(FS, path, source); return source.byteLength; } const { bytes, byteLength } = await collectResponse(source, onProgress); writeFileBytes(FS, path, bytes); return byteLength; } // The archive is collected into one buffer outside the engine's serialised // section and handed to the import as-is. Staging it as a Blob instead cost // three more copies of the archive (chunk slices, the Blob, and reading the // Blob back) for about 70 ms on a Jitendex import, and the concatenated buffer // exists on the import path either way. export async function stageImportArchive(response, onProgress = null) { return collectResponse(response, onProgress); } function removeStagedFile(FS, path) { try { FS.unlink(path); } catch (error) { // Never written, or already gone. } } // Where the archive is staged. A Yomitan ZIP has a fixed scratch name; an MDX // keeps its own name inside IMPORT_MDX_DIR with its MDD files beside it. function importStagingPaths(fileName, resources) { if (resources.length === 0 && !isMdxFileName(fileName)) { return { directory: null, archivePath: IMPORT_ZIP, resourcePaths: [] }; } return { directory: IMPORT_MDX_DIR, archivePath: `${IMPORT_MDX_DIR}/${fileName}`, resourcePaths: resources.map((resource) => `${IMPORT_MDX_DIR}/${resource.fileName}`), }; } async function importDictionaryArchive( archiveSource, generationRoot, importLowRam, fileName, expectedArchiveBytes = null, resources = [], ) { const FS = engine.FS; const { directory, archivePath, resourcePaths } = importStagingPaths(fileName, resources); try { if (directory !== null) { try { FS.mkdir(directory); } catch (error) { // Left by an interrupted import; its files are overwritten below. } } const archiveBytes = await streamResponseToFile(FS, archiveSource, archivePath); if (archiveBytes === 0) { throw new Error(`${fileName} is empty`); } if (expectedArchiveBytes !== null && archiveBytes !== expectedArchiveBytes) { throw new Error(`${fileName} changed while it was staged`); } resources.forEach((resource, index) => { writeFileBytes(FS, resourcePaths[index], resource.bytes); }); return normaliseReport( parseJson( engine.ccall( "hdw_import", "string", ["string", "string", "number"], [archivePath, generationRoot, importLowRam ? 1 : 0], ), "hdw_import", ), ); } finally { removeStagedFile(FS, archivePath); for (const path of resourcePaths) removeStagedFile(FS, path); if (directory !== null) { try { FS.rmdir(directory); } catch (error) { // Never created, or already gone. } } } } async function managedSourceForImport(message, recommendedSource) { const requested = message?.managedFingerprint; if (requested === null || requested === undefined) { return null; } const dictionary = (await readStoredDictionaries()).find((entry) => entry?.id === requested?.id); if (!managedDictionaryMatches(dictionary, requested)) { throw new Error(MANAGED_DICTIONARY_CHANGED); } const fingerprint = managedDictionaryFingerprint(dictionary); const isRecommended = fingerprint.source.kind === "recommended"; if ((isRecommended && recommendedSource?.sourceId !== fingerprint.source.sourceId) || (!isRecommended && recommendedSource !== null)) { throw new Error(MANAGED_DICTIONARY_CHANGED); } const archiveUrl = httpsUrl(message?.archiveUrl); if (archiveUrl === null || (isRecommended && archiveUrl !== fingerprint.source.downloadUrl)) { throw new Error("the managed dictionary update carried an invalid archive URL"); } const checkedAt = typeof message?.checkedAt === "string" && message.checkedAt !== "" ? message.checkedAt : null; if (checkedAt === null) { throw new Error("the managed dictionary update carried no check time"); } return { fingerprint, archiveUrl, checkedAt, }; } function validateLocalImportRequest(message, managedSource, recommendedSource) { if (managedSource !== null) { throw new Error("the managed import request carried a blob URL"); } if (recommendedSource !== null && !recommendedDownloadUrlMatches(recommendedSource, optionalText(message.finalUrl))) { throw new Error(`${recommendedSource.name} downloaded from an unexpected final URL`); } } function plainFileName(value) { return typeof value === "string" && value !== "" && value !== "." && value !== ".." && !/[/\\\0]/u.test(value); } function isMdxFileName(fileName) { return /\.mdx$/iu.test(fileName); } // The MDD resource files chosen with an .mdx: staged next to it under their own // names so the engine's sibling discovery (`.mdd`, `.1.mdd`, ...) // sees them. Only an ordinary local import of an .mdx can carry them. function importResources(message, remote, fileName) { const value = message.resources; if (value === undefined) return []; if (!Array.isArray(value)) { throw new TypeError("the import request carried an invalid resource list"); } if (value.length === 0) return []; if (remote || !isMdxFileName(fileName)) { throw new Error("only a local .mdx import can carry resource files"); } const names = new Set([fileName]); return value.map((resource) => { const name = resource?.fileName; const blobUrl = text(resource?.blobUrl); if (!plainFileName(name) || !/\.mdd$/iu.test(name) || blobUrl === "") { throw new Error("the import request carried an invalid resource file"); } if (names.has(name)) { throw new Error(`the import request lists ${name} twice`); } names.add(name); return { fileName: name, blobUrl }; }); } function exactNullableString(value, label, { empty = false } = {}) { if (value === null) return null; if (typeof value !== "string" || (!empty && value === "")) { throw new Error(`the import decision carried an invalid ${label}`); } return value; } function exactRequiredString(value, label) { if (typeof value !== "string" || value === "") { throw new Error(`the import decision carried an invalid ${label}`); } return value; } function importDecisionIdentity(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("the import decision carried no archive identity"); } return { title: exactRequiredString(value.title, "dictionary title"), revision: exactNullableString(value.revision, "dictionary revision", { empty: true }), indexUrl: exactNullableString(value.indexUrl, "dictionary index URL"), downloadUrl: exactNullableString(value.downloadUrl, "dictionary download URL"), }; } function importDecisionTarget(value) { if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.id !== "string" || value.id === "" || typeof value.title !== "string" || value.title === "" || typeof value.path !== "string" || value.path === "" || typeof value.revision !== "string" || !["string", "object"].includes(typeof value.sourceId) || !["string", "object"].includes(typeof value.indexUrl) || !["string", "object"].includes(typeof value.downloadUrl) || typeof value.isUpdatable !== "boolean") { throw new Error("the import decision carried an invalid replacement target"); } return { ...dictionaryImportTarget(value), sourceId: exactNullableString(value.sourceId, "target source ID"), indexUrl: exactNullableString(value.indexUrl, "target index URL"), downloadUrl: exactNullableString(value.downloadUrl, "target download URL"), }; } function interactiveImportDecision(value, remote, managedSource, recommendedSource) { if (value === undefined) return null; if (remote || managedSource !== null || recommendedSource !== null) { throw new Error("only an ordinary local import can carry an interactive decision"); } if (!value || typeof value !== "object" || Array.isArray(value) || !["install", "replace", "separate"].includes(value.action)) { throw new Error("the import decision carried an invalid action"); } const identity = importDecisionIdentity(value.identity); if (value.action === "install") { if (value.target !== null || value.matchKind !== null) { throw new Error("a new import decision cannot name a replacement target"); } return { action: value.action, identity, target: null, matchKind: null }; } if (!["title", "source"].includes(value.matchKind)) { throw new Error("the import decision carried an invalid target match"); } return { action: value.action, identity, target: importDecisionTarget(value.target), matchKind: value.matchKind, }; } // A remote import is either a checked managed update or a first install of a // recommended source, which downloads only its catalogue-pinned archive. function validateRemoteImportRequest(message, managedSource, recommendedSource, expectedRevision) { if (managedSource !== null) { if (expectedRevision === null) { throw new Error("the managed import request carried no expected revision"); } return managedSource.archiveUrl; } if (recommendedSource === null) { throw new Error("the import request carried no archive URL"); } if (httpsUrl(message.archiveUrl) !== recommendedSource.downloadUrl) { throw new Error(`${recommendedSource.name} must be downloaded from its catalogue archive URL`); } return recommendedSource.downloadUrl; } async function prepareImportRequest(message) { const blobUrl = text(message.blobUrl); const importLowRam = typeof message.lowRam === "boolean" ? message.lowRam : lowRam; const recommendedSource = recommendedSourceForImport(message); const managedSource = await managedSourceForImport(message, recommendedSource); const expectedRevision = optionalText(message.expectedRevision); const remote = blobUrl === ""; let archiveUrl = blobUrl; if (remote) { archiveUrl = validateRemoteImportRequest(message, managedSource, recommendedSource, expectedRevision); } else { validateLocalImportRequest(message, managedSource, recommendedSource); } const importDecision = interactiveImportDecision( message.importDecision, remote, managedSource, recommendedSource, ); const fileName = text(message.fileName) || recommendedSource?.archiveName || "the archive"; return { archiveUrl, expectedRevision, fileName, importDecision, importLowRam, managedSource, recommendedSource, remote, resources: importResources(message, remote, fileName), }; } function samePreparedImport(left, right) { return left.archiveUrl === right.archiveUrl && left.expectedRevision === right.expectedRevision && left.fileName === right.fileName && left.importLowRam === right.importLowRam && left.remote === right.remote && JSON.stringify(left.importDecision) === JSON.stringify(right.importDecision) && JSON.stringify(left.resources) === JSON.stringify(right.resources) && left.recommendedSource?.sourceId === right.recommendedSource?.sourceId && left.managedSource?.checkedAt === right.managedSource?.checkedAt && JSON.stringify(left.managedSource?.fingerprint ?? null) === JSON.stringify(right.managedSource?.fingerprint ?? null); } function remoteArchiveFinalUrlMatches(request, finalUrl) { return request.recommendedSource === null ? httpsUrl(finalUrl) !== null : recommendedDownloadUrlMatches(request.recommendedSource, finalUrl); } async function fetchImportArchive(request) { const response = await fetch(request.archiveUrl, { credentials: "omit" }); if (!response.ok) { throw new Error(`could not read ${request.fileName}: HTTP ${response.status}`); } if (request.remote && !remoteArchiveFinalUrlMatches(request, optionalText(response.url))) { throw new Error(`${request.fileName} downloaded from an unexpected final URL`); } return response; } // The MDD files are read like the archive, one at a time, and held as bytes // until the import stages them next to the .mdx. async function stageImportResources(request) { const staged = []; for (const resource of request.resources) { const response = await fetch(resource.blobUrl, { credentials: "omit" }); if (!response.ok) { throw new Error(`could not read ${resource.fileName}: HTTP ${response.status}`); } const { bytes, byteLength } = await collectResponse(response); if (byteLength === 0) { throw new Error(`${resource.fileName} is empty`); } staged.push({ fileName: resource.fileName, bytes }); } return staged; } async function runImportTransaction( archiveSource, fileName, importLowRam, commit, expectedArchiveBytes = null, resources = [], ) { const generationRoot = createGenerationRoot(); // Unload before importing: the loaded dictionaries are mapped into the same // 32-bit address space the importer needs. Public count/generation state is // not changed until either the candidate or the committed state is loaded. resetEngine(); // The archive is scratch: staging it in MEMFS instead of OPFS saves the // proxied write, read-back mapping, and unlink (about 50 ms of hdw_import for // Jitendex) and writes nothing to disk that the importer does not keep. The // heap holds one extra copy of the archive for the duration of the import. let report; let rollbackAttempted = false; try { report = await importDictionaryArchive( archiveSource, generationRoot, importLowRam, fileName, expectedArchiveBytes, resources, ); if (report.success && report.title === "") { // hdw_import refuses a title it cannot use as a folder name, so this is // unreachable; without a title there is nothing to register, and a row // with an empty title would poison reconcile(). report.success = false; report.error = `${fileName} declares no dictionary title`; } if (report.success) { await persistFilesystem(); await commit(generationRoot, report); } else { const failure = new Error(report.error || `${fileName} could not be imported`); rollbackAttempted = true; await rollbackImportedGeneration(generationRoot, failure); } } catch (error) { if (error instanceof UnknownDictionaryStateCommitError) { // Storage may already reference the new path. Neither generation is safe // to delete until an authoritative read succeeds. reloadError = error; throw error; } if (!rollbackAttempted) { await rollbackImportedGeneration(generationRoot, error); } throw error; } return report; } class CustomCommitRejectedError extends Error { constructor(reply) { super(reply?.error || "the custom dictionary could not be saved"); this.name = "CustomCommitRejectedError"; this.reply = reply; } } function customStaleReply(snapshot) { return { ok: false, stale: true, error: "the custom dictionary source changed while it was being saved", ...snapshot, }; } function customSnapshotFromReply(reply) { return { document: normaliseCustomDictionaryDocument(reply.document), state: reply.state, }; } async function commitCustomSourceOnly(initial, source, semanticRevision) { let snapshot = initial; for (let attempt = 0; attempt < STORAGE_ATTEMPTS; attempt += 1) { if (snapshot.document.revision !== initial.document.revision) { return customStaleReply(snapshot); } const reply = await commitCustomStorage(snapshot, source, semanticRevision); if (reply.ok === true || reply.stale === true) return reply; if (reply.conflict !== true || reply.document === undefined || reply.state === null) { return reply; } snapshot = customSnapshotFromReply(reply); } return { ok: false, conflict: true, error: "the dictionary state kept changing while the custom source was being saved", ...snapshot, }; } async function completeCustomRemoval(reply, removesPackage, loadedCount) { if (removesPackage) { publishLoadedDictionaries(loadedCount); reloadError = null; await cleanupCommittedDictionaries(); } return { ...reply, rebuilt: false, removed: removesPackage }; } async function rethrowCustomRemovalFailure(error, removesPackage) { if (error instanceof UnknownDictionaryStateCommitError) { reloadError = error; throw error; } if (removesPackage) { try { await restoreCommittedDictionaries(); } catch (restoreError) { reloadError = asError(restoreError); throw new Error(`${describe(error)}; custom dictionary rollback failed: ${describe(restoreError)}`); } } throw error; } function customRemovalLoadedCount(dictionaries, removesPackage) { if (!removesPackage) return dictionaryCount; return loadDictionaries(dictionaries, { committed: dictionaries }); } async function commitCustomRemoval(initial, source, semanticRevision) { let snapshot = initial; for (let attempt = 0; attempt < STORAGE_ATTEMPTS; attempt += 1) { if (snapshot.document.revision !== initial.document.revision) { return customStaleReply(snapshot); } const dictionaries = snapshot.state.dictionaries.filter( (dictionary) => dictionary?.id !== CUSTOM_DICTIONARY_ID, ); const removesPackage = dictionaries.length !== snapshot.state.dictionaries.length; try { const loadedCount = customRemovalLoadedCount(dictionaries, removesPackage); const reply = await commitCustomStorage( snapshot, source, semanticRevision, removesPackage ? dictionaries : undefined, ); if (reply.ok === true) { const completed = await completeCustomRemoval(reply, removesPackage, loadedCount); return completed; } if (reply.conflict === true && reply.stale !== true && reply.document !== undefined && reply.state !== null && attempt + 1 < STORAGE_ATTEMPTS) { snapshot = customSnapshotFromReply(reply); continue; } if (removesPackage) await restoreCommittedDictionaries(reply.state ?? snapshot.state); return reply; } catch (error) { await rethrowCustomRemovalFailure(error, removesPackage); } } throw new Error("the dictionary state kept changing while the custom dictionary was removed"); } async function commitCustomGeneration( initial, source, semanticRevision, generationRoot, report, ) { const generated = await packageFromIndex(`${generationRoot}/${report.title}`); if (report.title !== CUSTOM_DICTIONARY_TITLE || generated.title !== CUSTOM_DICTIONARY_TITLE) { throw new Error("the compiled custom archive has the wrong dictionary title"); } if (generated.revision !== semanticRevision) { throw new Error("the compiled custom archive has the wrong semantic revision"); } let snapshot = initial; for (let attempt = 0; attempt < STORAGE_ATTEMPTS; attempt += 1) { if (snapshot.document.revision !== initial.document.revision) { throw new CustomCommitRejectedError(customStaleReply(snapshot)); } const dictionaries = withCustomDictionary(snapshot.state.dictionaries, generated); const loadedCount = loadDictionaries(dictionaries, { committed: snapshot.state.dictionaries }); const reply = await commitCustomStorage(snapshot, source, semanticRevision, dictionaries); if (reply.ok === true) { publishLoadedDictionaries(loadedCount); reloadError = null; await cleanupCommittedDictionaries(); return reply; } if (reply.conflict === true && reply.stale !== true && reply.document !== undefined && reply.state !== null && attempt + 1 < STORAGE_ATTEMPTS) { snapshot = customSnapshotFromReply(reply); continue; } throw new CustomCommitRejectedError(reply); } throw new Error("the dictionary state kept changing while the custom dictionary was saved"); } async function saveCustomDictionary(snapshot, source) { const parsed = parseCustomDictionary(source); const semanticRevision = await customDictionarySemanticRevision(parsed.entries); if (parsed.entries.length === 0) { const removed = await commitCustomRemoval(snapshot, source, semanticRevision); return { ...removed, errors: parsed.errors }; } if (snapshot.state.dictionaries.some((dictionary) => dictionary?.id !== CUSTOM_DICTIONARY_ID && dictionary?.title === CUSTOM_DICTIONARY_TITLE)) { throw new Error(`a dictionary named ${CUSTOM_DICTIONARY_TITLE} is already installed`); } if (semanticRevision === snapshot.document.semanticRevision && await customPackageSatisfies(snapshot.state, semanticRevision, parsed.entries.length)) { const saved = await commitCustomSourceOnly(snapshot, source, semanticRevision); return { ...saved, errors: parsed.errors, rebuilt: false, removed: false }; } const archive = buildCustomDictionaryZip(parsed.entries, semanticRevision); let committed = null; try { const report = await runImportTransaction( new Response(archive), "the custom dictionary archive", lowRam, async (generationRoot, importedReport) => { committed = await commitCustomGeneration( snapshot, source, semanticRevision, generationRoot, importedReport, ); }, ); return { ...committed, errors: parsed.errors, rebuilt: true, removed: false, report, }; } catch (error) { if (error instanceof CustomCommitRejectedError) { return { ...error.reply, errors: parsed.errors }; } throw error; } } let preparedBackup = null; const backupUrls = new Set(); const dictionaryDownloads = new Map(); let dictionaryDownloadCounter = 0; async function readBackupStorage(raw = false) { const reply = await ask(raw ? "hd_backup_base_read" : "hd_backup_read"); if (!reply.ok) throw new Error(reply.error || "Could not read the complete Hachidori state."); return reply; } function backupFileBlob(path, size) { const FS = engine.FS; const input = FS.open(path, "r"); const parts = []; try { let offset = 0; while (offset < size) { // Transfer-sized chunks, not a limit on files or archive size. const bytes = new Uint8Array(Math.min(64 * 1024, size - offset)); const read = FS.read(input, bytes, 0, bytes.length); if (read !== bytes.length) throw new Error(`Could not read the complete dictionary file: ${path}`); parts.push(new Blob([bytes])); offset += read; } return new Blob(parts); } finally { FS.close(input); } } function collectBackupFiles(root, prefix, assertPath, output) { const FS = engine.FS; if (!isDirectory(FS.lstat(root))) throw new Error(`Dictionary generation is not a directory: ${root}`); for (const name of FS.readdir(root).filter(name => name !== "." && name !== "..").sort()) { const path = `${prefix}/${name}`; assertPath(path); const absolute = `${root}/${name}`; const stat = FS.lstat(absolute); if (isDirectory(stat)) collectBackupFiles(absolute, path, assertPath, output); else if ((stat.mode & 0o170000) === 0o100000) output.push({ path, data: backupFileBlob(absolute, stat.size) }); else throw new Error(`Dictionary generation contains a non-file entry: ${path}`); } } async function discardPreparedBackup() { const previous = preparedBackup; preparedBackup = null; if (previous) await discardGenerations(previous.roots); if (previous?.retained === true) await cleanupCommittedDictionaries(); } async function discardGenerations(roots) { for (const root of roots) { if (!isGenerationRoot(root)) throw new Error("Refusing to discard a path outside the dictionary generation namespace."); } let changed = false; for (const root of roots) { if (exists(root)) { removeTree(root); changed = true; } } if (changed) await persistFilesystem(); } async function stageBackupFiles(prepared, roots) { const archived = prepared.snapshot.state.dictionaries; const dictionaries = archived.map(dictionary => { const root = createGenerationRoot(); roots.push(root); const next = { ...dictionary, path: `${root}/${dictionary.title}` }; assertBackupDictionaryPath(next, "The backup contains an invalid dictionary title."); return next; }); for (const file of prepared.files) { const [, ordinal, ...relative] = file.path.split("/"); const dictionary = dictionaries[Number(ordinal)]; if (!dictionary) throw new Error(`The backup file has no dictionary: ${file.path}`); const path = `${dictionary.path}/${relative.join("/")}`; engine.FS.mkdirTree(path.slice(0, path.lastIndexOf("/"))); await streamResponseToFile(engine.FS, new Response(file.data), path); } for (const dictionary of dictionaries) await validateBackupDictionaryFiles(dictionary); await persistFilesystem(); return dictionaries; } function assertBackupDictionaryPath(dictionary, message = "The backup contains an invalid dictionary path.") { if (dictionaryRoot(dictionary) === null) { throw new Error(message); } } function assertBackupDictionaryPaths(dictionaries) { for (const dictionary of dictionaries) assertBackupDictionaryPath(dictionary); } async function validateBackupDictionaryFiles(dictionary) { const generated = await packageFromIndex(dictionary.path); const keys = ["title", "revision", "termCount", "frequencyCount", "pitchCount", "kanjiCount", "mediaCount"]; if (keys.some(key => generated[key] !== dictionary[key]) || !hasDictionaryMarker(dictionary.path)) { throw new Error(`The backup dictionary metadata does not match its files: ${dictionary.title}`); } const required = ["hash.table", "bloom.filter", "blobs.bin"]; if (dictionary.mediaCount > 0) required.push("media.idx", "media.bin"); for (const name of required) { if (!exists(`${dictionary.path}/${name}`)) throw new Error(`The backup is missing ${dictionary.title}/${name}`); } const recommended = recommendedDictionarySource(dictionary.sourceId); if (recommended) assertRecommendedDictionary(recommended, generated); if (dictionary.id === CUSTOM_DICTIONARY_ID && !customDictionaryMetadataMatches(generated, dictionary.revision, dictionary.termCount)) { throw new Error("The backup custom dictionary files do not satisfy the managed package invariants."); } } async function commitBackupSnapshot(current, snapshot, lookupStatsRows) { try { return await ask("hd_backup_cas", { base: current, snapshot, lookupStatsRows }); } catch (commitError) { let readback; try { readback = (await readBackupStorage(true)).snapshot; } catch (readError) { throw new UnknownDictionaryStateCommitError(commitError, readError, "backup restore"); } if (sameJsonValue(readback, snapshot)) return { ok: true, snapshot }; if (!sameJsonValue(readback, current)) { throw new UnknownDictionaryStateCommitError(commitError, new Error("readback did not match the exact complete restore transaction"), "backup restore"); } return { ok: false, error: describe(commitError) }; } } async function restoreBackup(message) { requireEngine(); if (!preparedBackup || preparedBackup.token !== message.token) { throw new Error("This prepared backup is no longer available. Choose the file again."); } const prepared = preparedBackup; preparedBackup = null; try { const { restoredBackupSnapshot } = await import("./backup-state.js"); const current = (await readBackupStorage(true)).snapshot; if (!sameJsonValue(current, prepared.current)) { throw new Error("Hachidori changed since this backup was prepared. Prepare it again before restoring."); } const loadedCount = loadDictionaries(prepared.dictionaries); const snapshot = restoredBackupSnapshot(current, prepared.snapshot, prepared.dictionaries); const reply = await commitBackupSnapshot(current, snapshot, prepared.lookupStatsRows); if (!reply.ok) throw new Error(reply.error || "Could not commit the backup restore."); publishLoadedDictionaries(loadedCount); reloadError = null; await cleanupCommittedDictionaries(); let warning = reply.warning ?? null; try { const cleanup = await ask("hd_lookup_stats_cleanup"); if (!cleanup.ok) throw new Error(cleanup.error); } catch (error) { warning = [warning, `Restored successfully; old lookup statistics could not be cleaned up: ${describe(error)}`].filter(Boolean).join("; "); } return { restored: true, dictionaryCount, warning }; } catch (error) { if (error instanceof UnknownDictionaryStateCommitError) { reloadError = error; throw error; } // Read authoritative state; never write an old snapshot over concurrent edits. await rollbackImportedGenerations(prepared.roots, error); throw error; } } function removalTarget(dictionaries, id, title) { const target = dictionaries.find((dictionary) => id === null ? text(dictionary?.title) === title : dictionary?.id === id); if (target === undefined) return null; if (text(target.title) !== title) { throw new Error("the remove request dictionary ID and title do not match"); } if (target.id === CUSTOM_DICTIONARY_ID) { throw new Error("the managed custom dictionary can only be removed by saving an empty source"); } return target; } const HANDLERS = { async hd_backup_export() { await ensureLoaded(); const [{ createBackupArchive, assertBackupPath }, { assertBackupSnapshot }] = await Promise.all([ import("./backup-archive.js"), import("./backup-state.js"), ]); const { snapshot, lookupStatsRows } = await readBackupStorage(); await assertBackupSnapshot(snapshot); const files = []; for (const [index, dictionary] of snapshot.state.dictionaries.entries()) { if (dictionaryRoot(dictionary) === null) throw new Error("Cannot back up an invalid dictionary path."); collectBackupFiles(dictionary.path, `dictionaries/${index}`, assertBackupPath, files); } const archive = await createBackupArchive(snapshot, files, lookupStatsRows); const blobUrl = URL.createObjectURL(archive); backupUrls.add(blobUrl); return { blobUrl, size: archive.size }; }, hd_backup_release(message) { if (backupUrls.delete(message.blobUrl)) URL.revokeObjectURL(message.blobUrl); return {}; }, // The relay's dictionary download: one dictionary as the backup archive the // host's own restore accepts, built while this turn holds the queue so no // cleanup can remove the generation under it, then served by offset. async hd_api_dictionary_open(message) { await ensureLoaded(); const [{ createBackupArchive, assertBackupPath }, { assertBackupSnapshot }, { emptyCustomDictionaryDocument }] = await Promise.all([ import("./backup-archive.js"), import("./backup-state.js"), import("./custom-dictionary.js"), ]); const { snapshot } = await readBackupStorage(); const dictionary = snapshot.state.dictionaries.find(entry => entry?.id === message.id); if (!dictionary) throw new Error("unknown dictionary"); if (dictionaryRoot(dictionary) === null) throw new Error("Cannot export an invalid dictionary path."); const single = { ...snapshot, state: { ...snapshot.state, dictionaries: [dictionary], groups: globalThis.HDDictionaryGroups.normaliseDictionaryGroups(snapshot.state.groups, [dictionary]) }, document: dictionary.id === CUSTOM_DICTIONARY_ID ? snapshot.document : emptyCustomDictionaryDocument(), }; await assertBackupSnapshot(single); const files = []; collectBackupFiles(dictionary.path, "dictionaries/0", assertBackupPath, files); const archive = await createBackupArchive(single, files, []); const token = `dl-${++dictionaryDownloadCounter}`; dictionaryDownloads.set(token, archive); return { token, size: archive.size }; }, async hd_api_dictionary_read(message) { const archive = dictionaryDownloads.get(message.token); if (!archive) throw new Error("unknown download token"); const offset = Number(message.offset), length = Number(message.length); if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0) { throw new Error("a dictionary read needs a non-negative offset and length"); } const end = Math.min(archive.size, offset + length); const bytes = new Uint8Array(await archive.slice(offset, end).arrayBuffer()); return { data: encodeBase64(bytes), eof: end >= archive.size }; }, hd_api_dictionary_close(message) { dictionaryDownloads.delete(message.token); return {}; }, async hd_backup_cancel(message) { if (preparedBackup?.token === message.token) await discardPreparedBackup(); return {}; }, async hd_backup_prepare(message) { requireEngine(); if (typeof message.token !== "string" || message.token === "") { throw new Error("Backup preparation requires its Settings cancellation token."); } await discardPreparedBackup(); const current = (await readBackupStorage(true)).snapshot; const [{ openBackupArchive }, { assertBackupSnapshot }] = await Promise.all([ import("./backup-archive.js"), import("./backup-state.js"), ]); if (typeof message.blobUrl !== "string" || !message.blobUrl.startsWith("blob:")) { throw new Error("Choose a local Hachidori backup archive."); } const response = await fetch(message.blobUrl); if (!response.ok) throw new Error("Could not read the selected backup."); const prepared = await openBackupArchive(await response.blob()); await assertBackupSnapshot(prepared.snapshot); const roots = []; try { const dictionaries = await stageBackupFiles(prepared, roots); loadDictionaries(dictionaries); let warning = null; try { await restoreCommittedDictionaries(null, { publish: false }); if (loadFailures.length > 0) { warning = "Some current dictionaries cannot be loaded. This validated backup can replace them."; } } catch (error) { reloadError = asError(error); warning = "The current dictionaries cannot be loaded. This validated backup can replace them."; } const token = message.token; preparedBackup = { token, current, roots, dictionaries, snapshot: prepared.snapshot, lookupStatsRows: prepared.lookupStatsRows }; return { token, warning, createdAt: prepared.createdAt, dictionaries: dictionaries.map(({ title, enabled }) => ({ title, enabled })), customEntryCount: parseCustomDictionary(prepared.snapshot.document.text).entries.length }; } catch (error) { try { await restoreCommittedDictionaries(null, { publish: false }); } catch (restoreError) { reloadError = asError(restoreError); } // Prepare never publishes these paths, even when the old state is broken. await discardGenerations(roots); throw error; } }, async hd_backup_auto_prepare(message) { requireEngine(); if (typeof message.token !== "string" || message.token === "") { throw new Error("Automatic backup preparation requires its Settings cancellation token."); } if (typeof message.id !== "string" || message.id === "") { throw new Error("Choose an automatic backup to restore."); } await discardPreparedBackup(); const current = (await readBackupStorage(true)).snapshot; const [{ backup: prepared }, { assertBackupSnapshot }] = await Promise.all([ ask("hd_backup_auto_get", { id: message.id }), import("./backup-state.js"), ]); if (!prepared) throw new Error("This automatic backup is corrupt or no longer retained."); await assertBackupSnapshot(prepared.snapshot); const dictionaries = prepared.snapshot.state.dictionaries; assertBackupDictionaryPaths(dictionaries); try { for (const dictionary of dictionaries) await validateBackupDictionaryFiles(dictionary); loadDictionaries(dictionaries); let warning = null; try { await restoreCommittedDictionaries(null, { publish: false }); if (loadFailures.length > 0) { warning = "Some current dictionaries cannot be loaded. This validated backup can replace them."; } } catch (error) { reloadError = asError(error); warning = "The current dictionaries cannot be loaded. This validated backup can replace them."; } const token = message.token; preparedBackup = { token, current, retained: true, roots: [], dictionaries, snapshot: prepared.snapshot, lookupStatsRows: prepared.lookupStatsRows, }; return { token, warning, createdAt: prepared.createdAt, dictionaries: dictionaries.map(({ title, enabled }) => ({ title, enabled })), customEntryCount: parseCustomDictionary(prepared.snapshot.document.text).entries.length, }; } catch (error) { try { await restoreCommittedDictionaries(null, { publish: false }); } catch (restoreError) { reloadError = asError(restoreError); } throw error; } }, async hd_backup_auto_cleanup() { requireEngine(); await cleanupCommittedDictionaries(); return {}; }, hd_backup_restore: restoreBackup, async hd_frequencies(message) { await ensureLoaded(); if (!Array.isArray(message.termReadingList) || message.termReadingList.length > 4096) { throw new Error('Invalid frequency term/reading list'); } const pairs = message.termReadingList.map(pair => { if (!pair || typeof pair.term !== 'string' || (pair.reading !== null && typeof pair.reading !== 'string')) { throw new Error('Invalid frequency term/reading pair'); } return { term: boundedText(pair.term, 'frequency term', MAX_LOOKUP_TEXT_BYTES), reading: pair.reading === null ? null : boundedText(pair.reading, 'frequency reading', MAX_LOOKUP_TEXT_BYTES) }; }); const json = engine.ccall('hdw_frequencies', 'string', ['string'], [JSON.stringify(pairs)]); throwIfEngineFailed('hdw_frequencies'); return { frequencies: parseJson(json, 'hdw_frequencies') }; }, async hd_lookup(message) { await ensureLoaded(); const json = engine.ccall( "hdw_lookup", "string", ["string", "number", "number", "string"], lookupArguments(message), ); throwIfEngineFailed("hdw_lookup"); return termLookupReply(json, "hdw_lookup"); }, async hd_lookup_dictionary(message) { await ensureLoaded(); const args = lookupArguments(message); const title = text(message.dictionary); const entry = (await readStoredDictionaries()).find((candidate) => candidate?.enabled !== false && kindsForPackage(candidate).includes("term") && candidate?.title === title); if (!entry) { return { results: [], dictionaryCount }; } const json = engine.ccall( "hdw_lookup_dictionary", "string", ["string", "string", "number", "number", "string"], [args[0], text(entry.path), ...args.slice(1)], ); throwIfEngineFailed("hdw_lookup_dictionary"); return termLookupReply(json, "hdw_lookup_dictionary"); }, async hd_kanji(message) { await ensureLoaded(); const character = boundedText(message.character, "kanji text", MAX_LOOKUP_TEXT_BYTES); if (character === "") { return { kanji: null }; } const json = engine.ccall("hdw_kanji", "string", ["string"], [character]); throwIfEngineFailed("hdw_kanji"); const kanji = parseJson(json, "hdw_kanji"); if (typeof kanji?.character !== "string" || !Array.isArray(kanji.entries)) { throw new TypeError("hdw_kanji returned a malformed lookup response"); } return { kanji: kanji.character === "" ? null : kanji, nativeJsonLength: json.length }; }, async hd_styles() { await ensureLoaded(); const json = engine.ccall("hdw_styles", "string", [], []); throwIfEngineFailed("hdw_styles"); const styles = parseJson(json, "hdw_styles"); return { styles: Array.isArray(styles) ? styles : [] }; }, async hd_media(message) { await ensureLoaded(); if (!Number.isSafeInteger(message.generation) || message.generation !== generation) { throw new Error("media generation no longer matches the loaded dictionaries"); } const dictionary = boundedText(message.dictionary, "media dictionary", MAX_MEDIA_DICTIONARY_BYTES); const path = boundedText(message.path, "media path", MAX_MEDIA_PATH_BYTES); if (dictionary === "" || path === "") { return { dataUrl: null }; } let length = 0; for (const candidate of mediaPathCandidates(path)) { length = engine.ccall("hdw_media", "number", ["string", "string"], [dictionary, candidate]); throwIfEngineFailed("hdw_media"); if (length > 0) break; } if (length <= 0) { return { dataUrl: null }; } // "pointer", not "number": the glue only masks the raw i32 back to unsigned // for the former, and with ALLOW_MEMORY_GROWTH up to 4GB the buffer can sit // above 0x80000000, where a signed read silently slices the wrong bytes. const pointer = engine.ccall("hdw_media_data", "pointer", [], []); if (pointer === 0) { return { dataUrl: null }; } // Read HEAPU8 through the module: memory growth swaps the view out. const bytes = engine.HEAPU8.subarray(pointer, pointer + length); return { dataUrl: `data:${mediaType(path)};base64,${encodeBase64(bytes)}` }; }, async hd_custom_save(message) { requireEngine(); if (!Number.isInteger(message?.baseDocumentRevision) || message.baseDocumentRevision < 0) { throw new Error("the custom dictionary save carried no valid document revision"); } if (typeof message?.text !== "string") { throw new TypeError("the custom dictionary save carried no source text"); } const snapshot = await readCustomStorage(); if (message.baseDocumentRevision !== snapshot.document.revision) { return customStaleReply(snapshot); } return saveCustomDictionary(snapshot, message.text); }, async hd_custom_append(message) { requireEngine(); // This read deliberately happens inside the engine mutation queue. A Note // submitted behind a Settings save must append to that newly committed // source rather than the document that existed when the click was sent. const snapshot = await readCustomStorage(); const source = appendCustomDictionaryEntry(snapshot.document.text, message?.entry); return saveCustomDictionary(snapshot, source); }, async hd_import(message) { requireEngine(); if (stagingImports > 0) { throw new Error("another dictionary import is already in progress"); } stagingImports += 1; const requestId = message.requestId ?? null; try { const stagedRequest = await prepareImportRequest(message); const response = await fetchImportArchive(stagedRequest); const onDownload = reportProgress === null ? null : (event) => { try { Promise.resolve(reportProgress({ requestId, ...event })).catch((error) => { console.warn(`hoshidicts: could not report import progress: ${describe(error)}`); }); } catch (error) { console.warn(`hoshidicts: could not report import progress: ${describe(error)}`); } }; const staged = await stageImportArchive(response, onDownload); if (staged.byteLength === 0) { throw new Error(`${stagedRequest.fileName} is empty`); } const stagedResources = await stageImportResources(stagedRequest); return await serialise(async () => { requireEngine(); const request = await prepareImportRequest(message); if (!samePreparedImport(stagedRequest, request)) { throw new Error("the dictionary import request changed while its archive was downloading"); } const { expectedRevision, fileName, importDecision, importLowRam, managedSource, recommendedSource, } = request; // The native importer has no progress callback. Awaiting this transition // lets the offscreen bridge reject new reads before hdw_reset unloads // the committed dictionaries. await reportProgress?.({ requestId, phase: "installing", receivedBytes: staged.byteLength, totalBytes: staged.byteLength, }); const report = await runImportTransaction( staged.bytes, fileName, importLowRam, (generationRoot, importedReport) => commitImportedGeneration( generationRoot, importedReport, recommendedSource, managedSource, expectedRevision, importDecision, ), staged.byteLength, stagedResources, ); if (!report.success) { return { ok: false, error: report.error || `${fileName} could not be imported`, report }; } return { report }; }); } finally { stagingImports -= 1; } }, async hd_apply_state(message) { requireEngine(); if (!Number.isInteger(message?.baseRevision) || message.baseRevision < 0) { throw new Error("the dictionary state change carried no valid base revision"); } if (!Array.isArray(message?.dictionaries)) { throw new TypeError("the dictionary state change carried no dictionary list"); } let restorationAttempted = false; try { const loadedCount = loadDictionaries(message.dictionaries, { committed: await readStoredDictionaries(), }); const reply = await commitDictionaryState(message.baseRevision, message.dictionaries); if (reply.ok === true) { publishLoadedDictionaries(loadedCount); reloadError = null; return { state: reply.state }; } if (reply.conflict !== true || reply.state === null) { throw new Error(reply.error || "the dictionary state could not be saved"); } restorationAttempted = true; await restoreCommittedDictionaries(reply.state); return { ok: false, conflict: true, error: reply.error || "the dictionary state changed while it was being written", state: reply.state, }; } catch (error) { if (restorationAttempted) { reloadError = asError(error); throw error; } try { const { state } = await readDictionaryStorage(); await restoreCommittedDictionaries(state); } catch (restoreError) { reloadError = asError(restoreError); } throw error; } }, async hd_reload() { requireEngine(); await reloadFromStorage(); return { dictionaryCount }; }, async hd_remove(message) { requireEngine(); const title = text(message.title); const id = optionalText(message.id); const legacyRemovalRoot = title === ".hdw-remove" && hasDictionaryMarker(REMOVAL_ROOT); if (!usableDictionaryTitle(title) && !legacyRemovalRoot) { throw new Error("the remove request carried an unusable dictionary title"); } const snapshot = await readDictionaryStorage(); if (snapshot.state === null) { throw new Error("the dictionary state is unavailable"); } await recoverPendingRemovals(snapshot); const target = removalTarget(snapshot.state.dictionaries, id, title); if (target === null) { // Nothing to do, and reloading for nothing would invalidate the renderer's // media cache. return {}; } const remaining = snapshot.state.dictionaries.filter( (dictionary) => dictionary?.id !== target.id, ); let loadedCount; let reply; try { loadedCount = loadDictionaries(remaining, { committed: remaining }); reply = await commitDictionaryState(snapshot.state.revision, remaining); } catch (error) { if (error instanceof UnknownDictionaryStateCommitError) { // The manifest may already exclude the package. Keep its files until an // authoritative read can decide whether they are still committed. reloadError = error; throw error; } try { await restoreCommittedDictionaries(snapshot.state); } catch (restoreError) { reloadError = asError(restoreError); throw new Error(`${describe(error)}; removal rollback failed: ${describe(restoreError)}`); } throw error; } if (reply.ok === true) { publishLoadedDictionaries(loadedCount); reloadError = null; await cleanupCommittedDictionaries(); return {}; } const authoritative = reply.conflict === true && reply.state !== null ? reply.state : snapshot.state; try { await restoreCommittedDictionaries(authoritative); } catch (restoreError) { reloadError = asError(restoreError); throw new Error( `${reply.error || "the dictionary removal could not be saved"}; ` + `removal rollback failed: ${describe(restoreError)}`, ); } return { ok: false, conflict: reply.conflict === true, error: reply.error || "the dictionary removal could not be saved", state: reply.state, }; }, hd_status() { // Nothing else repairs a failed reload on its own. ensureLoaded() retries on // the next lookup, which keeps hovering alive, but a reader who only opens // the settings page would sit in front of "Engine error, 0 dictionaries // loaded" indefinitely -- the engine is fine and the files are on disk, and // the only way out was to go and hover a word. So a status poll drives the // recovery: this reply describes the state as it is now, and the next poll // sees the repair. A timer would do the same, but would also keep waking the // service worker forever in the case where the retry cannot succeed. if (bootError === null && reloadError !== null) { retryReload(); } // A reload failure is reported here too, or an engine that is ready with // nothing loaded is indistinguishable from an empty profile: the settings // page would say "0 dictionaries loaded" next to the rows it just imported. const failure = bootError ?? reloadError; return { ok: failure === null, error: failure === null ? null : describe(failure), ready, loading: busy > 0 || stagingImports > 0, dictionaryCount, failedDictionaries: loadFailures, generation, storageBackend, threaded: !lowRam, }; }, }; function failurePayload(type) { switch (type) { case "hd_lookup": case "hd_lookup_dictionary": return { results: [], dictionaryCount: 0 }; case "hd_kanji": return { kanji: null }; case "hd_styles": return { styles: [] }; case "hd_media": return { dataUrl: null }; case "hd_import": return { report: emptyReport("") }; case "hd_reload": return { dictionaryCount: 0 }; case "hd_status": return { ready: false, loading: false, dictionaryCount: 0, generation }; default: return {}; } } function engineFailureReply(type, requestId, error) { const description = describe(error); let errorCode = error === bootError ? "engine-start-failed" : null; if (errorCode === null && description === "the dictionary engine is still starting") { errorCode = "engine-starting"; } return boundResponseFailure({ type: `${type}_result`, requestId, ok: false, error: description, ...(errorCode === null ? {} : { errorCode }), generation, ...failurePayload(type), }); } export async function handleEngineMessage(message) { const type = text(message.type); let requestId = message.requestId ?? null; if (!Object.prototype.hasOwnProperty.call(HANDLERS, type)) { return { type: `${type || "hd_unknown"}_result`, requestId, ok: false, error: `unknown request type ${JSON.stringify(type)}`, generation, }; } const bounded = isBoundedRequest(type); try { if (bounded) { if (!validResponseRequestId(requestId)) { requestId = null; throw new Error("request ID must be a string, finite number, or null"); } if (!responseFits({ type: `${type}_result`, requestId, ok: false, error: responseLimitError(type), generation, ...failurePayload(type) })) { requestId = null; throw new Error(responseLimitError(type)); } } const handler = HANDLERS[type]; const run = UNQUEUED.has(type) ? Promise.resolve().then(() => handler(message)) : serialise(() => handler(message)); const result = await run; const { ok = true, error = null, nativeJsonLength = 0, ...payload } = result ?? {}; const reply = { type: `${type}_result`, requestId, ok, error, generation, ...payload }; if (bounded && !responseFits(reply, nativeJsonLength)) throw new Error(responseLimitError(type)); return reply; } catch (error) { return engineFailureReply(type, requestId, error); } } export function startEngine() { if (started) { throw new Error("the engine service is already started"); } started = true; serialise(boot); }