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
1081 lines
43 KiB
JavaScript
1081 lines
43 KiB
JavaScript
/*
|
||
* 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 you’re 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();
|
||
}
|