mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- 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
403 lines
15 KiB
JavaScript
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);
|
|
}
|