Files
SubMiner/vendor/hachidori/extension/offscreen.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

403 lines
15 KiB
JavaScript

/*
* Bridges extension runtime messages to the Hoshidicts engine.
*
* Browsers with pthread and OPFS support use the dedicated worker on direct
* OPFS. Hosts with pthread support but no OPFS access handles (Electron) use
* the dedicated worker on IDBFS. Other browsers use the single-thread IDBFS
* compatibility runtime on this document.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { extensionApi as chrome, expectedBackgroundUrl } from "./browser-api.js";
import { announceFirefoxOffscreen } from "./firefox-host.js";
import { boundResponseFailure } from "./response-limits.js";
const TARGET = "hoshidicts-offscreen";
const AUDIO_TARGET = "hachidori-audio";
const ANKI_TARGET = "hachidori-anki-render";
const SETUP_TARGET = "hachidori-setup";
let audioService, ankiService, audioRepository, setupInstaller;
let captureService;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.target !== "hachidori-capture-page" || message.relayed !== true
|| sender.id !== chrome.runtime.id || sender.url !== expectedBackgroundUrl(chrome)
|| sender.tab !== undefined) return false;
captureService ??= import("./capture-host.js");
captureService.then(module => module.handleCaptureMessage(message)).then(
result => sendResponse({ type: `${message.type}_result`, requestId: message.requestId, ok: true, ...result }),
error => sendResponse(failedResponse(message, describe(error))),
);
return true;
});
function getAudioRepository() {
audioRepository ??= import("./audio-repository.js").then(module => module.createAudioRepository({
window: globalThis, fetch: globalThis.fetch.bind(globalThis), now: () => performance.now(),
}));
return audioRepository;
}
const MAX_PENDING_REQUESTS = 128;
const PROBE_TIMEOUT_MS = 10_000;
const MUTATION_TYPES = new Set([
"hd_apply_state",
"hd_reload",
"hd_remove",
"hd_custom_save",
"hd_backup_export",
"hd_backup_prepare",
"hd_backup_auto_prepare",
"hd_backup_auto_cleanup",
"hd_backup_restore",
"hd_backup_cancel",
]);
const STAGED_MUTATION_TYPES = new Set(["hd_custom_append"]);
const IMPORT_READ_TYPES = new Set([
"hd_frequencies",
"hd_lookup",
"hd_lookup_dictionary",
"hd_kanji",
"hd_styles",
"hd_media",
"hd_backup_release",
"hd_api_dictionary_read",
"hd_api_dictionary_close",
]);
const STAGED_MUTATION_READ_TYPES = new Set([
"hd_frequencies",
"hd_lookup",
"hd_lookup_dictionary",
"hd_kanji",
"hd_styles",
"hd_media",
"hd_api_dictionary_read",
"hd_api_dictionary_close",
]);
function supportsSharedWasmMemory() {
if (globalThis.crossOriginIsolated !== true
|| typeof globalThis.SharedArrayBuffer !== "function"
|| typeof globalThis.WebAssembly?.Memory !== "function") {
return false;
}
try {
const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true });
return memory.buffer instanceof SharedArrayBuffer;
} catch {
return false;
}
}
const CAN_THREAD = supportsSharedWasmMemory();
let worker = null;
let localEngine = null;
let nextRequestId = 0;
let engineError = null;
let activeMutationRequestId = null;
let activeStagedMutationRequestId = null;
let activeImportRequestId = null;
let lastEngineStatus = {
ok: true,
error: null,
ready: false,
loading: true,
dictionaryCount: 0,
failedDictionaries: [],
generation: 0,
};
const pending = new Map();
function describe(error) {
return error instanceof Error ? error.message || String(error) : String(error);
}
function failedResponse(message, error, errorCode = null) {
return boundResponseFailure({
type: `${message?.type || "hd_unknown"}_result`,
requestId: message?.requestId ?? null,
ok: false,
error,
...(errorCode === null ? {} : { errorCode }),
});
}
function finishRequest(id, response) {
const request = pending.get(id);
if (request === undefined) return;
pending.delete(id);
if (id === activeMutationRequestId) activeMutationRequestId = null;
if (id === activeStagedMutationRequestId) activeStagedMutationRequestId = null;
if (id === activeImportRequestId) activeImportRequestId = null;
if (response?.type === "hd_status_result") {
lastEngineStatus = {
...lastEngineStatus,
ok: response.ok === true,
error: response.error ?? null,
ready: response.ready === true,
loading: response.loading === true,
dictionaryCount: Number(response.dictionaryCount) || 0,
failedDictionaries: Array.isArray(response.failedDictionaries) ? response.failedDictionaries : [],
generation: Number(response.generation) || 0,
};
}
request.sendResponse(response);
}
function adoptEngineProgress(progress) {
const id = activeImportRequestId;
const request = pending.get(id);
if (request === undefined || request.message.requestId !== progress?.requestId) {
return false;
}
if (progress.phase === "installing") {
activeImportRequestId = null;
activeMutationRequestId = id;
}
setupInstaller?.then((installer) => installer.progress(progress));
return true;
}
function failEngine(error) {
if (engineError !== null) return;
engineError = describe(error) || "the Hoshidicts engine stopped";
console.error(`hoshidicts: engine failed: ${engineError}`);
for (const [id, request] of pending) {
finishRequest(id, failedResponse(request.message, engineError, "engine-start-failed"));
}
}
function probeDirectOpfs() {
return new Promise((resolve) => {
let probe = null;
let timer = null;
let settled = false;
const finish = (ok, error = "") => {
if (settled) return;
settled = true;
if (timer !== null) clearTimeout(timer);
probe?.terminate();
resolve({ ok, error });
};
try {
probe = new Worker(new URL("./opfs-capability-worker.js", import.meta.url), {
type: "module",
name: "hoshidicts-opfs-capability",
});
probe.addEventListener("error", (event) => finish(false, describe(event.error || event.message)));
probe.addEventListener("messageerror", () => finish(false, "the OPFS capability worker sent an unreadable message"));
probe.addEventListener("message", (event) => {
if (event.data?.channel !== "opfs-capability-result") return;
finish(event.data.ok === true, describe(event.data.error || ""));
});
timer = setTimeout(() => finish(false, "the OPFS capability probe timed out"), PROBE_TIMEOUT_MS);
probe.postMessage({ channel: "opfs-capability-probe" });
} catch (error) {
finish(false, describe(error));
}
});
}
// "opfs": pthread worker on direct OPFS; "threaded-idbfs": pthread worker on
// IDBFS; "local": single-thread IDBFS runtime on this document.
async function selectEngine() {
if (!CAN_THREAD || typeof globalThis.Worker !== "function") {
return "local";
}
if (typeof navigator.storage?.getDirectory !== "function") {
console.warn("hoshidicts: the origin private file system is unavailable, using threaded IDBFS");
return "threaded-idbfs";
}
const result = await probeDirectOpfs();
if (!result.ok) {
console.warn(`hoshidicts: direct OPFS is unavailable, using threaded IDBFS: ${result.error}`);
return "threaded-idbfs";
}
return "opfs";
}
function startWorkerEngine(script) {
worker = new Worker(new URL(script, import.meta.url), {
type: "module",
name: "hoshidicts-engine",
});
worker.addEventListener("error", (event) => failEngine(event.error || event.message));
worker.addEventListener("messageerror", () => failEngine("the engine worker sent an unreadable message"));
worker.onmessage = (event) => {
const data = event.data;
if (data?.channel === "host-request") {
Promise.resolve(chrome.runtime.sendMessage(data.message)).then(
(response) => worker.postMessage({ channel: "host-response", id: data.id, ok: true, response }),
(error) => worker.postMessage({ channel: "host-response", id: data.id, ok: false, error: describe(error) }),
);
return;
}
if (data?.channel === "engine-progress") {
const adopted = adoptEngineProgress(data.progress);
if (data.id !== undefined) {
worker.postMessage({
channel: "engine-progress-ack",
id: data.id,
ok: adopted,
error: adopted ? null : "the import request is no longer active",
});
}
return;
}
if (data?.channel !== "engine-response") return;
finishRequest(data.id, data.response);
};
}
function reportEngineProgress(progress) {
const adopted = adoptEngineProgress(progress);
if (!adopted && progress?.phase === "installing") {
throw new Error("the import request is no longer active");
}
}
function startLocalEngine() {
return Promise.all([
import("./engine-service.js"),
import("./vendor/hoshidicts.mjs"),
]).then(([service, module]) => {
service.configureEngineService(
(message) => chrome.runtime.sendMessage(message),
{
createHoshidicts: module.default,
storageBackend: "idbfs",
lowRam: true,
reportProgress: reportEngineProgress,
},
);
service.startEngine();
localEngine = service;
});
}
const engineSelection = selectEngine().then((mode) => {
lastEngineStatus.storageBackend = mode === "opfs" ? "opfs" : "idbfs";
lastEngineStatus.threaded = mode !== "local";
if (mode === "local") return startLocalEngine();
return startWorkerEngine(mode === "opfs" ? "./engine-worker.js" : "./engine-worker-idbfs.js");
}).catch(failEngine);
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (![AUDIO_TARGET, ANKI_TARGET].includes(message?.target) || message.relayed !== true) return false;
let service;
if (message.target === AUDIO_TARGET) {
audioService ??= Promise.all([import("./audio-offscreen.js"), getAudioRepository()])
.then(([module, repository]) => module.createAudioService(globalThis, repository));
service = audioService;
} else {
ankiService ??= import("./anki-offscreen.js").then(module => module.createAnkiOffscreenService(globalThis, getAudioRepository));
service = ankiService;
}
service.then(handle => handle(message)).then(
result => sendResponse({ type: `${message.type}_result`, requestId: message.requestId, ok: true, ...result }),
error => sendResponse(failedResponse(message, describe(error))),
);
return true;
});
// Admission and the mutation lock are shared by relayed runtime requests and the
// first-run installer, so both see one engine queue.
function dispatchEngine(message, sendResponse) {
if (engineError !== null) {
sendResponse(failedResponse(message, engineError, "engine-start-failed"));
return;
}
if (message.type === "hd_status"
&& (activeMutationRequestId !== null
|| activeStagedMutationRequestId !== null
|| activeImportRequestId !== null
|| pending.size >= MAX_PENDING_REQUESTS)) {
sendResponse({
type: "hd_status_result",
requestId: message.requestId ?? null,
...lastEngineStatus,
loading: activeMutationRequestId !== null
|| activeStagedMutationRequestId !== null
|| activeImportRequestId !== null
|| lastEngineStatus.loading,
});
return;
}
const activeMutation = pending.get(activeMutationRequestId)?.message;
const cancelsBackup = message.type === "hd_backup_cancel" && typeof message.token === "string" && message.token !== "";
if (activeMutationRequestId !== null && message.type !== "hd_backup_release" && !cancelsBackup) {
sendResponse(failedResponse(message, "the dictionary engine is busy mutating", "engine-mutating"));
return;
}
if (activeStagedMutationRequestId !== null
&& !STAGED_MUTATION_READ_TYPES.has(message.type)
&& message.type !== "hd_status") {
sendResponse(failedResponse(message, "the dictionary engine is busy mutating", "engine-mutating"));
return;
}
if (activeImportRequestId !== null && !IMPORT_READ_TYPES.has(message.type)) {
sendResponse(failedResponse(message, "the dictionary engine is busy mutating", "engine-mutating"));
return;
}
// One serialized download release and one token-scoped backup cancellation
// must fit even if ordinary requests occupy all 128 slots. The cancellation
// remains queued behind the active mutation and takes over its lock.
const cleanupSlots = message.type === "hd_backup_release"
? 1 + Number(activeMutation?.type === "hd_backup_cancel") : 2 * Number(cancelsBackup);
const limit = MAX_PENDING_REQUESTS + cleanupSlots;
if (pending.size >= limit) {
sendResponse(failedResponse(message, "the dictionary engine request queue is full"));
return;
}
// Reserve before engine selection or module loading can retain the payload.
const id = ++nextRequestId;
pending.set(id, { message, sendResponse });
if (message.type === "hd_import") activeImportRequestId = id;
else if (MUTATION_TYPES.has(message.type)) activeMutationRequestId = id;
else if (STAGED_MUTATION_TYPES.has(message.type)) activeStagedMutationRequestId = id;
engineSelection.then(() => {
if (!pending.has(id)) return undefined;
if (worker === null) {
return localEngine.handleEngineMessage(message).then((response) => finishRequest(id, response));
}
worker.postMessage({ channel: "engine-request", id, message });
return undefined;
}).catch((error) => finishRequest(id, failedResponse(message, describe(error), "engine-start-failed")));
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.target !== TARGET || message.relayed !== true) {
return false;
}
dispatchEngine(message, sendResponse);
return true;
});
// Startup and Settings ask this document, not the engine, to install recommended
// dictionaries: the run must outlive the page and any service-worker restart.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.target !== SETUP_TARGET || message.relayed !== true) return false;
setupInstaller ??= import("./setup-installer.js").then((module) => module.createSetupInstaller({
dispatch: (request) => new Promise((resolve) => dispatchEngine(request, resolve)),
ask: (request) => chrome.runtime.sendMessage(request),
notify: (request) => chrome.runtime.sendMessage(request),
broadcast: (event) => Promise.resolve(chrome.runtime.sendMessage(event)).catch(() => {}),
}));
setupInstaller.then((installer) => {
if (message.type !== "hd_setup_install") throw new Error(`unknown setup request type ${JSON.stringify(message.type)}`);
return installer.attach(message.sourceIds, { recordSetup: message.recordSetup === true });
}).then(
(result) => sendResponse({ type: `${message.type}_result`, requestId: message.requestId ?? null, ok: true, error: null, ...result }),
(error) => sendResponse(failedResponse(message, describe(error))),
);
return true;
});
try {
await announceFirefoxOffscreen();
} catch (error) {
failEngine(error);
}