mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
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
This commit is contained in:
+402
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user