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

1081 lines
43 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.
/*
* Startup page: the first-run setup shown once after installation.
*
* The service worker owns the revisioned setup state; this page reads it from
* storage, renders the current stage in one card, and advances it through
* compare-and-set writes so a stale tab cannot move a newer screen backward.
* The dictionary stage attaches to the offscreen installer's run, which outlives
* this page, and mirrors its per-dictionary phases.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { extensionApi as chrome } from "./browser-api.js";
import "./reader-options.js";
import "./visual-novel.js";
import {
createDictionaryProgressList,
installEntryState,
formatSeconds,
} from "./dictionary-progress.js";
import { createRecommendedInstallClient } from "./recommended-install-client.js";
import { applyPageTheme } from "./settings-dom.js";
import { canDiscoverSharingHost } from "./sharing-protocol.js";
import { findLocalAudioSource } from "./local-audio-source.js";
import { recommendedDictionaryInstalled } from "./managed-dictionary-source.js";
import { RECOMMENDED_DICTIONARIES, describeRecommendedCatalogue } from "./recommended-dictionaries.js";
import { SETUP_STATE_KEY, SETUP_STAGES, normaliseSetupState } from "./setup-state.js";
import { createPracticeView, practiceReadiness } from "./startup-practice.js";
const WORKER_TARGET = "hoshidicts-worker";
const SETUP_TARGET = "hachidori-setup";
const ENGINE_TARGET = "hoshidicts-offscreen";
const SHARING_TARGET = "hachidori-sharing";
// How long the first render waits for the look around this computer.
const SHARED_LOOKUP_MS = 1500;
const ANKI_RESULT_DISPLAY_MS = 3000;
const COUNTDOWN_TICK_MS = 250;
const ANKI_PROGRESS_STEP_MS = 2000;
const ANKI_PROGRESS_STEPS = 3;
// A dictionary mutation refuses lookups while it holds the engine, so the
// practice probe waits for the engine to go idle and asks again rather than
// calling the sentence unanswerable. Only an idle engine that still refuses is
// counted, so a long generation cleanup cannot exhaust these attempts.
const PROBE_RETRY_MS = 400;
const PROBE_ATTEMPTS = 5;
const { normaliseOptions } = globalThis.HDReaderOptions;
let setupState = null;
let setupError = null;
let dictionaries = [];
let dictionaryRevision = -1;
let options = normaliseOptions(undefined);
let optionsRevision = -1;
let requestCounter = 0;
let saving = false;
let renderedStage;
// A Hachidori sharing itself from another browser on this computer, when one answered.
let sharedHost = null;
let countdown = null;
let countdownPaused = false;
let advanceFailed = false;
let automaticAdvance = null;
let readerLoading = null;
// What a real lookup of the practice sentence found: unknown, "ready" (the
// exact-selection shortcut answers), "passage" (another passage lookup answers),
// "missing" (nothing in the installed dictionaries) or "unavailable" (the engine
// could not answer). Probed again whenever the inventory or the options it
// depends on change, so a removed dictionary or a shortened scan cannot leave a
// stale invitation standing.
let practiceOutcome = null;
let practiceProbed = "";
let practiceLookupShown = false;
// Anki detection is asked for once per page; a failed request waits for Retry.
let ankiRequest = null;
let ankiFailed = false;
// Only a check started by this page may stage a newly configured result.
let ankiProgressRequested = false;
let ankiProgressStartedAt = null;
let ankiProgressTimer = null;
const announced = new Map();
let dictionaryProgress;
let practice;
const installation = createRecommendedInstallClient({
send: sourceIds => send("hd_setup_install", { sourceIds }, SETUP_TARGET),
onChange() { if (!saving) render(); },
onError(error) { setStatus(`Could not start dictionary installation: ${describe(error)}`, "error"); },
});
function element(id) {
return document.getElementById(id);
}
function wait(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
function describe(error) {
return typeof error?.message === "string" && error.message !== "" ? error.message : String(error);
}
async function send(type, fields, target = WORKER_TARGET) {
requestCounter += 1;
const reply = await chrome.runtime.sendMessage({
target,
type,
requestId: `${type.replace(/^hd_/u, "")}-${requestCounter}`,
...fields,
});
if (!reply) throw new Error("the extension's service worker did not reply");
return reply;
}
function adoptSetupState(value) {
let state;
try {
state = normaliseSetupState(value);
} catch (error) {
setupError = describe(error);
return true;
}
setupError = null;
if (state !== null && setupState !== null && state.revision <= setupState.revision) return false;
setupState = state;
return true;
}
function adoptDictionaryState(value) {
const revision = Number.isInteger(value?.revision) ? value.revision : 0;
if (revision <= dictionaryRevision) return false;
dictionaryRevision = revision;
dictionaries = Array.isArray(value?.dictionaries) ? value.dictionaries : [];
return true;
}
function adoptOptions(value) {
const revision = Number.isInteger(value?.revision) && value.revision >= 0 ? value.revision : 0;
if (revision <= optionsRevision) return false;
optionsRevision = revision;
options = normaliseOptions(value);
applyPageTheme(document, options);
return true;
}
function setStatus(message, tone = "") {
const status = element("setup-status");
status.textContent = message;
status.classList.toggle("is-error", tone === "error");
}
function paragraph(text, className = "hint") {
const node = document.createElement("p");
node.className = className;
node.textContent = text;
return node;
}
// Every control the card rebuilds carries a stable key so a rerender can hand
// focus back to its replacement.
function settingsNote(before, href, after = ".") {
const node = document.createElement("p");
node.className = "hint";
const link = document.createElement("a");
link.href = href;
link.dataset.focusKey = `link:${href}`;
link.textContent = "Settings";
node.append(before, link, after);
return node;
}
function button(id, text, onClick, className = "primary-button") {
const node = document.createElement("button");
node.type = "button";
node.id = id;
node.dataset.focusKey = id;
node.className = className;
node.textContent = text;
node.disabled = saving;
node.addEventListener("click", onClick);
return node;
}
function missingEntries() {
return RECOMMENDED_DICTIONARIES.filter((entry) => !recommendedDictionaryInstalled(entry, dictionaries));
}
// Sources setup has no settled outcome for: a missing one installs, an
// installed one is recorded as already installed. That covers a package whose
// commit outlived the installer that made it, and one the user installed from
// Settings after an automatic attempt failed — its stale failure is reconciled
// without importing anything. A failed source that is still missing waits for
// the explicit Retry.
function untouchedEntries() {
return RECOMMENDED_DICTIONARIES.filter((entry) => {
const outcome = setupState.dictionaries.outcomes[entry.sourceId];
if (outcome === undefined) return true;
return outcome.status === "failed" && recommendedDictionaryInstalled(entry, dictionaries);
});
}
function runActive() {
return installation.run?.finished === false;
}
// A row's text follows the live run while one is active, and otherwise the
// recorded outcome checked against the current inventory: a dictionary removed
// after setup shows as not installed rather than keeping an old time.
function rowState(entry) {
const live = runActive() ? installation.run.entries.find((candidate) => candidate.sourceId === entry.sourceId) : undefined;
if (live !== undefined) return installEntryState(live);
const outcome = setupState.dictionaries.outcomes[entry.sourceId];
if (recommendedDictionaryInstalled(entry, dictionaries)) {
return outcome?.status === "installed" && outcome.seconds !== null
? { text: `Installed in ${formatSeconds(outcome.seconds)}`, tone: "ok" }
: { text: "Already installed", tone: "ok" };
}
if (outcome?.status === "failed") return { text: `Failed: ${outcome.error}`, tone: "error" };
return { text: "Not installed" };
}
function dictionaryRows() {
if (dictionaryProgress) {
updateDictionaryRows();
return dictionaryProgress.element;
}
dictionaryProgress = createDictionaryProgressList({
document,
ariaLabel: "Default dictionaries",
idPrefix: "setup-dictionary",
});
dictionaryProgress.setEntries(RECOMMENDED_DICTIONARIES.map((entry) => ({
id: entry.sourceId,
labelId: `setup-dictionary-${entry.sourceId}`,
name: entry.name,
purpose: entry.description,
})));
updateDictionaryRows();
return dictionaryProgress.element;
}
function updateDictionaryRows() {
for (const entry of RECOMMENDED_DICTIONARIES) {
dictionaryProgress.update(entry.sourceId, rowState(entry));
}
}
// Announce outcomes, not bytes: one sentence when a dictionary settles.
function announceOutcomes() {
if (!runActive()) return;
const run = installation.run;
for (const entry of run.entries) {
if (!["installed", "already-installed", "failed"].includes(entry.phase) || announced.get(`${run.runId}:${entry.sourceId}`) === entry.phase) continue;
announced.set(`${run.runId}:${entry.sourceId}`, entry.phase);
const name = RECOMMENDED_DICTIONARIES.find((candidate) => candidate.sourceId === entry.sourceId)?.name ?? entry.sourceId;
if (entry.phase === "installed") setStatus(`${name} installed in ${formatSeconds(entry.seconds)}.`);
else if (entry.phase === "already-installed") setStatus(`${name} was already installed.`);
else setStatus(`${name} could not be installed: ${entry.error}`, "error");
}
}
function cancelCountdown() {
if (countdown === null) return;
clearTimeout(countdown.timer);
clearInterval(countdown.ticker);
countdown = null;
}
function countdownLabel() {
const remaining = Math.max(0, Math.ceil((countdown.durationMs - (Date.now() - countdown.startedAt)) / 1000));
return `Continuing to ${countdown.destination} in ${remaining} ${remaining === 1 ? "second" : "seconds"}`;
}
function updateCountdown() {
if (countdown === null) return;
const label = element("setup-countdown-label");
const track = element("setup-countdown-track");
if (!label || !track) return;
const elapsed = Math.min(1, (Date.now() - countdown.startedAt) / countdown.durationMs);
label.textContent = countdownLabel();
track.setAttribute("aria-valuenow", String(Math.floor(elapsed * 100)));
track.style.setProperty("--progress", `${elapsed * 100}%`);
}
async function advanceSettledStage(stage, nextStage) {
if (!stage || !nextStage || setupState?.stage !== stage
|| (stage === "dictionaries" && missingEntries().length > 0)) return;
// The installer may have recorded its run total between our read and this
// write; the second attempt carries the revision that reply delivered.
let advanced = await advance(nextStage);
if (!advanced && setupState?.stage === stage
&& (stage !== "dictionaries" || missingEntries().length === 0)) {
advanced = await advance(nextStage);
}
if (!advanced && setupState?.stage === stage) {
// Leave the result readable with an explicit control instead of retrying on a timer.
advanceFailed = true;
render();
}
}
async function finishCountdown(stage = countdown?.stage, nextStage = countdown?.nextStage) {
const finishing = countdown ?? { stage, nextStage };
cancelCountdown();
countdownPaused = false;
await advanceSettledStage(finishing.stage, finishing.nextStage);
}
// Dictionary success moves on without a display delay. One task owns the
// automatic write across any renders it causes, including its conflict retry.
function startImmediateAdvance(stage, nextStage) {
if (automaticAdvance?.stage === stage) return;
const task = { stage, nextStage };
automaticAdvance = task;
queueMicrotask(() => {
void advanceSettledStage(stage, nextStage).finally(() => {
if (automaticAdvance === task) automaticAdvance = null;
});
});
}
// A settled Anki result stays readable briefly, then setup moves on by itself.
// The countdown label is not a live region: ticks are not news.
function startCountdown(stage, nextStage, destination, durationMs) {
if (countdown?.stage === stage) return;
cancelCountdown();
countdown = {
stage,
nextStage,
destination,
durationMs,
startedAt: Date.now(),
ticker: setInterval(updateCountdown, COUNTDOWN_TICK_MS),
timer: setTimeout(() => { void finishCountdown(); }, durationMs),
};
}
function countdownView() {
// Keep the same animated fill through installer/storage updates. Replacing
// it would visibly restart the countdown even though its deadline is unchanged.
if (countdown.node) return countdown.node;
const wrapper = document.createElement("div");
wrapper.className = "setup-countdown";
const label = document.createElement("span");
label.id = "setup-countdown-label";
label.className = "setup-countdown-label";
label.textContent = countdownLabel();
const track = document.createElement("div");
track.id = "setup-countdown-track";
track.className = "track setup-track is-determinate";
track.setAttribute("role", "progressbar");
track.setAttribute("aria-labelledby", "setup-countdown-label");
track.setAttribute("aria-valuemin", "0");
track.setAttribute("aria-valuemax", "100");
track.setAttribute("aria-valuenow", "0");
track.style.setProperty("--progress", "0%");
track.style.setProperty("--countdown-duration", `${countdown.durationMs}ms`);
track.style.setProperty("--countdown-delay", `${countdown.startedAt - Date.now()}ms`);
track.appendChild(document.createElement("div")).className = "track-fill";
wrapper.append(label, track);
countdown.node = wrapper;
return wrapper;
}
function requestInstall(sourceIds) {
if (sourceIds.length > 0) setStatus("");
return installation.request(sourceIds);
}
// A complete inventory is announced with this setup's own install time only
// when it installed something; a profile that already carried every source
// reads as already installed and setup moves straight on.
function installedView(rows, importNote) {
const total = setupState.dictionaries.totalSeconds;
const installedHere = Object.values(setupState.dictionaries.outcomes).some((outcome) => outcome.status === "installed");
cancelCountdown();
if (!advanceFailed) startImmediateAdvance("dictionaries", "anki");
return {
heading: installedHere && total !== null ? `All dictionaries installed in ${formatSeconds(total)}` : "All dictionaries are already installed",
body: [paragraph("Your recommended dictionaries are installed. You can add your own whenever you like."), rows, importNote],
actions: advanceFailed
? [button("setup-continue", "Continue now", () => { void advanceSettledStage("dictionaries", "anki"); })]
: [],
};
}
// A failed or later removed source waits for an explicit retry of the missing
// ones, and setup can be continued without them.
function incompleteView(rows, importNote, missing) {
const failed = installation.failed || missing.some((entry) => setupState.dictionaries.outcomes[entry.sourceId]?.status === "failed");
return {
heading: failed ? "Some dictionaries could not be installed" : "Some dictionaries are not installed",
body: [paragraph("Retry the missing dictionaries, or continue and add them later in Settings."), rows, importNote],
actions: [
button("setup-retry", "Retry missing dictionaries", () => { void requestInstall(missing.map((entry) => entry.sourceId)); }),
button("setup-continue", "Continue setup", () => { void advance("anki", { continued: true }); }, "ghost"),
],
};
}
// Every source without a recorded outcome is requested on its own; the run's
// live rows and the recorded result follow.
function dictionariesView() {
const attaching = installation.pending;
const rows = dictionaryRows();
const importNote = settingsNote("Import your own dictionary ZIPs in ", "settings.html#add-dictionaries");
const installingView = () => ({
heading: "Installing default dictionaries…",
body: [paragraph(`These ${describeRecommendedCatalogue().count} dictionaries get you started. Installation continues if you close this tab; use Resume setup in Settings to return.`), rows, importNote],
actions: [],
});
if (attaching !== null && !attaching.installing && !runActive()) {
cancelCountdown();
return { heading: "Checking installed dictionaries…", body: [rows, importNote], actions: [] };
}
if (runActive() || attaching !== null) {
cancelCountdown();
return installingView();
}
const untouched = untouchedEntries();
if (untouched.length > 0 && !installation.failed) {
void requestInstall(untouched.map((entry) => entry.sourceId));
return installingView();
}
const missing = missingEntries();
if (missing.length === 0) return installedView(rows, importNote);
cancelCountdown();
return incompleteView(rows, importNote, missing);
}
function stopAnkiProgress() {
if (ankiProgressTimer !== null) clearTimeout(ankiProgressTimer);
ankiProgressTimer = null;
ankiProgressStartedAt = null;
}
function startAnkiProgress() {
stopAnkiProgress();
ankiProgressStartedAt = Date.now();
}
function ankiProgressStep() {
if (ankiProgressStartedAt === null) return 0;
return Math.min(ANKI_PROGRESS_STEPS - 1,
Math.floor((Date.now() - ankiProgressStartedAt) / ANKI_PROGRESS_STEP_MS));
}
function scheduleAnkiProgressRender() {
if (ankiProgressStartedAt === null || ankiProgressTimer !== null) return;
const elapsed = Date.now() - ankiProgressStartedAt;
const total = ANKI_PROGRESS_STEPS * ANKI_PROGRESS_STEP_MS;
if (elapsed >= total) return;
const nextBoundary = Math.min(total,
(Math.floor(elapsed / ANKI_PROGRESS_STEP_MS) + 1) * ANKI_PROGRESS_STEP_MS);
ankiProgressTimer = setTimeout(() => {
ankiProgressTimer = null;
render();
}, Math.max(0, nextBoundary - elapsed));
}
function ankiProgressView(anki = null, complete = false) {
const steps = [
"Looking for the most popular mining card",
"Looking for the most popular deck",
"Setting Hachidori to use them",
];
const selected = anki?.status === "configured";
const current = complete ? ANKI_PROGRESS_STEPS : ankiProgressStep();
const details = selected
? [`Selected ${anki.model}`, `Selected ${anki.deck}`, "Ready for future mining"]
: [];
const list = document.createElement("ol");
list.className = "setup-anki-progress";
list.setAttribute("aria-label", "Automatic Anki setup");
for (const [index, title] of steps.entries()) {
const row = document.createElement("li");
const done = index < current;
const active = !complete && index === current;
row.className = "setup-anki-progress-step";
row.classList.toggle("is-done", done);
row.classList.toggle("is-current", active);
row.dataset.step = String(index + 1);
if (active) row.setAttribute("aria-current", "step");
const marker = document.createElement("span");
marker.className = "setup-anki-progress-marker";
marker.setAttribute("aria-hidden", "true");
if (done) {
const icon = document.createElement("span");
icon.className = "hd-icon";
icon.dataset.icon = "checkmark";
icon.setAttribute("aria-hidden", "true");
marker.append(icon);
} else {
marker.textContent = String(index + 1);
}
const copy = document.createElement("span");
copy.className = "setup-anki-progress-copy";
const label = document.createElement("strong");
label.textContent = title;
const detail = document.createElement("small");
let detailText = "Waiting";
if (done || (active && selected)) detailText = details[index];
else if (active) detailText = "Checking Anki…";
detail.textContent = detailText;
copy.append(label, detail);
row.append(marker, copy);
list.appendChild(row);
}
return list;
}
function automaticAnkiView(anki = null) {
scheduleAnkiProgressRender();
return {
heading: "Finding your Anki setup…",
body: [
paragraph("Hachidori is checking the cards and decks you already use, then choosing the setup you use most."),
ankiProgressView(anki),
paragraph("You can continue while the check finishes in the background."),
],
actions: [button("setup-continue", "Continue now", () => { void advance("practice"); })],
};
}
function ankiOutcomeRecorded() {
return setupState !== null && setupState.anki !== null;
}
function requestAnkiSetup() {
if (ankiRequest !== null) return ankiRequest;
const showPending = ankiFailed;
ankiFailed = false;
ankiProgressRequested = setupState?.anki === null;
stopAnkiProgress();
const request = send("hd_setup_anki", {}).then((reply) => {
if (!reply.ok) throw new Error(reply.error || "Anki could not be checked");
adoptSetupState(reply.state);
// The reply promises a recorded outcome; without one the check is reported, not repeated.
if (setupState?.anki === null) throw new Error("no Anki outcome was recorded");
if (setupState.anki.status !== "configured") ankiProgressRequested = false;
}).catch((error) => {
// The worker may have committed the outcome before its reply was lost.
if (ankiOutcomeRecorded()) return;
ankiFailed = true;
stopAnkiProgress();
setStatus(`Could not check Anki: ${describe(error)}`, "error");
}).finally(() => {
ankiRequest = null;
render();
});
ankiRequest = request;
if (showPending) {
setStatus("Finding your Anki setup…");
render();
}
return request;
}
// The settled Anki outcome, with its Settings link, as one readable sentence.
// Names come from Anki and are rendered as text, never as markup.
function ankiOutcomeNote(anki) {
const node = document.createElement("p");
node.className = "hint setup-anki-outcome";
node.dataset.status = anki.status;
const link = document.createElement("a");
link.href = "settings.html#anki";
link.dataset.focusKey = "link:settings.html#anki";
link.textContent = "Settings";
if (anki.status === "configured") {
node.append(`Automatically set up ${anki.model} for deck ${anki.deck}. Change in `, link, ".");
} else if (anki.status === "already-configured") {
node.append(`Anki is already set up with ${anki.model} for deck ${anki.deck}. Change in `, link, ".");
} else if (anki.status === "unavailable") {
link.href = "https://apps.ankiweb.net/";
link.dataset.focusKey = "link:anki-download";
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = "Anki";
node.append("Could not find Anki. If you want to make flashcards out of words, I suggest ", link, "!");
} else {
node.append(`Anki needs attention: ${anki.detail} Set up in `, link, ".");
}
return node;
}
function localAudioOutcomeNote() {
const source = findLocalAudioSource(options.audioSources);
if (source === null) return null;
const node = paragraph(source.enabled
? "Local audio is configured."
: "Local audio is configured but disabled.");
node.classList.add("setup-local-audio-outcome");
return node;
}
function ankiOutcomeBody(anki) {
const audio = localAudioOutcomeNote();
return [ankiOutcomeNote(anki), ...(audio === null ? [] : [audio])];
}
function ankiHeading(anki) {
switch (anki.status) {
case "configured": return "Anki is set up";
case "already-configured": return "Anki is already set up";
case "unavailable": return "Could not find Anki";
default: return "Anki needs attention";
}
}
function pendingAnkiView() {
if (ankiFailed) {
return {
heading: "Anki could not be checked",
body: [settingsNote("Anki is optional. You can set it up later in ", "settings.html#anki")],
actions: [
button("setup-retry", "Retry", () => { void requestAnkiSetup(); }),
button("setup-continue", "Continue setup", () => { void advance("practice"); }, "ghost"),
],
};
}
void requestAnkiSetup();
return automaticAnkiView();
}
function stagedConfiguredAnkiView(anki) {
if (anki.status !== "configured") {
ankiProgressRequested = false;
stopAnkiProgress();
return null;
}
if (!ankiProgressRequested) return null;
if (ankiProgressStartedAt === null) startAnkiProgress();
if (Date.now() - ankiProgressStartedAt < ANKI_PROGRESS_STEPS * ANKI_PROGRESS_STEP_MS) {
return automaticAnkiView(anki);
}
ankiProgressRequested = false;
stopAnkiProgress();
return null;
}
// Detection runs once per installation; its recorded outcome stays readable
// for three seconds before setup moves on by itself.
function ankiView() {
const anki = setupState.anki;
if (anki === null) return pendingAnkiView();
// A fast local AnkiConnect response can finish before the browser paints.
// Keep each real detection step visible once before revealing a successful
// automatic choice; the worker's existing detection and saved result remain
// the source of truth.
const staged = stagedConfiguredAnkiView(anki);
if (staged !== null) return staged;
if (advanceFailed) {
cancelCountdown();
return { heading: ankiHeading(anki),
body: [...(anki.status === "configured" ? [ankiProgressView(anki, true)] : []), ...ankiOutcomeBody(anki)],
actions: [button("setup-continue", "Continue setup", () => { void advance("practice"); })] };
}
if (countdownPaused) cancelCountdown();
else startCountdown("anki", "practice", "practice", ANKI_RESULT_DISPLAY_MS);
return {
heading: ankiHeading(anki),
body: [...(anki.status === "configured" ? [ankiProgressView(anki, true)] : []), ...ankiOutcomeBody(anki),
countdownPaused ? paragraph("Automatic continuation is paused. Continue when youre ready.") : countdownView()],
actions: [
button("setup-continue", "Continue now", () => { void finishCountdown("anki", "practice"); }),
button("setup-pause", countdownPaused ? "Resume countdown" : "Pause countdown", () => {
countdownPaused = !countdownPaused;
render();
}, "ghost"),
],
};
}
// The reader itself, in the one authoritative order: the manifest's own
// content-script list, minus `reader-options.js`, which this module already
// loaded. It arrives only when the practice step does, so nothing scans the
// installation or Anki screens.
function readerScripts() {
const [injected] = chrome.runtime.getManifest().content_scripts ?? [];
return (injected?.js ?? []).filter((src) => src !== "reader-options.js");
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.dataset.setupReader = "true";
script.addEventListener("load", () => { resolve(); });
script.addEventListener("error", () => { reject(new Error(`${src} could not be loaded`)); });
document.head.appendChild(script);
});
}
// One load per page: the reader initialises itself when its last script runs.
function loadReader() {
readerLoading ??= readerScripts().reduce(
(chain, src) => chain.then(() => loadScript(src)), Promise.resolve(),
).then(() => globalThis.HDReaderReady).catch((error) => {
// The persistent practice controller also exposes its recovery link.
setStatus(`The lookup exercise could not start: ${describe(error)}`, "error");
throw error;
});
return readerLoading;
}
// The invitation is only made when this exact sentence can be answered, so the
// page asks the exact-selection shortcut first, then ordinary lookups from the
// passage offsets if needed, stopping at the first hit. A partly installed or
// unrelated library therefore cannot advertise an exercise that returns nothing.
// What the answer depends on, rather than the whole revision: the engine-visible
// library and the lookup options the probe sends. A group-only or presentation
// write leaves this unchanged, so it cannot invalidate a ready exercise.
function practiceSignature() {
const library = dictionaries.map((dictionary) => [dictionary?.id ?? "", dictionary?.title ?? "",
dictionary?.revision ?? "", dictionary?.path ?? "", dictionary?.enabled !== false,
dictionary?.termCount ?? 0].join("\u001f")).join("\u001e");
return `${library}|${options.scanLength}|${options.maxResults}|${options.frequencyDictionary}|${options.frequencyOrder}`;
}
async function probePracticeText(signature, text, exact = false) {
let reply;
try {
// Selection uses the selected word's length and ignores prefix-only hits;
// hover uses the configured scan length and needs just one term result.
reply = await send("hd_lookup", { text,
scanLength: exact ? [...text].length : options.scanLength,
maxResults: exact ? options.maxResults : 1,
options: { frequencyDictionary: options.frequencyDictionary, frequencyOrder: options.frequencyOrder, primaryReading: "" },
}, ENGINE_TARGET);
} catch {
return "refused";
}
if (practiceProbed !== signature) return "gone";
if (reply?.ok === false) return "refused";
const found = Array.isArray(reply?.results) && reply.results.some((result) => result?.term
&& (!exact || result.matched === text));
return found ? "ready" : "missing";
}
// Prove the fixed shortcut first. If it misses, one passage sweep can still
// enable ordinary hover/selection without advertising that unanswered button.
async function sweepPractice(signature) {
const word = practice.node.querySelector("#setup-practice-word").textContent;
const shortcut = await probePracticeText(signature, word, true);
if (shortcut !== "missing") return shortcut;
const characters = [...practice.node.querySelector("#setup-practice-text").textContent];
for (let start = 0; start < characters.length; start += 1) {
const found = await probePracticeText(signature, characters.slice(start).join(""));
if (found === "ready") return "passage";
if (found !== "missing") return found;
}
return "missing";
}
// The engine reports ready after boot and loading while any mutation, including
// a long generation cleanup, holds it. Waiting here is what keeps a refusal from
// becoming a verdict. A failed status is also worth waiting on: a status poll is
// what drives the engine's own reload recovery, so the next one can describe a
// repaired engine. Only an unreachable engine, or one that keeps failing, ends
// the wait, as does a newer signature.
async function awaitIdleEngine(signature) {
for (let failures = 0; failures < PROBE_ATTEMPTS;) {
let status;
try {
status = await send("hd_status", {}, ENGINE_TARGET);
} catch {
return false;
}
if (practiceProbed !== signature) return false;
if (status?.ok === true && status.ready === true && status.loading !== true) return true;
// A failure that is still loading is recovery in progress, not a verdict.
if (status?.ok !== true && status?.loading !== true) failures += 1;
await wait(PROBE_RETRY_MS);
if (practiceProbed !== signature) return false;
}
return false;
}
function probePractice() {
const signature = practiceSignature();
practiceProbed = signature;
practiceOutcome = null;
void (async () => {
const settle = (found) => {
if (practiceProbed !== signature) return;
practiceOutcome = found;
render();
};
for (let refusals = 0; refusals < PROBE_ATTEMPTS; refusals += 1) {
const found = await sweepPractice(signature);
// A newer inventory or option has its own probe; this one's answer is stale.
if (found === "gone" || practiceProbed !== signature) return;
if (found !== "refused") {
settle(found);
return;
}
if (!await awaitIdleEngine(signature)) {
settle("unavailable");
return;
}
}
settle("unavailable");
})();
}
// Preserve the reviewed scene while the current library is proved answerable.
// Finish and saved-page guidance remain available throughout the probe.
function practiceView() {
if (!practice) {
practice = createPracticeView({ document, loadReader, onReaderSettled: render,
onDismiss: () => { element("setup-finish")?.focus(); } });
globalThis.HDVisualNovel.initialize(practice.node.querySelector(".vn-scene"));
}
if (!practiceReadiness(options, dictionaries).canProbe) {
// Retire an in-flight probe when lookup becomes unavailable too.
practiceProbed = "";
practiceOutcome = null;
} else if (practiceProbed !== practiceSignature()) {
probePractice();
}
const readiness = practice.update(options, dictionaries, practiceOutcome);
if (!practiceLookupShown && practiceOutcome === "ready") {
// The first render mounts the practice node while the reader loads. As soon
// as its real lookup control becomes enabled, select before the ready render
// returns so the final page cannot paint ahead of its demonstration.
practiceLookupShown = practice.lookup();
}
return {
heading: readiness.heading,
body: [practice.node, ...(setupState.anki === null ? [] : [ankiOutcomeNote(setupState.anki)])],
actions: [button("setup-finish", "Finish setup", () => { void finish(); }, readiness.message ? "ghost" : "primary-button"), paragraph("The exercise is optional. You can finish any time.")],
};
}
// Nothing found, or no answer within the wait, leaves the plain welcome; a
// late answer re-renders it with the offer.
async function findSharedHachidori() {
try {
const status = await send("hd_sharing_status", {}, SHARING_TARGET);
if (!status.ok || !canDiscoverSharingHost(status.sharing)) return;
const reply = await send("hd_sharing_client_probe", { address: "" }, SHARING_TARGET);
if (!reply.ok || setupState?.stage !== "welcome") return;
sharedHost = { address: reply.address, host: reply.host };
if (!saving) render();
} catch {
// The plain welcome stays.
}
}
// Linking mirrors the shared library here, and setup then has nothing left to do.
async function useSharedHachidori() {
if (saving || setupState === null || sharedHost === null) return;
saving = true;
for (const control of element("setup-actions").querySelectorAll("button")) control.disabled = true;
setStatus("Linking…");
try {
const reply = await send("hd_sharing_client_link", { address: sharedHost.address }, SHARING_TARGET);
if (!reply.ok) throw new Error(reply.error || "the link did not complete");
} catch (error) {
saving = false;
setStatus(`Could not use that Hachidori: ${describe(error)}`, "error");
render();
return;
}
saving = false;
await advance("complete");
}
function welcomeView() {
if (sharedHost !== null) {
const name = sharedHost.host.name || "another browser";
const count = sharedHost.host.dictionaryCount === 1 ? "1 dictionary" : `${sharedHost.host.dictionaryCount} dictionaries`;
return {
heading: "Welcome to Hachidori",
body: [
paragraph(`${name} on this computer already has Hachidori set up, with ${count}.`),
paragraph("Use it here instead of setting up again? Words are looked up there, and nothing is downloaded twice."),
],
actions: [
button("setup-use-shared", `Use the Hachidori in ${name}`, () => { void useSharedHachidori(); }),
button("setup-start", "Set up separately", () => { void advance("dictionaries"); }, "ghost"),
button("setup-manual", "Set up manually", () => { void advance("practice"); }, "ghost"),
],
};
}
return {
heading: "Welcome to Hachidori",
body: [
paragraph("Click Start Setup to automatically set up Hachidori"),
settingsNote("Already using Hachidori in another browser, on this computer or another one? Link to it from ", "settings.html#sharing", " instead of setting up again."),
],
actions: [
button("setup-start", "Start Setup", () => { void advance("dictionaries"); }),
button("setup-manual", "Set up manually", () => { void advance("practice"); }, "ghost"),
],
};
}
const VIEWS = {
welcome: welcomeView,
dictionaries: dictionariesView,
anki: ankiView,
practice: practiceView,
complete: () => ({
heading: "Setup is complete.",
body: [paragraph("You can close this tab. Your setup progress is saved."), settingsNote("Change dictionaries, Anki and reading preferences any time in ", "settings.html")],
actions: [],
}),
};
function inactiveView() {
return {
heading: "Manage Hachidori",
body: [settingsNote("Setup runs once after installation. Manage dictionaries and preferences in ", "settings.html")],
actions: [],
};
}
function failedView() {
return {
heading: "Setup could not be read.",
body: [paragraph(setupError, "hint is-error"), settingsNote("Manage dictionaries and preferences in ", "settings.html")],
actions: [button("setup-reload", "Reload setup", () => { location.reload(); })],
};
}
function renderSteps(stage) {
element("setup-steps").hidden = stage === "welcome";
const position = stage === null ? -1 : SETUP_STAGES.indexOf(stage);
for (const step of element("setup-steps").querySelectorAll(".setup-step")) {
const index = SETUP_STAGES.indexOf(step.dataset.stage);
const current = index === position;
step.classList.toggle("is-current", current);
step.classList.toggle("is-done", position > index);
if (current) step.setAttribute("aria-current", "step");
else step.removeAttribute("aria-current");
}
}
function currentView() {
if (setupError !== null) return failedView();
if (setupState === null) return inactiveView();
if (setupState.stage !== "anki") {
ankiProgressRequested = false;
if (ankiProgressStartedAt !== null) stopAnkiProgress();
}
if (countdown !== null && countdown.stage !== setupState.stage) cancelCountdown();
return VIEWS[setupState.stage]();
}
function renderBody(children) {
const body = element("setup-body");
// Remove obsolete nodes backwards before inserting replacements, keeping
// persistent rows and the scene connected throughout the update.
for (let index = body.children.length - 1; index >= 0; index -= 1) {
const child = body.children[index];
if (!children.includes(child)) child.remove();
}
for (const [index, child] of children.entries()) {
if (body.children[index] !== child) body.insertBefore(child, body.children[index] ?? null);
}
}
function recoverRecordedAnkiOutcome(view) {
if (!ankiFailed || !ankiOutcomeRecorded()) return;
ankiFailed = false;
const status = element("setup-status");
if (status.classList.contains("is-error") && status.textContent.startsWith("Could not check Anki:")) {
setStatus(view.heading);
}
}
function render() {
const stage = setupError === null ? setupState?.stage ?? null : null;
// A stage of its own starts without the previous stage's failed-advance state.
if (stage !== renderedStage) {
advanceFailed = false;
countdownPaused = false;
}
const card = element("setup-card");
const focusKey = card.contains(document.activeElement) ? document.activeElement.dataset.focusKey ?? "" : "";
const view = currentView();
recoverRecordedAnkiOutcome(view);
renderSteps(stage);
const heading = element("setup-heading");
if (heading.textContent !== view.heading) heading.textContent = view.heading;
renderBody(view.body);
element("setup-actions").replaceChildren(...view.actions);
announceOutcomes();
if (heading.textContent !== lastAnnouncedHeading) {
lastAnnouncedHeading = heading.textContent;
if (!element("setup-status").classList.contains("is-error")) setStatus(view.heading);
}
if (renderedStage !== undefined && renderedStage !== stage) {
// The control that held focus belonged to the previous stage.
heading.focus();
} else if (focusKey) {
const replacement = [...card.querySelectorAll("[data-focus-key]")].find((node) => node.dataset.focusKey === focusKey);
// Retry removes its button while work runs; keep a keyboard user's place
// at the result heading when that action no longer exists.
(replacement ?? heading).focus();
}
renderedStage = stage;
}
let lastAnnouncedHeading;
async function advance(stage, { continued = false } = {}) {
if (saving || setupState === null) return false;
saving = true;
for (const control of element("setup-actions").querySelectorAll("button")) control.disabled = true;
setStatus("Saving…");
let advanced = false;
try {
const reply = await send("hd_setup_cas", { baseRevision: setupState.revision, stage, ...(continued ? { continued } : {}) });
if (reply.state) adoptSetupState(reply.state);
// Another tab may have already made this exact move: the conflict it leaves
// behind is the move this page asked for, not a failure to report.
const reached = setupState !== null && SETUP_STAGES.indexOf(setupState.stage) >= SETUP_STAGES.indexOf(stage);
if (!reply.ok && !(reply.conflict === true && reached)) throw new Error(reply.error || "setup progress could not be saved");
setStatus("");
advanced = true;
} catch (error) {
setStatus(`Could not save setup progress: ${describe(error)}`, "error");
} finally {
saving = false;
render();
}
return advanced;
}
async function finish() {
if (!await advance("complete")) return;
try {
const tab = await chrome.tabs.getCurrent();
if (tab?.id !== undefined) await chrome.tabs.remove(tab.id);
} catch {
// The completed view stays readable when the tab cannot close itself.
}
}
function handleStorageChange(changes, area) {
if (area !== "local") return;
let changed = false;
if (changes[SETUP_STATE_KEY]) changed = adoptSetupState(changes[SETUP_STATE_KEY].newValue) || changed;
if (changes.dictionaryState) changed = adoptDictionaryState(changes.dictionaryState.newValue) || changed;
if (changes.options) changed = adoptOptions(changes.options.newValue) || changed;
// A write in flight renders once its reply settles.
if (changed && !saving) render();
}
async function start() {
// Focus directly without a new history entry. The reader also permits the
// native heading fragment if the link ran before this handler was attached.
document.querySelector(".skip-link").addEventListener("click", (event) => {
event.preventDefault();
element("setup-heading").focus();
});
chrome.storage.onChanged.addListener(handleStorageChange);
chrome.runtime.onMessage.addListener(message => { if (setupState?.stage === "dictionaries") installation.receive(message); });
window.addEventListener("pagehide", installation.stop);
window.addEventListener("pageshow", event => { if (event.persisted && setupState?.stage === "dictionaries") void requestInstall([]); });
const stored = await chrome.storage.local.get([SETUP_STATE_KEY, "dictionaryState", "options"]);
adoptSetupState(stored[SETUP_STATE_KEY]);
adoptDictionaryState(stored.dictionaryState);
adoptOptions(stored.options);
if (setupError === null && setupState?.stage === "dictionaries") {
// Reconnect first: a run started by an earlier page may still be active.
await requestInstall(untouchedEntries().map((entry) => entry.sourceId));
}
if (setupError === null && setupState?.stage === "welcome") {
await Promise.race([findSharedHachidori(), new Promise((resolveWait) => setTimeout(resolveWait, SHARED_LOOKUP_MS))]);
}
render();
}
try {
await start();
} catch (error) {
setupError = describe(error);
render();
}