Files
SubMiner/vendor/hachidori/extension/engine-service.js
T
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00

3250 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 70100 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 (`<stem>.mdd`, `<stem>.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);
}