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
3359 lines
140 KiB
JavaScript
3359 lines
140 KiB
JavaScript
import { extensionApi as chrome, IS_FIREFOX } from "./browser-api.js";
|
|
import { ensureChromeOffscreen } from "./chrome-offscreen.js";
|
|
import { waitForFirefoxOffscreen } from "./firefox-host.js";
|
|
import "./reader-options.js";
|
|
import { createAnkiGateway } from "./anki.js";
|
|
import { detectAnkiSetup, verifyAnkiSetup } from "./anki-setup.js";
|
|
import { createAnkiWorkerService } from "./anki-worker.js";
|
|
import { detectLocalAudioSource } from "./local-audio-setup.js";
|
|
import { createLocalAudioSource, findLocalAudioSource } from "./local-audio-source.js";
|
|
import { lookupAnkiIndex } from "./anki-index.js";
|
|
import { ANKI_INDEX_ALARM, ANKI_INDEX_KEY, ankiIndexConfigurationChange, createAnkiDuplicateIndex } from "./anki-index-cache.js";
|
|
import { createBackupDownloads } from "./backup-downloads.js";
|
|
import { assertBackupSnapshot, backupRevisions } from "./backup-state.js";
|
|
import {
|
|
AUTOMATIC_BACKUP_ALARM,
|
|
AUTOMATIC_BACKUPS_KEY,
|
|
automaticBackupDue,
|
|
automaticBackupStore,
|
|
nextAutomaticBackupTime,
|
|
replaceAutomaticBackup,
|
|
validAutomaticBackups,
|
|
} from "./backup-automatic.js";
|
|
import { SHARING_HOST_ALARM, SHARING_KEY, createSharingHost } from "./sharing-host.js";
|
|
import { API_REQUESTS, createApiHost } from "./api-host.js";
|
|
import { NOT_REACHABLE, SHARING_LOCAL_STATE_KEY, createSharingClient } from "./sharing-client.js";
|
|
import {
|
|
API_CAPABILITY, FORWARDED_REQUESTS, LINKED_ANKI_CAPABILITY, LINKED_ANKI_UNSUPPORTED, SHARING_CAPABILITIES,
|
|
allowLinkedAnkiDiscoveryRequest, allowLinkedAnkiRequest, allowLinkedAnkiSetupRequest,
|
|
browserName, forwardableRequest, mutatingForwardedRequest, parseLinkAddress,
|
|
} from "./sharing-protocol.js";
|
|
import { LOOKUP_STATS_KEY, LOOKUP_STATS_ROW_PREFIX, assertLookupStatsDescriptor, assertLookupStatsRows, emptyLookupStats, incrementLookupStats, lookupStatsKey, lookupStatsPrefix, normaliseLookupTerm } from "./lookup-stats.js";
|
|
import "./external-links.js";
|
|
import "./dictionary-group-state.js";
|
|
import {
|
|
assertDictionaryUpdateSchedule,
|
|
httpsUrl,
|
|
installedRecommendedDictionary,
|
|
MANAGED_DICTIONARY_CHANGED,
|
|
managedDictionaryFingerprint,
|
|
managedDictionaryMatches,
|
|
managedDictionarySource,
|
|
managedUpdateSchedule,
|
|
nextDictionaryUpdateCheck,
|
|
nextManagedUpdateCheck,
|
|
normaliseUpdateSettings,
|
|
recommendedDictionarySource,
|
|
recommendedIndexUrlMatches,
|
|
} from "./managed-dictionary-source.js";
|
|
import {
|
|
CUSTOM_DICTIONARY_ID,
|
|
CUSTOM_DICTIONARY_SOURCE_KEY,
|
|
CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION,
|
|
CUSTOM_DICTIONARY_TITLE,
|
|
assertCustomDictionaryCommit,
|
|
assertCustomSourceState,
|
|
customDictionarySemanticRevision,
|
|
normaliseCustomDictionaryDocument,
|
|
parseCustomDictionary,
|
|
} from "./custom-dictionary.js";
|
|
import { sameJsonValue } from "./json-value.js";
|
|
import {
|
|
boundResponseFailure, responseFits, responseLimitError, validResponseRequestId,
|
|
} from "./response-limits.js";
|
|
import { HOST_CAPABILITIES, MINING_CAPABILITIES, OVERLAY_MODE } from "./overlay-mode.js";
|
|
import {
|
|
FIRST_INSTALL_OPTIONS, FIRST_INSTALL_SELECTIONS, OVERLAY_MODE_OPTIONS, SETUP_STATE_KEY, STARTUP_PAGE,
|
|
RECOMMENDED_SELECTIONS_KEY, OVERLAY_LOCAL_OPTION_KEYS,
|
|
advanceSetupState, capabilityAnkiOptions, initialSetupState, normaliseSetupState, overlayAnkiOptions, recordSetupAnki, recordSetupDictionaries,
|
|
} from "./setup-state.js";
|
|
import { applyCustomJavaScript } from "./custom-javascript.js";
|
|
|
|
const {
|
|
ANKI_TEMPLATE_CONFIG_KEYS, DEFAULT_OPTIONS, ankiTemplateConfig, normaliseOptions, projectStoredOptions,
|
|
validateOptionsPatch,
|
|
} = globalThis.HDReaderOptions;
|
|
const { normaliseExternalUrl } = globalThis.HDExternalLinks;
|
|
const { pruneGroupMemberships } = globalThis.HDDictionaryGroups;
|
|
|
|
/*
|
|
* Service worker for Hachidori.
|
|
*
|
|
* The worker holds no engine state: it only guarantees that the offscreen
|
|
* document exists and relays requests to it. The engine lives in the offscreen
|
|
* document because a service worker is torn down after 30 s idle, which would
|
|
* throw away the loaded dictionaries.
|
|
*
|
|
* It owns the revisioned `dictionaryState` value and dictionary-backed option
|
|
* writes in chrome.storage.local. An offscreen document is granted chrome.runtime
|
|
* and nothing else -- no chrome.storage -- so every read and write the engine
|
|
* needs arrives here as a message.
|
|
*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
const OFFSCREEN_DOCUMENT = "offscreen.html";
|
|
const TARGET = "hoshidicts-offscreen";
|
|
const UPDATE_TARGET = "hachidori-updates";
|
|
const AUDIO_TARGET = "hachidori-audio";
|
|
const CAPTURE_TARGET = "hachidori-capture";
|
|
const CAPTURE_PAGE_TARGET = "hachidori-capture-page";
|
|
const CAPTURE_CONTENT_TARGET = "hachidori-capture-content";
|
|
const CAPTURE_DOCUMENT = "capture.html";
|
|
const SETUP_TARGET = "hachidori-setup";
|
|
const PAGE_ZOOM_TARGET = "hachidori-page-zoom";
|
|
const BACKUP_LIFECYCLE_PORT = "hachidori-backup-settings";
|
|
|
|
// Requests the worker answers itself. A second target is what keeps them out of
|
|
// the relay below: a message from the offscreen document carrying TARGET is
|
|
// indistinguishable from one sent by an extension page, so it would be stamped
|
|
// `relayed` and handed straight back to the offscreen document, where the
|
|
// engine's own request queue would then wait on itself.
|
|
const WORKER_TARGET = "hoshidicts-worker";
|
|
let ankiGateway, ankiMining, ankiDuplicateIndex;
|
|
let activeAnkiOperations = 0;
|
|
const ankiIdleWaiters = new Set();
|
|
|
|
function trackAnkiOperation(job) {
|
|
activeAnkiOperations += 1;
|
|
return Promise.resolve().then(job).finally(() => {
|
|
activeAnkiOperations -= 1;
|
|
if (activeAnkiOperations !== 0) return;
|
|
for (const resolve of ankiIdleWaiters) resolve();
|
|
ankiIdleWaiters.clear();
|
|
});
|
|
}
|
|
|
|
function waitForAnkiIdle() {
|
|
if (activeAnkiOperations === 0) return Promise.resolve();
|
|
return new Promise(resolve => ankiIdleWaiters.add(resolve));
|
|
}
|
|
let backupDownloads;
|
|
let automaticBackupRun = null;
|
|
let automaticBackupNextAt = null;
|
|
let automaticBackupWaitingForState = false;
|
|
// One first-run Anki detection at a time; duplicate startup pages share it.
|
|
let ankiSetupDetection = null;
|
|
|
|
function getBackupDownloads() {
|
|
backupDownloads ??= createBackupDownloads(chrome, relay);
|
|
return backupDownloads;
|
|
}
|
|
|
|
const DICTIONARY_STATE_KEY = "dictionaryState";
|
|
const LEGACY_DICTIONARIES_KEY = "dictionaries";
|
|
const OPTIONS_KEY = "options";
|
|
const UPDATE_SETTINGS_KEY = "dictionaryUpdates";
|
|
const UPDATE_ALARM = "hachidori-managed-dictionary-updates";
|
|
const AUTOMATIC_BACKUP_RETRY_MS = 60 * 60 * 1000;
|
|
const DICTIONARY_STATE_SCHEMA_VERSION = 1;
|
|
const KANJI_SELECTION_KINDS = new Set(["term", "kanji"]);
|
|
// The overlay host (Electron 43) exposes chrome.alarms, and create()/get()
|
|
// even record the alarm, but onAlarm never dispatches to the worker; a host
|
|
// without the API at all behaves the same. Keep the one-shot contract on
|
|
// worker-lifetime timers there: a worker restart re-runs the module-load
|
|
// reconciliation, which re-arms whatever is still due.
|
|
const alarms = chrome.alarms && !OVERLAY_MODE ? chrome.alarms : createTimerAlarms();
|
|
|
|
function createTimerAlarms() {
|
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
const pending = new Map();
|
|
function arm(name, when) {
|
|
pending.get(name).timer = setTimeout(() => {
|
|
if (Date.now() < when) {
|
|
arm(name, when);
|
|
return;
|
|
}
|
|
pending.delete(name);
|
|
handleAlarm({ name, scheduledTime: when });
|
|
}, Math.min(Math.max(when - Date.now(), 0), MAX_TIMER_MS));
|
|
}
|
|
return {
|
|
async create(name, { when, delayInMinutes }) {
|
|
clearTimeout(pending.get(name)?.timer);
|
|
const scheduledTime = when ?? Date.now() + delayInMinutes * 60_000;
|
|
pending.set(name, { when: scheduledTime });
|
|
arm(name, scheduledTime);
|
|
},
|
|
async get(name) {
|
|
const entry = pending.get(name);
|
|
return entry ? { name, scheduledTime: entry.when } : undefined;
|
|
},
|
|
async clear(name) {
|
|
const entry = pending.get(name);
|
|
if (!entry) return false;
|
|
clearTimeout(entry.timer);
|
|
pending.delete(name);
|
|
return true;
|
|
},
|
|
};
|
|
}
|
|
|
|
// The user data a linked browser mirrors: the same five keys a backup carries,
|
|
// plus the lookup-count rows.
|
|
const SHARED_STATE_KEYS = [DICTIONARY_STATE_KEY, OPTIONS_KEY, CUSTOM_DICTIONARY_SOURCE_KEY, UPDATE_SETTINGS_KEY, LOOKUP_STATS_KEY];
|
|
// What this install is called by the ones it shares with or links to.
|
|
const SHARING_NAME = OVERLAY_MODE ? "GameSentenceMiner overlay" : browserName(globalThis.navigator);
|
|
let sharingHost;
|
|
|
|
function dictionaryCount(state) {
|
|
return Array.isArray(state?.dictionaries) ? state.dictionaries.length : 0;
|
|
}
|
|
|
|
async function readSharedState() {
|
|
const stored = await chrome.storage.local.get(SHARED_STATE_KEYS);
|
|
return Object.fromEntries(SHARED_STATE_KEYS.map(key => [key, stored[key] ?? null]));
|
|
}
|
|
|
|
function getSharingHost() {
|
|
sharingHost ??= createSharingHost({
|
|
WebSocket: globalThis.WebSocket,
|
|
alarms,
|
|
dispatch: dispatchSharedRequest,
|
|
readSnapshot: readSharedState,
|
|
sharedKey: key => SHARED_STATE_KEYS.includes(key) || key.startsWith(LOOKUP_STATS_ROW_PREFIX),
|
|
version: chrome.runtime.getManifest().version,
|
|
name: SHARING_NAME,
|
|
capabilities: [...SHARING_CAPABILITIES, API_CAPABILITY],
|
|
});
|
|
return sharingHost;
|
|
}
|
|
|
|
// The relay's API asks like a linked browser; its lookups and renders go
|
|
// through the same engine and offscreen senders as Anki mining.
|
|
let apiHost;
|
|
function getApiHost() {
|
|
apiHost ??= createApiHost({
|
|
version: chrome.runtime.getManifest().version,
|
|
engine: fields => sendAnkiRequest(TARGET, fields),
|
|
render: fields => sendAnkiRequest("hachidori-anki-render", fields),
|
|
readDictionaries: async () => (await readDictionaryStorage()).state?.dictionaries ?? [],
|
|
readAudioSources: async () => (await readAnkiOptions()).audioSources.filter(source => source.enabled),
|
|
});
|
|
return apiHost;
|
|
}
|
|
|
|
// Client side: this install uses another Hachidori. `sharingLinked` is read
|
|
// synchronously by the interception points below after `sharingReady`.
|
|
let sharingClient;
|
|
let sharingLinked = false;
|
|
let sharingEpoch = 0;
|
|
let sharingReady = Promise.resolve();
|
|
let sharingTransitionTail = Promise.resolve();
|
|
const WORKER_FORWARDS = FORWARDED_REQUESTS[WORKER_TARGET];
|
|
const SHARING_OPTIONS_VERSION_KEY = "sharingOptionsVersion";
|
|
const OVERLAY_OPTIONS_STORAGE_KEYS = [OPTIONS_KEY, DICTIONARY_STATE_KEY, SHARING_LOCAL_STATE_KEY, SHARING_OPTIONS_VERSION_KEY];
|
|
const linkedAnkiConfigPrefix = `linked:${crypto.randomUUID()}:`;
|
|
|
|
function linkedAnkiConfigKey(configKey) {
|
|
return `${linkedAnkiConfigPrefix}${String(configKey ?? "")}`;
|
|
}
|
|
|
|
function hostLinkedAnkiRequest(request) {
|
|
if (typeof request?.configKey !== "string" || !request.configKey.startsWith(linkedAnkiConfigPrefix)) {
|
|
throw new Error("Anki configuration changed. Refresh this result before adding a note.");
|
|
}
|
|
return { ...request, configKey: request.configKey.slice(linkedAnkiConfigPrefix.length) };
|
|
}
|
|
|
|
function engineSender(sender) {
|
|
return sender?.id === chrome.runtime.id && sender.url === chrome.runtime.getURL(OFFSCREEN_DOCUMENT);
|
|
}
|
|
|
|
function ankiSettingsSender(sender) {
|
|
return sender?.id === chrome.runtime.id
|
|
&& sender.url?.split(/[?#]/u)[0] === chrome.runtime.getURL("settings.html");
|
|
}
|
|
|
|
// While linked, this install's own engine keeps reading and committing the
|
|
// state it had before linking, so its generations are never judged against
|
|
// the host's inventory that the mirror now holds under the live keys.
|
|
const sharingLocalStore = {
|
|
async get(keys) {
|
|
const record = (await chrome.storage.local.get(SHARING_LOCAL_STATE_KEY))[SHARING_LOCAL_STATE_KEY] ?? {};
|
|
const list = Array.isArray(keys) ? keys : [keys];
|
|
return Object.fromEntries(list.filter(key => record[key] !== null && record[key] !== undefined).map(key => [key, record[key]]));
|
|
},
|
|
async set(values) {
|
|
const record = (await chrome.storage.local.get(SHARING_LOCAL_STATE_KEY))[SHARING_LOCAL_STATE_KEY] ?? {};
|
|
await chrome.storage.local.set({ [SHARING_LOCAL_STATE_KEY]: { ...record, ...values } });
|
|
},
|
|
};
|
|
|
|
function stateStore(sender) {
|
|
return sharingLinked && engineSender(sender) ? sharingLocalStore : chrome.storage.local;
|
|
}
|
|
|
|
function composeOverlayOptions(shared, local, revision) {
|
|
const preferences = normaliseOptions(local);
|
|
return { ...projectStoredOptions(shared),
|
|
...Object.fromEntries(OVERLAY_LOCAL_OPTION_KEYS.map(key => [key, preferences[key]])), revision };
|
|
}
|
|
|
|
// The offset keeps one increasing revision for existing readers and Settings,
|
|
// while retaining the host's actual CAS revision for forwarded writes.
|
|
function overlayHostOptionsValues(shared, stored, snapshot = false) {
|
|
const previous = stored[SHARING_OPTIONS_VERSION_KEY];
|
|
const hostRevision = optionsRevision(shared);
|
|
if (previous && !snapshot && shared !== null && hostRevision < previous.hostRevision) return {};
|
|
const revision = optionsRevision(stored[OPTIONS_KEY]);
|
|
const offset = !previous || hostRevision < previous.hostRevision
|
|
? Math.max(previous?.offset ?? 0, revision + 1 - hostRevision) : previous.offset;
|
|
return {
|
|
[OPTIONS_KEY]: composeOverlayOptions(shared, stored[SHARING_LOCAL_STATE_KEY]?.options ?? stored[OPTIONS_KEY], hostRevision + offset),
|
|
[SHARING_OPTIONS_VERSION_KEY]: { hostRevision, offset },
|
|
};
|
|
}
|
|
|
|
// Mirror host batches together; overlay options additionally retain their
|
|
// local preferences and translate the host's revision for existing consumers.
|
|
async function applyMirror(changes, snapshot = false) {
|
|
const values = {};
|
|
const removals = [];
|
|
if (OVERLAY_MODE && Object.hasOwn(changes, OPTIONS_KEY)) {
|
|
Object.assign(values, overlayHostOptionsValues(changes[OPTIONS_KEY],
|
|
await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS), snapshot));
|
|
}
|
|
for (const [key, value] of Object.entries(changes)) {
|
|
if (OVERLAY_MODE && key === OPTIONS_KEY) continue;
|
|
if (value === null) removals.push(key);
|
|
else values[key] = value;
|
|
}
|
|
if (Object.keys(values).length > 0) await chrome.storage.local.set(values);
|
|
if (removals.length > 0) await chrome.storage.local.remove(removals);
|
|
}
|
|
|
|
function getSharingClient() {
|
|
sharingClient ??= createSharingClient({
|
|
WebSocket: globalThis.WebSocket,
|
|
applyBatch: (changes, isCurrent, snapshot) => serialiseStorage(() => {
|
|
// Unlink or a replacement connection may have retired this batch while
|
|
// it waited behind the restoration's storage writes.
|
|
if (sharingLinked && isCurrent()) return applyMirror(changes, snapshot);
|
|
}),
|
|
version: chrome.runtime.getManifest().version,
|
|
name: SHARING_NAME,
|
|
});
|
|
return sharingClient;
|
|
}
|
|
|
|
function sharingStatus() {
|
|
const client = getSharingClient().status();
|
|
return { ...getSharingHost().status(), client: { ...client, display: client.address === null ? null : parseLinkAddress(client.address).display } };
|
|
}
|
|
|
|
function forwardToHost(message, capability = null) {
|
|
return getSharingClient().forward(message, {
|
|
capability,
|
|
mutation: mutatingForwardedRequest(message),
|
|
}).catch(error => failureReply(message, error));
|
|
}
|
|
|
|
function linkedOptionsCapability(message) {
|
|
if (message?.type !== "hd_options_write") return null;
|
|
const patch = message.options;
|
|
return patch && typeof patch === "object"
|
|
&& (Object.hasOwn(patch, "anki") || Object.hasOwn(patch, "customButtons"))
|
|
? LINKED_ANKI_CAPABILITY
|
|
: null;
|
|
}
|
|
|
|
const LINKED_SETTINGS_UPDATE_REQUIRED =
|
|
"Update the linked Hachidori before editing Templates or Custom Buttons.";
|
|
|
|
function assignMatchingLegacyLinks(incoming, currentLinks, available, assigned) {
|
|
for (const [incomingIndex, button] of incoming.entries()) {
|
|
const match = currentLinks.findIndex((candidate, currentIndex) => available.has(currentIndex)
|
|
&& candidate.label === button.label && candidate.url === button.url);
|
|
if (match < 0) continue;
|
|
assigned[incomingIndex] = match;
|
|
available.delete(match);
|
|
}
|
|
}
|
|
|
|
function assignPositionedLegacyLinks(incoming, available, assigned) {
|
|
for (let index = 0; index < incoming.length; index += 1) {
|
|
if (assigned[index] >= 0 || !available.has(index)) continue;
|
|
assigned[index] = index;
|
|
available.delete(index);
|
|
}
|
|
}
|
|
|
|
function mergeLegacyCustomLinks(currentButtons, links) {
|
|
const incoming = validateOptionsPatch({ customLinks: links }).customButtons;
|
|
const currentLinks = currentButtons.filter(button => button.type === "link");
|
|
const assigned = new Array(incoming.length).fill(-1);
|
|
const available = new Set(currentLinks.map((_, index) => index));
|
|
// Preserve identity through legacy reordering before treating a changed row
|
|
// as an edit of the link that occupied the same legacy position.
|
|
assignMatchingLegacyLinks(incoming, currentLinks, available, assigned);
|
|
assignPositionedLegacyLinks(incoming, available, assigned);
|
|
const usedIds = new Set(currentButtons.filter(button => button.type !== "link").map(button => button.id));
|
|
for (const currentIndex of assigned) {
|
|
if (currentIndex >= 0) usedIds.add(currentLinks[currentIndex].id);
|
|
}
|
|
let generated = 1;
|
|
const nextLinks = incoming.map((button, index) => {
|
|
if (assigned[index] >= 0) return { ...button, id: currentLinks[assigned[index]].id };
|
|
let id = `legacy-link-${generated++}`;
|
|
while (usedIds.has(id)) id = `legacy-link-${generated++}`;
|
|
usedIds.add(id);
|
|
return { ...button, id };
|
|
});
|
|
let linkIndex = 0;
|
|
const merged = [];
|
|
for (const button of currentButtons) {
|
|
if (button.type === "link") {
|
|
if (linkIndex < nextLinks.length) merged.push(nextLinks[linkIndex++]);
|
|
} else {
|
|
merged.push(button);
|
|
}
|
|
}
|
|
merged.push(...nextLinks.slice(linkIndex));
|
|
return merged;
|
|
}
|
|
|
|
function mergeLegacyAnki(current, legacy) {
|
|
const first = {
|
|
id: current.templates[0].id,
|
|
name: current.templates[0].name,
|
|
...Object.fromEntries(ANKI_TEMPLATE_CONFIG_KEYS.map(key => [key, legacy[key]])),
|
|
};
|
|
return {
|
|
url: legacy.url,
|
|
apiKey: legacy.apiKey,
|
|
templates: [first, ...current.templates.slice(1)],
|
|
...Object.fromEntries(ANKI_TEMPLATE_CONFIG_KEYS.map(key => [key, first[key]])),
|
|
};
|
|
}
|
|
|
|
async function compatibleLinkedWorkerMessage(message, sender) {
|
|
const capabilities = sender.linkedCapabilities;
|
|
if (message.type !== "hd_options_write" || !Array.isArray(capabilities)
|
|
|| capabilities.includes(LINKED_ANKI_CAPABILITY)) return message;
|
|
const patch = message.options;
|
|
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return message;
|
|
const richAnki = patch.anki && typeof patch.anki === "object"
|
|
&& Object.hasOwn(patch.anki, "templates");
|
|
if (Object.hasOwn(patch, "customButtons") || richAnki) {
|
|
throw new Error(LINKED_SETTINGS_UPDATE_REQUIRED);
|
|
}
|
|
if (!Object.hasOwn(patch, "customLinks") && !Object.hasOwn(patch, "anki")) return message;
|
|
const stored = normaliseOptions((await chrome.storage.local.get(OPTIONS_KEY))[OPTIONS_KEY]);
|
|
const compatible = { ...patch };
|
|
if (Object.hasOwn(patch, "customLinks")) {
|
|
compatible.customButtons = mergeLegacyCustomLinks(stored.customButtons, patch.customLinks);
|
|
delete compatible.customLinks;
|
|
}
|
|
if (Object.hasOwn(patch, "anki")) {
|
|
const legacy = validateOptionsPatch({ anki: patch.anki }).anki;
|
|
compatible.anki = mergeLegacyAnki(stored.anki, legacy);
|
|
}
|
|
return { ...message, options: compatible };
|
|
}
|
|
|
|
function forwardWorkerRequest(message) {
|
|
return OVERLAY_MODE && message.type === "hd_options_write"
|
|
? writeLinkedOverlayOptions(message)
|
|
: forwardToHost(message, linkedOptionsCapability(message));
|
|
}
|
|
|
|
async function readAnkiOptions() {
|
|
const options = normaliseOptions((await chrome.storage.local.get(OPTIONS_KEY))[OPTIONS_KEY]);
|
|
return capabilityAnkiOptions(options, {
|
|
screenshot: MINING_CAPABILITIES.screenshot,
|
|
browserSpeech: MINING_CAPABILITIES.browserSpeech,
|
|
mediaCapture: HOST_CAPABILITIES.mediaCapture,
|
|
});
|
|
}
|
|
|
|
// Called within the background storage queue. Options and index invalidation
|
|
// share one write so a delayed storage event cannot publish an obsolete pull.
|
|
async function writeLocalState(values, store = chrome.storage.local) {
|
|
if (store === chrome.storage.local && Object.hasOwn(values, OPTIONS_KEY)) {
|
|
const stored = await chrome.storage.local.get([OPTIONS_KEY, ANKI_INDEX_KEY]);
|
|
const index = await ankiIndexConfigurationChange(
|
|
normaliseOptions(stored[OPTIONS_KEY]), normaliseOptions(values[OPTIONS_KEY]), stored[ANKI_INDEX_KEY],
|
|
);
|
|
if (index !== undefined) values = { ...values, [ANKI_INDEX_KEY]: index };
|
|
}
|
|
await store.set(values);
|
|
}
|
|
|
|
function getAnkiDuplicateIndex() {
|
|
ankiDuplicateIndex ??= createAnkiDuplicateIndex({
|
|
fetchRows: async source => {
|
|
const reply = await relay({ target: "hachidori-anki-render", type: "hd_anki_index_refresh",
|
|
requestId: `anki-index-${crypto.randomUUID()}`, source });
|
|
if (!reply.ok) throw new Error(reply.error);
|
|
return reply.rows;
|
|
},
|
|
lookupLive: (source, expression, invoke) => lookupAnkiIndex(invoke, source, expression),
|
|
readOptions: readAnkiOptions,
|
|
readState: async () => (await chrome.storage.local.get(ANKI_INDEX_KEY))[ANKI_INDEX_KEY],
|
|
updateState: update => serialiseStorage(async () => {
|
|
const stored = await chrome.storage.local.get([OPTIONS_KEY, ANKI_INDEX_KEY]);
|
|
const state = stored[ANKI_INDEX_KEY];
|
|
const next = await update({ options: normaliseOptions(stored[OPTIONS_KEY]), state });
|
|
if (next !== undefined && !sameJsonValue(state, next)) {
|
|
await writeLocalState({ [ANKI_INDEX_KEY]: next });
|
|
}
|
|
return next ?? state;
|
|
}),
|
|
alarms,
|
|
});
|
|
return ankiDuplicateIndex;
|
|
}
|
|
// A relayed request can arrive in the window between createDocument() resolving
|
|
// and offscreen.js running its module body, where nothing is listening yet.
|
|
const RELAY_ATTEMPTS = 5;
|
|
const RELAY_BACKOFF_MS = 40;
|
|
const NOT_LISTENING = /Receiving end does not exist|Could not establish connection/i;
|
|
|
|
// True after the offscreen document answered a relayed request; cleared when a
|
|
// relay gets no reply, so the next attempt verifies the document again.
|
|
let offscreenAnswered = false;
|
|
let latestAudioOperation = null;
|
|
let capturePage = null;
|
|
let captureRecovery = null;
|
|
let captureContentDocument = null;
|
|
let captureLink = null;
|
|
|
|
function describe(error) {
|
|
if (error instanceof Error) {
|
|
return error.message || String(error);
|
|
}
|
|
return typeof error === "string" ? error : JSON.stringify(error);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
function capturePageSender(sender) {
|
|
return sender.id === chrome.runtime.id
|
|
&& sender.url === chrome.runtime.getURL(OFFSCREEN_DOCUMENT)
|
|
&& sender.tab === undefined;
|
|
}
|
|
|
|
function trustedCaptureControl(sender) {
|
|
if (sender.id !== chrome.runtime.id || typeof sender.url !== "string") return false;
|
|
try {
|
|
const url = new URL(sender.url);
|
|
if (url.search) return false;
|
|
url.hash = "";
|
|
return ["settings.html", "toolbar.html", CAPTURE_DOCUMENT]
|
|
.some(document => url.href === chrome.runtime.getURL(document));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function relayCapture(message, stillCurrent = null) {
|
|
if (!capturePage || captureRecovery) await recoverCaptureHost();
|
|
if (stillCurrent && !stillCurrent()) return { ignored: true };
|
|
return sendCapture(message);
|
|
}
|
|
|
|
async function sendCapture(message) {
|
|
if (!capturePage?.documentId) throw new Error("The media capture host is unavailable.");
|
|
const request = {
|
|
...message,
|
|
target: CAPTURE_PAGE_TARGET,
|
|
relayed: true,
|
|
captureDocumentId: capturePage.documentId,
|
|
};
|
|
let reply;
|
|
try {
|
|
reply = await chrome.runtime.sendMessage(request);
|
|
} catch (error) {
|
|
capturePage = null;
|
|
void unlinkCaptureContent();
|
|
throw error;
|
|
}
|
|
if (!reply) {
|
|
capturePage = null;
|
|
void unlinkCaptureContent();
|
|
throw new Error("The media capture host did not reply.");
|
|
}
|
|
if (!responseFits(reply)) throw new Error(responseLimitError(message.type));
|
|
if (!reply.ok) throw new Error(reply.error || "The capture operation failed.");
|
|
return reply;
|
|
}
|
|
|
|
async function recoverCaptureBinding(page) {
|
|
if (captureContentDocument || captureLink?.captureSessionId || !page) return;
|
|
assertCaptureTabId(page.tabId);
|
|
if (!shortCaptureString(page.documentId)) throw new Error("The linked document identity is invalid.");
|
|
const owner = capturePage;
|
|
const identity = { tabId: page.tabId, documentId: page.documentId };
|
|
const reply = await chrome.tabs.sendMessage(page.tabId, {
|
|
target: CAPTURE_CONTENT_TARGET, type: "hd_capture_recover",
|
|
}, { documentId: page.documentId }).catch(() => null);
|
|
if (capturePage !== owner || captureContentDocument || captureLink?.captureSessionId) return;
|
|
if (reply?.linked === true && reply.documentId === page.documentId) {
|
|
captureContentDocument = identity;
|
|
} else {
|
|
await sendCapture({ type: "hd_capture_unlinked", ...identity,
|
|
reason: "The reading document is no longer available. Link the page again." });
|
|
}
|
|
}
|
|
|
|
async function recoverCaptureHost() {
|
|
if (captureRecovery) return captureRecovery;
|
|
if (capturePage) return;
|
|
captureRecovery = (async () => {
|
|
await ensureOffscreen();
|
|
const contexts = await chrome.runtime.getContexts({
|
|
contextTypes: ["OFFSCREEN_DOCUMENT"], documentUrls: [chrome.runtime.getURL(OFFSCREEN_DOCUMENT)],
|
|
});
|
|
if (capturePage || contexts.length !== 1) return;
|
|
const context = contexts[0];
|
|
capturePage = { documentId: context.documentId };
|
|
const status = await sendCapture({ type: "hd_capture_status" });
|
|
await recoverCaptureBinding(status.linkedPage);
|
|
})();
|
|
try { await captureRecovery; }
|
|
finally { captureRecovery = null; }
|
|
}
|
|
|
|
function finiteCaptureTime(value) {
|
|
return Number.isFinite(value) && Math.abs(value - Date.now()) <= 10 * 60 * 1000;
|
|
}
|
|
|
|
function shortCaptureString(value, limit = 256) {
|
|
return typeof value === "string" && value.length > 0 && value.length <= limit;
|
|
}
|
|
|
|
function assertCaptureTabId(tabId) {
|
|
if (!Number.isInteger(tabId) || tabId < 0) throw new Error("Choose a valid reading tab.");
|
|
}
|
|
|
|
async function captureTabs() {
|
|
const tabs = await chrome.tabs.query({});
|
|
return tabs.filter(tab => Number.isInteger(tab.id) && typeof tab.url === "string"
|
|
&& /^(https?|file):/u.test(tab.url))
|
|
.map(tab => ({ id: tab.id, title: String(tab.title || "").slice(0, 200), url: tab.url.slice(0, 2048) }));
|
|
}
|
|
|
|
async function commandCaptureContent(tabId, type, fields = {}) {
|
|
assertCaptureTabId(tabId);
|
|
try {
|
|
const reply = await chrome.tabs.sendMessage(tabId, {
|
|
target: CAPTURE_CONTENT_TARGET,
|
|
type,
|
|
...fields,
|
|
}, { frameId: 0 });
|
|
if (reply?.error) throw new Error(reply.error);
|
|
return reply;
|
|
} catch (error) {
|
|
throw new Error(`The reading page is unavailable. Reload it and try again. ${describe(error)}`);
|
|
}
|
|
}
|
|
|
|
function captureDocumentKey(tabId) {
|
|
return `tab:${tabId}`;
|
|
}
|
|
|
|
async function unlinkCaptureContent() {
|
|
const linked = captureContentDocument;
|
|
if (!linked) return;
|
|
try {
|
|
await commandCaptureContent(linked.tabId, "hd_capture_unlink");
|
|
} catch {
|
|
// Navigation and tab closure already destroy the content-script state.
|
|
}
|
|
if (captureContentDocument?.tabId === linked.tabId
|
|
&& captureContentDocument.documentId === linked.documentId) {
|
|
captureContentDocument = null;
|
|
}
|
|
}
|
|
|
|
// createDocument() rejects when called while another call is in flight, so every
|
|
// caller waits on the same promise.
|
|
async function ensureOffscreen() {
|
|
if (IS_FIREFOX) {
|
|
await waitForFirefoxOffscreen();
|
|
return;
|
|
}
|
|
// Some extension hosts keep this page alive themselves instead of exposing
|
|
// Chrome's offscreen-document lifecycle API.
|
|
await ensureChromeOffscreen(OFFSCREEN_DOCUMENT);
|
|
}
|
|
|
|
async function relay(message, stillCurrent = null) {
|
|
let failure = null;
|
|
for (let attempt = 0; attempt < RELAY_ATTEMPTS; attempt += 1) {
|
|
// ensureOffscreen() costs a getContexts() round trip to the browser process
|
|
// on every request (about 0.2 ms of a 2.3 ms lookup). Once the document has
|
|
// answered, send to it directly; a missing reply falls back to the checked
|
|
// path immediately, without consuming an attempt or backing off.
|
|
const optimistic = offscreenAnswered;
|
|
if (!optimistic) {
|
|
await ensureOffscreen();
|
|
}
|
|
if (stillCurrent && !stillCurrent()) {
|
|
return { type: `${message.type}_result`, requestId: message.requestId, ok: true, status: "cancelled" };
|
|
}
|
|
try {
|
|
// `relayed` is what lets offscreen.js ignore the copy of this message that
|
|
// chrome.runtime.sendMessage also delivers to it directly, so a request
|
|
// from an extension page runs on the engine exactly once.
|
|
const reply = await chrome.runtime.sendMessage({ ...message, relayed: true });
|
|
if (reply !== undefined) {
|
|
offscreenAnswered = true;
|
|
return reply;
|
|
}
|
|
failure = new Error("offscreen document sent no reply");
|
|
} catch (error) {
|
|
if (!NOT_LISTENING.test(describe(error))) {
|
|
throw error;
|
|
}
|
|
failure = error;
|
|
}
|
|
offscreenAnswered = false;
|
|
if (optimistic) {
|
|
attempt -= 1;
|
|
continue;
|
|
}
|
|
await sleep(RELAY_BACKOFF_MS * (attempt + 1));
|
|
}
|
|
throw failure ?? new Error("offscreen document unreachable");
|
|
}
|
|
|
|
async function readDictionaryStorage(includeCustomDocument = false, store = chrome.storage.local) {
|
|
const keys = [
|
|
DICTIONARY_STATE_KEY,
|
|
LEGACY_DICTIONARIES_KEY,
|
|
OPTIONS_KEY,
|
|
];
|
|
if (includeCustomDocument) keys.push(CUSTOM_DICTIONARY_SOURCE_KEY);
|
|
const stored = await store.get(keys);
|
|
const state = stored?.[DICTIONARY_STATE_KEY] ?? null;
|
|
return {
|
|
state,
|
|
legacyDictionaries:
|
|
state === null && Array.isArray(stored?.[LEGACY_DICTIONARIES_KEY])
|
|
? stored[LEGACY_DICTIONARIES_KEY]
|
|
: null,
|
|
options: stored?.[OPTIONS_KEY],
|
|
customDocument: includeCustomDocument
|
|
? stored?.[CUSTOM_DICTIONARY_SOURCE_KEY] ?? null
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
async function readUpdateSettings() {
|
|
const stored = await chrome.storage.local.get(UPDATE_SETTINGS_KEY);
|
|
return normaliseUpdateSettings(stored?.[UPDATE_SETTINGS_KEY]);
|
|
}
|
|
|
|
function hasCapability(dictionary, kind) {
|
|
if (kind === "freq") return dictionary.frequencyCount > 0;
|
|
if (kind === "kanji") return dictionary.kanjiCount > 0;
|
|
if (dictionary.termCount > 0) return true;
|
|
return dictionary.frequencyCount === 0 && dictionary.pitchCount === 0 && dictionary.kanjiCount === 0;
|
|
}
|
|
|
|
function normaliseDictionarySelections(value, dictionaries) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
const options = { ...value };
|
|
const selectedFrequency = dictionaries.find((entry) =>
|
|
entry.title === options.frequencyDictionary);
|
|
if (
|
|
options.frequencyDictionary
|
|
&& (!selectedFrequency || selectedFrequency.enabled === false || !hasCapability(selectedFrequency, "freq"))
|
|
) {
|
|
options.frequencyDictionary = "";
|
|
}
|
|
|
|
const selection = typeof options.kanjiClickDictionary === "string"
|
|
? { title: options.kanjiClickDictionary, kind: "" }
|
|
: options.kanjiClickDictionary;
|
|
if (selection?.title) {
|
|
const selected = dictionaries.find((entry) => entry.title === selection.title);
|
|
let kind = selection.kind;
|
|
if (!KANJI_SELECTION_KINDS.has(kind)) {
|
|
kind = selected && hasCapability(selected, "kanji") ? "kanji" : "term";
|
|
}
|
|
if (!selected || selected.enabled === false || !hasCapability(selected, kind)) {
|
|
options.kanjiClickDictionary = "";
|
|
} else if (!KANJI_SELECTION_KINDS.has(selection.kind)) {
|
|
options.kanjiClickDictionary = { title: selection.title, kind };
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function assertDictionaryState(state) {
|
|
if (state !== null && state?.schemaVersion !== DICTIONARY_STATE_SCHEMA_VERSION) {
|
|
throw new Error(`unsupported dictionary state schema ${String(state?.schemaVersion)}`);
|
|
}
|
|
}
|
|
|
|
function customPackageEngineState(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
const engineState = { ...value };
|
|
delete engineState.displayName;
|
|
delete engineState.favorite;
|
|
return engineState;
|
|
}
|
|
|
|
function assertOrdinaryCustomTransition(currentDictionaries, nextDictionaries) {
|
|
const currentIndex = currentDictionaries.findIndex(
|
|
(dictionary) => dictionary?.id === CUSTOM_DICTIONARY_ID,
|
|
);
|
|
const nextIndexes = nextDictionaries.flatMap((dictionary, index) =>
|
|
dictionary?.id === CUSTOM_DICTIONARY_ID ? [index] : []);
|
|
if (currentIndex < 0 && nextIndexes.length === 0) return;
|
|
if (currentIndex < 0 || nextIndexes.length !== 1) {
|
|
throw new Error("the managed custom dictionary can only be changed by its source editor");
|
|
}
|
|
const current = currentDictionaries[currentIndex];
|
|
const next = nextDictionaries[nextIndexes[0]];
|
|
if (currentIndex !== 0
|
|
|| current?.title !== CUSTOM_DICTIONARY_TITLE
|
|
|| current?.enabled !== true
|
|
|| nextIndexes[0] !== 0
|
|
|| next?.title !== CUSTOM_DICTIONARY_TITLE
|
|
|| next?.enabled !== true
|
|
|| !sameJsonValue(customPackageEngineState(current), customPackageEngineState(next))) {
|
|
throw new Error("the managed custom dictionary must stay enabled and first");
|
|
}
|
|
}
|
|
|
|
function assertCustomDictionaryCasRequest(message) {
|
|
if (!Number.isInteger(message?.baseDocumentRevision)
|
|
|| message.baseDocumentRevision < 0) {
|
|
throw new Error("the custom dictionary write carried no valid document revision");
|
|
}
|
|
if (!Number.isInteger(message?.baseRevision) || message.baseRevision < 0) {
|
|
throw new Error("the custom dictionary write carried no valid dictionary revision");
|
|
}
|
|
if (typeof message?.text !== "string"
|
|
|| typeof message?.semanticRevision !== "string") {
|
|
throw new TypeError("the custom dictionary write carried no source document");
|
|
}
|
|
const changesDictionaryState = message.dictionaries !== undefined;
|
|
if (changesDictionaryState && !Array.isArray(message.dictionaries)) {
|
|
throw new TypeError("the custom dictionary write carried an invalid dictionary list");
|
|
}
|
|
if (message.groups !== undefined && !Array.isArray(message.groups)) {
|
|
throw new TypeError("the custom dictionary write carried invalid groups");
|
|
}
|
|
return changesDictionaryState;
|
|
}
|
|
|
|
function committedSelectionTitle(title, current, dictionaries) {
|
|
const selected = current?.dictionaries.find((entry) => entry.title === title);
|
|
return selected ? dictionaries.find((entry) => entry.id === selected.id)?.title ?? "" : title;
|
|
}
|
|
|
|
function migrateCommittedDictionarySelections(value, current, dictionaries) {
|
|
const options = { ...value };
|
|
const migrate = (title) => typeof title === "string" && title !== ""
|
|
? committedSelectionTitle(title, current, dictionaries)
|
|
: title;
|
|
for (const key of [
|
|
"frequencyDictionary",
|
|
"definitionBlurFrequencyDictionary",
|
|
"compactDefinitionSummaryDictionary",
|
|
"pitchAccentFuriganaDictionary",
|
|
]) {
|
|
if (Object.hasOwn(options, key)) options[key] = migrate(options[key]);
|
|
}
|
|
if (Object.hasOwn(options, "kanjiClickDictionary")
|
|
&& typeof options.kanjiClickDictionary === "string") {
|
|
options.kanjiClickDictionary = migrate(options.kanjiClickDictionary);
|
|
} else if (options.kanjiClickDictionary?.title) {
|
|
const title = migrate(options.kanjiClickDictionary.title);
|
|
options.kanjiClickDictionary = title === ""
|
|
? ""
|
|
: { ...options.kanjiClickDictionary, title };
|
|
}
|
|
if (Object.hasOwn(options, "popupImageSource")
|
|
&& options.popupImageSource?.kind === "dictionary") {
|
|
const title = migrate(options.popupImageSource.title);
|
|
options.popupImageSource = title ? { kind: "dictionary", title } : null;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function dictionaryCommit(current, currentOptions, dictionaries, groups) {
|
|
for (const dictionary of dictionaries) assertDictionaryUpdateSchedule(dictionary);
|
|
const currentRevision = current?.revision ?? 0;
|
|
const state = {
|
|
schemaVersion: DICTIONARY_STATE_SCHEMA_VERSION,
|
|
revision: currentRevision + 1,
|
|
dictionaries,
|
|
groups: pruneGroupMemberships(groups ?? current?.groups, dictionaries),
|
|
};
|
|
const values = { [DICTIONARY_STATE_KEY]: state };
|
|
if (currentOptions !== undefined) {
|
|
const revision = optionsRevision(currentOptions);
|
|
const nextOptions = normaliseDictionarySelections(
|
|
migrateCommittedDictionarySelections(
|
|
{ ...projectStoredOptions(currentOptions), revision },
|
|
current,
|
|
state.dictionaries,
|
|
),
|
|
state.dictionaries,
|
|
);
|
|
if (!sameJsonValue(nextOptions, { ...currentOptions, revision })) {
|
|
values[OPTIONS_KEY] = { ...nextOptions, revision: revision + 1 };
|
|
}
|
|
}
|
|
return { state, values };
|
|
}
|
|
|
|
function optionsRevision(options) {
|
|
return Number.isInteger(options?.revision) && options.revision >= 0 ? options.revision : 0;
|
|
}
|
|
|
|
async function removeLegacyDictionaryRows(current, legacyDictionaries) {
|
|
if (current !== null || legacyDictionaries === null) return;
|
|
try {
|
|
await chrome.storage.local.remove(LEGACY_DICTIONARIES_KEY);
|
|
} catch (error) {
|
|
console.warn("hoshidicts: could not remove legacy dictionary rows:", describe(error));
|
|
}
|
|
}
|
|
|
|
// The engine or settings page reads state, changes it, and sends it back a
|
|
// message round trip later. A caller includes the revision it read so a stale
|
|
// write cannot discard a change made by another extension context.
|
|
async function lookupStatisticsStorage(message, record) {
|
|
const term = normaliseLookupTerm(message.term, message.reading);
|
|
const stored = await chrome.storage.local.get([LOOKUP_STATS_KEY, OPTIONS_KEY]);
|
|
let descriptor = stored[LOOKUP_STATS_KEY] === undefined ? emptyLookupStats() : stored[LOOKUP_STATS_KEY];
|
|
assertLookupStatsDescriptor(descriptor);
|
|
const storedOptions = stored[OPTIONS_KEY];
|
|
if (storedOptions?.showLookupCounts === false) {
|
|
return { descriptor, statistics: null };
|
|
}
|
|
const key = lookupStatsKey(descriptor, term);
|
|
let row = descriptor.generation === null ? undefined : (await chrome.storage.local.get(key))[key];
|
|
if (record) {
|
|
row = incrementLookupStats(row, term, Date.now());
|
|
descriptor = { generation: descriptor.generation ?? crypto.randomUUID(), revision: descriptor.revision + 1 };
|
|
assertLookupStatsDescriptor(descriptor);
|
|
await writeLocalState({ [LOOKUP_STATS_KEY]: descriptor, [lookupStatsKey(descriptor, term)]: row });
|
|
} else if (row !== undefined) {
|
|
assertLookupStatsRows(descriptor, [row]);
|
|
if (lookupStatsKey(descriptor, row) !== key) throw new Error("The lookup statistics row does not match its key.");
|
|
}
|
|
return {
|
|
descriptor,
|
|
statistics: row ?? { ...term, lookupCount: 0 },
|
|
};
|
|
}
|
|
|
|
function lookupStatistics(message, record) {
|
|
return serialiseStorage(
|
|
() => lookupStatisticsStorage(message, record),
|
|
);
|
|
}
|
|
|
|
function assertBackupEngineSender(sender) {
|
|
if (sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL(OFFSCREEN_DOCUMENT)) {
|
|
throw new Error("Backup restore and cleanup must be requested by the dictionary engine.");
|
|
}
|
|
}
|
|
|
|
class AutomaticBackupNotReadyError extends Error {}
|
|
|
|
async function readBackupPayload() {
|
|
const { snapshot } = await WORKER_HANDLERS.hd_backup_base_read();
|
|
const stored = await chrome.storage.local.get(null);
|
|
const descriptor = stored[LOOKUP_STATS_KEY] === undefined ? emptyLookupStats() : stored[LOOKUP_STATS_KEY];
|
|
assertLookupStatsDescriptor(descriptor);
|
|
const prefix = lookupStatsPrefix(descriptor);
|
|
const lookupStatsRows = Object.entries(stored).filter(([key]) => key.startsWith(prefix)).map(([key, row]) => {
|
|
if (lookupStatsKey(descriptor, row) !== key) throw new Error("The lookup statistics row does not match its key.");
|
|
return row;
|
|
});
|
|
assertLookupStatsRows(descriptor, lookupStatsRows);
|
|
return { snapshot: {
|
|
state: snapshot.state,
|
|
options: { ...projectStoredOptions(snapshot.options), revision: optionsRevision(snapshot.options) },
|
|
document: normaliseCustomDictionaryDocument(snapshot.document),
|
|
updates: normaliseUpdateSettings(snapshot.updates),
|
|
lookupStats: descriptor,
|
|
}, lookupStatsRows };
|
|
}
|
|
|
|
async function commitAutomaticBackupStore(current, next) {
|
|
try {
|
|
await chrome.storage.local.set({ [AUTOMATIC_BACKUPS_KEY]: next });
|
|
return;
|
|
} catch (commitError) {
|
|
let readback;
|
|
try {
|
|
readback = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
|
|
} catch (readError) {
|
|
throw new Error(
|
|
`automatic backup metadata commit outcome is unknown: ${describe(commitError)}; `
|
|
+ `readback failed: ${describe(readError)}`,
|
|
);
|
|
}
|
|
if (sameJsonValue(readback, next)) return;
|
|
if (sameJsonValue(readback, current)) throw commitError;
|
|
throw new Error(
|
|
`automatic backup metadata commit outcome is unknown: ${describe(commitError)}; `
|
|
+ "readback did not match the previous or replacement index",
|
|
);
|
|
}
|
|
}
|
|
|
|
function automaticBackupSummary(record) {
|
|
return {
|
|
id: record.id,
|
|
createdAt: record.createdAt,
|
|
dictionaries: record.snapshot.state.dictionaries.map(({ title, enabled }) => ({ title, enabled })),
|
|
customEntryCount: parseCustomDictionary(record.snapshot.document.text).entries.length,
|
|
};
|
|
}
|
|
|
|
async function scheduleAutomaticBackup(when) {
|
|
const scheduledTime = Math.max(Date.now(), when);
|
|
const existing = await alarms.get(AUTOMATIC_BACKUP_ALARM);
|
|
if (existing?.scheduledTime !== scheduledTime || existing.periodInMinutes !== undefined) {
|
|
await alarms.create(AUTOMATIC_BACKUP_ALARM, { when: scheduledTime });
|
|
}
|
|
automaticBackupNextAt = when;
|
|
}
|
|
|
|
async function suppressAutomaticBackupsWhileLinked() {
|
|
automaticBackupNextAt = null;
|
|
automaticBackupWaitingForState = false;
|
|
await alarms.clear(AUTOMATIC_BACKUP_ALARM);
|
|
return { created: false, linked: true };
|
|
}
|
|
|
|
async function reconcileAutomaticBackups() {
|
|
if (sharingLinked) return suppressAutomaticBackupsWhileLinked();
|
|
const result = await serialiseStorage(async () => {
|
|
if (sharingLinked) return { created: false, linked: true };
|
|
const current = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
|
|
const store = automaticBackupStore(current);
|
|
const checkedAt = Date.now();
|
|
if (!automaticBackupDue(store, checkedAt)) {
|
|
return { created: false, nextAt: nextAutomaticBackupTime(store, checkedAt) };
|
|
}
|
|
const payload = await readBackupPayload();
|
|
try {
|
|
await assertBackupSnapshot(payload.snapshot);
|
|
} catch (error) {
|
|
throw new AutomaticBackupNotReadyError(describe(error), { cause: error });
|
|
}
|
|
const createdAt = Date.now();
|
|
if (!automaticBackupDue(store, createdAt)) {
|
|
return { created: false, nextAt: nextAutomaticBackupTime(store, createdAt) };
|
|
}
|
|
const record = {
|
|
id: crypto.randomUUID(),
|
|
createdAt: new Date(createdAt).toISOString(),
|
|
snapshot: payload.snapshot,
|
|
lookupStatsRows: payload.lookupStatsRows,
|
|
};
|
|
const next = await replaceAutomaticBackup(store, record, normaliseOptions(payload.snapshot.options).automaticBackupDays);
|
|
await commitAutomaticBackupStore(current, next);
|
|
return {
|
|
created: true,
|
|
record,
|
|
nextAt: nextAutomaticBackupTime(next, createdAt),
|
|
};
|
|
});
|
|
if (result.linked) return suppressAutomaticBackupsWhileLinked();
|
|
automaticBackupWaitingForState = false;
|
|
await scheduleAutomaticBackup(result.nextAt);
|
|
if (result.created) {
|
|
try {
|
|
const cleanup = await relay({
|
|
target: TARGET,
|
|
type: "hd_backup_auto_cleanup",
|
|
requestId: `automatic-backup-cleanup-${result.record.id}`,
|
|
});
|
|
if (!cleanup?.ok) throw new Error(cleanup?.error || "the dictionary engine refused automatic backup cleanup");
|
|
} catch (error) {
|
|
console.warn("hachidori: automatic backup metadata was committed; deferred generation cleanup failed:", describe(error));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function queueAutomaticBackup(force = false) {
|
|
if (!force && automaticBackupNextAt !== null && Date.now() < automaticBackupNextAt) {
|
|
return automaticBackupRun ?? Promise.resolve();
|
|
}
|
|
if (automaticBackupRun !== null) return automaticBackupRun;
|
|
const run = sharingReady.then(reconcileAutomaticBackups).catch(async (error) => {
|
|
if (error instanceof AutomaticBackupNotReadyError) {
|
|
automaticBackupNextAt = null;
|
|
automaticBackupWaitingForState = true;
|
|
return;
|
|
}
|
|
automaticBackupWaitingForState = false;
|
|
const retryAt = Date.now() + AUTOMATIC_BACKUP_RETRY_MS;
|
|
try { await scheduleAutomaticBackup(retryAt); }
|
|
catch (alarmError) {
|
|
console.warn("hachidori: could not schedule an automatic backup retry:", describe(alarmError));
|
|
}
|
|
console.warn("hachidori: could not create the automatic backup:", describe(error));
|
|
}).finally(() => {
|
|
if (automaticBackupRun === run) automaticBackupRun = null;
|
|
});
|
|
automaticBackupRun = run;
|
|
return run;
|
|
}
|
|
|
|
async function reconcileAutomaticBackupsAfterSharingTransition() {
|
|
const previous = automaticBackupRun;
|
|
if (previous !== null) await previous;
|
|
await queueAutomaticBackup(true);
|
|
}
|
|
|
|
const WORKER_HANDLERS = {
|
|
hd_lookup_stats_record(message) { return lookupStatistics(message, true); },
|
|
hd_lookup_stats_read(message) { return lookupStatistics(message, false); },
|
|
async hd_lookup_stats_cleanup(_message, sender) {
|
|
assertBackupEngineSender(sender);
|
|
const stored = await chrome.storage.local.get(null);
|
|
const descriptor = stored[LOOKUP_STATS_KEY] === undefined ? emptyLookupStats() : stored[LOOKUP_STATS_KEY];
|
|
assertLookupStatsDescriptor(descriptor);
|
|
const prefix = lookupStatsPrefix(descriptor);
|
|
const unused = Object.keys(stored).filter(key => key.startsWith(LOOKUP_STATS_ROW_PREFIX) && !key.startsWith(prefix));
|
|
if (unused.length > 0) await chrome.storage.local.remove(unused);
|
|
return {};
|
|
},
|
|
async hd_backup_download(message, sender) {
|
|
if (sender.id !== chrome.runtime.id || sender.url?.split(/[?#]/u)[0] !== chrome.runtime.getURL("settings.html")) {
|
|
throw new Error("Backup downloads are available only from Hachidori Settings.");
|
|
}
|
|
return getBackupDownloads().download();
|
|
},
|
|
async hd_backup_base_read() {
|
|
const stored = await chrome.storage.local.get([
|
|
DICTIONARY_STATE_KEY, OPTIONS_KEY, CUSTOM_DICTIONARY_SOURCE_KEY, UPDATE_SETTINGS_KEY, LOOKUP_STATS_KEY,
|
|
]);
|
|
return { snapshot: {
|
|
state: stored[DICTIONARY_STATE_KEY] ?? null,
|
|
options: stored[OPTIONS_KEY] ?? null,
|
|
document: stored[CUSTOM_DICTIONARY_SOURCE_KEY] ?? null,
|
|
updates: stored[UPDATE_SETTINGS_KEY] ?? null,
|
|
lookupStats: stored[LOOKUP_STATS_KEY] ?? null,
|
|
} };
|
|
},
|
|
|
|
async hd_backup_read() {
|
|
return readBackupPayload();
|
|
},
|
|
|
|
async hd_backup_auto_list(_message, sender) {
|
|
if (!ankiSettingsSender(sender)) {
|
|
throw new Error("Automatic backups are available only from Hachidori Settings.");
|
|
}
|
|
if (sharingLinked) return { backups: [], corruptCount: 0, linked: true };
|
|
const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
|
|
const { backups, corruptCount } = await validAutomaticBackups(stored);
|
|
return { backups: backups.map(automaticBackupSummary), corruptCount };
|
|
},
|
|
|
|
async hd_backup_auto_get(message, sender) {
|
|
assertBackupEngineSender(sender);
|
|
if (typeof message.id !== "string" || message.id === "") {
|
|
throw new Error("Choose an automatic backup to restore.");
|
|
}
|
|
const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
|
|
const { backups } = await validAutomaticBackups(stored);
|
|
const matches = backups.filter(record => record.id === message.id);
|
|
if (matches.length !== 1) {
|
|
throw new Error("This automatic backup is corrupt or no longer retained.");
|
|
}
|
|
return { backup: matches[0] };
|
|
},
|
|
|
|
async hd_backup_auto_roots(_message, sender) {
|
|
assertBackupEngineSender(sender);
|
|
const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
|
|
const store = automaticBackupStore(stored);
|
|
const { backups, corruptCount } = await validAutomaticBackups(store);
|
|
if (corruptCount > 0 || backups.length !== store.backups.length) {
|
|
return { complete: false, dictionaries: [] };
|
|
}
|
|
return {
|
|
complete: true,
|
|
dictionaries: backups.flatMap(record => record.snapshot.state.dictionaries),
|
|
};
|
|
},
|
|
|
|
async hd_backup_cas(message, sender) {
|
|
assertBackupEngineSender(sender);
|
|
const { snapshot: current } = await WORKER_HANDLERS.hd_backup_base_read();
|
|
if (!sameJsonValue(message.base, current)) {
|
|
return { ok: false, conflict: true, error: "Hachidori changed since this backup was prepared. Prepare it again before restoring." };
|
|
}
|
|
const snapshot = message.snapshot;
|
|
await assertBackupSnapshot(snapshot);
|
|
assertLookupStatsRows(snapshot.lookupStats, message.lookupStatsRows);
|
|
if (snapshot.lookupStats.generation === null || snapshot.lookupStats.generation === current.lookupStats?.generation) {
|
|
throw new Error("A backup restore requires a fresh lookup statistics namespace.");
|
|
}
|
|
const expected = Object.fromEntries(Object.entries(backupRevisions(current)).map(([key, revision]) => [key, revision + 1]));
|
|
if (!sameJsonValue(backupRevisions(snapshot), expected)) throw new Error("Invalid backup restore revisions.");
|
|
if (!sameJsonValue(snapshot.options, normaliseDictionarySelections(snapshot.options, snapshot.state.dictionaries))) {
|
|
throw new Error("The backup reader settings refer to unavailable dictionaries.");
|
|
}
|
|
await writeLocalState({
|
|
[DICTIONARY_STATE_KEY]: snapshot.state,
|
|
[OPTIONS_KEY]: snapshot.options,
|
|
[CUSTOM_DICTIONARY_SOURCE_KEY]: snapshot.document,
|
|
[UPDATE_SETTINGS_KEY]: snapshot.updates,
|
|
[LOOKUP_STATS_KEY]: snapshot.lookupStats,
|
|
...Object.fromEntries(message.lookupStatsRows.map(row => [lookupStatsKey(snapshot.lookupStats, row), row])),
|
|
});
|
|
return { snapshot };
|
|
},
|
|
|
|
async hd_anki_discover(message, sender) {
|
|
if (!ankiSettingsSender(sender)) {
|
|
throw new Error("Anki discovery is available only from Hachidori Settings");
|
|
}
|
|
if (typeof message.model !== "string" || typeof message.apiKey !== "string") {
|
|
throw new TypeError("Anki discovery requires a note type and API key string");
|
|
}
|
|
ankiGateway ??= createAnkiGateway();
|
|
const stored = await chrome.storage.local.get(OPTIONS_KEY);
|
|
const url = message.url === undefined ? normaliseOptions(stored[OPTIONS_KEY]).anki.url : message.url;
|
|
return ankiGateway.discover({ model: message.model, apiKey: message.apiKey, url });
|
|
},
|
|
async hd_anki_setup(message, sender) {
|
|
if (sender.id !== chrome.runtime.id || sender.url?.split(/[?#]/u)[0] !== chrome.runtime.getURL("settings.html")) {
|
|
throw new Error("Anki setup discovery is available only from Hachidori Settings");
|
|
}
|
|
// Settings owns the draft and saves a proposal through its ordinary options
|
|
// CAS. Discovery itself neither changes options nor records onboarding.
|
|
return checkAnkiSetup(validateOptionsPatch({ anki: message.anki }).anki);
|
|
},
|
|
async hd_open_external(message, sender) {
|
|
if (sender.id !== chrome.runtime.id) throw new Error("external link request came from another extension");
|
|
const url = normaliseExternalUrl(message.url);
|
|
if (!url) throw new TypeError("external link URL is invalid");
|
|
const active = message.active === undefined ? true : message.active;
|
|
if (typeof active !== "boolean") throw new TypeError("external link activation is invalid");
|
|
await chrome.tabs.create({ url, active, ...(sender.tab ? { windowId: sender.tab.windowId } : {}) });
|
|
return { opened: true };
|
|
},
|
|
|
|
async hd_state_read(message, sender) {
|
|
const { state, legacyDictionaries } = await readDictionaryStorage(false, stateStore(sender));
|
|
return { state, legacyDictionaries };
|
|
},
|
|
|
|
async hd_state_cas(message, sender) {
|
|
const store = stateStore(sender);
|
|
if (!Number.isInteger(message?.baseRevision) || message.baseRevision < 0) {
|
|
throw new Error("the dictionary state write request carried no valid base revision");
|
|
}
|
|
if (!Array.isArray(message?.dictionaries)) {
|
|
throw new TypeError("the dictionary state write request carried no list");
|
|
}
|
|
if (message.groups !== undefined && !Array.isArray(message.groups)) {
|
|
throw new TypeError("the dictionary state write request carried invalid groups");
|
|
}
|
|
|
|
const { state: current, legacyDictionaries, options: currentOptions } = await readDictionaryStorage(false, store);
|
|
assertDictionaryState(current);
|
|
const currentRevision = current?.revision ?? 0;
|
|
if (message.baseRevision !== currentRevision) {
|
|
return {
|
|
ok: false,
|
|
conflict: true,
|
|
error: "the dictionary state changed while it was being written",
|
|
state: current,
|
|
};
|
|
}
|
|
|
|
try {
|
|
assertOrdinaryCustomTransition(current?.dictionaries ?? [], message.dictionaries);
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
protected: true,
|
|
error: describe(error),
|
|
state: current,
|
|
};
|
|
}
|
|
const { state, values } = dictionaryCommit(
|
|
current,
|
|
currentOptions,
|
|
message.dictionaries,
|
|
message.groups,
|
|
);
|
|
await writeLocalState(values, store);
|
|
await removeLegacyDictionaryRows(current, legacyDictionaries);
|
|
return { state };
|
|
},
|
|
|
|
async hd_custom_read(message, sender) {
|
|
const { state, customDocument } = await readDictionaryStorage(true, stateStore(sender));
|
|
assertDictionaryState(state);
|
|
return {
|
|
document: normaliseCustomDictionaryDocument(customDocument),
|
|
state,
|
|
};
|
|
},
|
|
|
|
async hd_custom_cas(message, sender) {
|
|
const store = stateStore(sender);
|
|
const changesDictionaryState = assertCustomDictionaryCasRequest(message);
|
|
|
|
const {
|
|
state: current,
|
|
legacyDictionaries,
|
|
options: currentOptions,
|
|
customDocument: storedDocument,
|
|
} = await readDictionaryStorage(true, store);
|
|
assertDictionaryState(current);
|
|
const document = normaliseCustomDictionaryDocument(storedDocument);
|
|
if (message.baseDocumentRevision !== document.revision) {
|
|
return {
|
|
ok: false,
|
|
stale: true,
|
|
error: "the custom dictionary source changed while it was being saved",
|
|
document,
|
|
state: current,
|
|
};
|
|
}
|
|
const currentRevision = current?.revision ?? 0;
|
|
if (message.baseRevision !== currentRevision) {
|
|
return {
|
|
ok: false,
|
|
conflict: true,
|
|
error: "the dictionary state changed while the custom dictionary was being saved",
|
|
document,
|
|
state: current,
|
|
};
|
|
}
|
|
const parsed = parseCustomDictionary(message.text);
|
|
const calculatedRevision = await customDictionarySemanticRevision(parsed.entries);
|
|
if (calculatedRevision !== message.semanticRevision) {
|
|
throw new Error("the custom dictionary semantic revision does not match its source");
|
|
}
|
|
assertCustomSourceState(
|
|
changesDictionaryState ? message.dictionaries : current?.dictionaries ?? [],
|
|
calculatedRevision,
|
|
parsed.entries.length,
|
|
);
|
|
|
|
const documentChanged = document.text !== message.text
|
|
|| document.semanticRevision !== message.semanticRevision;
|
|
const nextDocument = documentChanged
|
|
? {
|
|
schemaVersion: CUSTOM_DICTIONARY_SOURCE_SCHEMA_VERSION,
|
|
revision: document.revision + 1,
|
|
semanticRevision: message.semanticRevision,
|
|
text: message.text,
|
|
}
|
|
: document;
|
|
let state = current;
|
|
const values = {};
|
|
if (documentChanged) {
|
|
values[CUSTOM_DICTIONARY_SOURCE_KEY] = nextDocument;
|
|
}
|
|
if (changesDictionaryState) {
|
|
assertCustomDictionaryCommit(message.dictionaries);
|
|
const nextGroups = pruneGroupMemberships(
|
|
message.groups ?? current?.groups,
|
|
message.dictionaries,
|
|
);
|
|
const dictionaryChanged = current === null
|
|
|| !sameJsonValue(current.dictionaries, message.dictionaries)
|
|
|| !sameJsonValue(current.groups, nextGroups);
|
|
if (dictionaryChanged) {
|
|
const commit = dictionaryCommit(
|
|
current,
|
|
currentOptions,
|
|
message.dictionaries,
|
|
nextGroups,
|
|
);
|
|
state = commit.state;
|
|
Object.assign(values, commit.values);
|
|
}
|
|
}
|
|
if (Object.keys(values).length > 0) {
|
|
await writeLocalState(values, store);
|
|
if (state !== current) {
|
|
await removeLegacyDictionaryRows(current, legacyDictionaries);
|
|
}
|
|
}
|
|
return { document: nextDocument, state };
|
|
},
|
|
|
|
async hd_options_write(message) {
|
|
const patch = validateOptionsPatch(message.options);
|
|
const { state, options: currentOptions } = await readDictionaryStorage();
|
|
const result = optionsWriteResult(message, patch, state, currentOptions);
|
|
if (result.ok !== false && result.options.revision !== optionsRevision(currentOptions)) {
|
|
await writeLocalState({ [OPTIONS_KEY]: result.options });
|
|
}
|
|
return result;
|
|
},
|
|
|
|
async hd_setup_cas(message, sender) {
|
|
if (sender.id !== chrome.runtime.id || sender.url?.split(/[?#]/u)[0] !== chrome.runtime.getURL(STARTUP_PAGE)) {
|
|
throw new Error("Setup progress can be changed only from the Hachidori startup page.");
|
|
}
|
|
if (!Number.isInteger(message.baseRevision) || message.baseRevision < 0) {
|
|
throw new Error("the setup write request carried no valid base revision");
|
|
}
|
|
if (message.continued !== undefined && typeof message.continued !== "boolean") {
|
|
throw new Error("the setup write request carried an invalid continuation flag");
|
|
}
|
|
const stored = await chrome.storage.local.get(SETUP_STATE_KEY);
|
|
const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
|
|
if (current === null) throw new Error("Setup has not started on this installation.");
|
|
if (message.baseRevision !== current.revision) {
|
|
return { ok: false, conflict: true, error: "Setup changed in another tab.", state: current };
|
|
}
|
|
const state = advanceSetupState(current, message.stage, new Date().toISOString(), { continued: message.continued === true });
|
|
await writeLocalState({ [SETUP_STATE_KEY]: state });
|
|
return { state };
|
|
},
|
|
|
|
// The offscreen installer reports each dictionary outcome and each run's
|
|
// duration; a committed catalogue entry also settles its first-install
|
|
// selection exactly once.
|
|
// The startup page asks once for Anki and local-audio detection. The Anki
|
|
// outcome is the durable gate, so duplicate startup pages share one run.
|
|
async hd_setup_anki(message, sender) {
|
|
if (!startupSender(sender)) throw new Error("Anki setup is available only from the Hachidori startup page.");
|
|
const stored = await chrome.storage.local.get(SETUP_STATE_KEY);
|
|
const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
|
|
if (current === null) throw new Error("Setup has not started on this installation.");
|
|
if (current.stage === "welcome") throw new Error("Start setup before checking Anki.");
|
|
if (current.anki !== null) return { state: current };
|
|
ankiSetupDetection ??= detectFirstRunAnki().finally(() => { ankiSetupDetection = null; });
|
|
return ankiSetupDetection;
|
|
},
|
|
|
|
async hd_setup_record(message, sender) {
|
|
if (sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL(OFFSCREEN_DOCUMENT)) {
|
|
throw new Error("Setup outcomes are recorded only by the dictionary engine host.");
|
|
}
|
|
const outcomes = message.outcomes ?? {};
|
|
if (!outcomes || typeof outcomes !== "object" || Array.isArray(outcomes)
|
|
|| !Object.keys(outcomes).every((sourceId) => recommendedDictionarySource(sourceId) !== null)) {
|
|
throw new Error("the setup record names an unknown catalogue source");
|
|
}
|
|
const stored = await chrome.storage.local.get([SETUP_STATE_KEY, DICTIONARY_STATE_KEY, OPTIONS_KEY, RECOMMENDED_SELECTIONS_KEY]);
|
|
const store = stateStore(sender);
|
|
const library = store === chrome.storage.local ? stored : await store.get([DICTIONARY_STATE_KEY, OPTIONS_KEY]);
|
|
const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
|
|
const previousSelections = stored[RECOMMENDED_SELECTIONS_KEY] ?? current?.dictionaries.selectionsApplied ?? [];
|
|
const selections = firstInstallSelections(previousSelections, outcomes, library[DICTIONARY_STATE_KEY], library[OPTIONS_KEY]);
|
|
const state = recordSetupDictionaries(message.recordSetup === false ? null : current, {
|
|
runId: message.runId, outcomes, runSeconds: message.runSeconds ?? null, selectionsApplied: selections.applied,
|
|
});
|
|
const values = state === null ? {} : { [SETUP_STATE_KEY]: state };
|
|
if (selections.applied.length > 0) values[RECOMMENDED_SELECTIONS_KEY] = [...new Set([...previousSelections, ...selections.applied])];
|
|
if (selections.options !== null) {
|
|
if (store === chrome.storage.local) values[OPTIONS_KEY] = selections.options;
|
|
else {
|
|
const captured = (await chrome.storage.local.get(SHARING_LOCAL_STATE_KEY))[SHARING_LOCAL_STATE_KEY];
|
|
values[SHARING_LOCAL_STATE_KEY] = { ...captured, options: selections.options };
|
|
}
|
|
}
|
|
if (Object.keys(values).length > 0) await writeLocalState(values);
|
|
return { state };
|
|
},
|
|
};
|
|
|
|
function startupSender(sender) {
|
|
return sender.id === chrome.runtime.id && sender.url?.split(/[?#]/u)[0] === chrome.runtime.getURL(STARTUP_PAGE);
|
|
}
|
|
|
|
function optionsWriteConflict(message, options) {
|
|
return checkedOptionsResult(message, { ok: false, conflict: true,
|
|
error: "Settings changed in another page. Review your changes before saving again.", options });
|
|
}
|
|
|
|
function optionsWriteResult(message, patch, state, storedOptions) {
|
|
if (!Number.isInteger(message.baseRevision) || message.baseRevision < 0) {
|
|
throw new Error("the options write request carried no valid base revision");
|
|
}
|
|
assertDictionaryState(state);
|
|
const revision = optionsRevision(storedOptions);
|
|
const current = { ...projectStoredOptions(storedOptions), revision };
|
|
if (message.baseRevision !== revision) return optionsWriteConflict(message, current);
|
|
const patched = { ...current, ...patch };
|
|
const options = state === null ? patched : normaliseDictionarySelections(patched, state.dictionaries);
|
|
if (!sameJsonValue(options, { ...storedOptions, revision })) options.revision += 1;
|
|
return checkedOptionsResult(message, { options });
|
|
}
|
|
|
|
function localOverlayOptionsValues(options, patch, stored) {
|
|
const changed = options.revision - optionsRevision(stored[OPTIONS_KEY]);
|
|
if (changed === 0) return {};
|
|
const version = stored[SHARING_OPTIONS_VERSION_KEY];
|
|
const values = { [OPTIONS_KEY]: options,
|
|
[SHARING_OPTIONS_VERSION_KEY]: { ...version, offset: version.offset + changed } };
|
|
const captured = stored[SHARING_LOCAL_STATE_KEY];
|
|
if (captured) values[SHARING_LOCAL_STATE_KEY] = { ...captured,
|
|
options: { ...captured.options, ...patch, revision: optionsRevision(captured.options) + 1 } };
|
|
return values;
|
|
}
|
|
|
|
async function prepareOverlayOptionsWrite(message) {
|
|
if (!sharingLinked) return { reply: workerReply(message, await WORKER_HANDLERS.hd_options_write(message)) };
|
|
const patch = validateOptionsPatch(message.options);
|
|
const stored = await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS);
|
|
const result = optionsWriteResult(message, patch, stored[DICTIONARY_STATE_KEY] ?? null, stored[OPTIONS_KEY]);
|
|
if (result.ok === false) return { reply: workerReply(message, result) };
|
|
const local = {}, shared = {};
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
(OVERLAY_LOCAL_OPTION_KEYS.includes(key) ? local : shared)[key] = value;
|
|
}
|
|
if (Object.keys(shared).length === 0) {
|
|
const values = localOverlayOptionsValues(result.options, local, stored);
|
|
if (Object.keys(values).length > 0) await writeLocalState(values);
|
|
return { reply: workerReply(message, result) };
|
|
}
|
|
return { local, shared, version: stored[SHARING_OPTIONS_VERSION_KEY], epoch: sharingEpoch };
|
|
}
|
|
|
|
async function finishOverlayOptionsWrite(message, prepared, reply) {
|
|
const stored = await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS);
|
|
// Link/Unlink and local edits remain available during the network wait. A
|
|
// reply for the former owner must not change the newly selected installation.
|
|
if (!sharingLinked || prepared.epoch !== sharingEpoch) {
|
|
return workerReply(message, optionsWriteConflict(message, stored[OPTIONS_KEY]));
|
|
}
|
|
if (!reply.options) return reply;
|
|
const version = stored[SHARING_OPTIONS_VERSION_KEY];
|
|
const values = overlayHostOptionsValues(reply.options, stored);
|
|
const current = values[OPTIONS_KEY] ?? stored[OPTIONS_KEY];
|
|
let result;
|
|
if (reply.ok !== false && (version.offset !== prepared.version.offset || optionsRevision(reply.options) < version.hostRevision)) {
|
|
result = workerReply(message, optionsWriteConflict(message, current));
|
|
} else {
|
|
let options = current;
|
|
if (reply.ok !== false) {
|
|
options = { ...current, ...prepared.local };
|
|
if (Object.entries(prepared.local).some(([key, value]) => current[key] !== value)) options.revision += 1;
|
|
Object.assign(values, localOverlayOptionsValues(options, prepared.local, { ...stored, ...values }));
|
|
}
|
|
result = checkedOptionsResult(message, { ...reply, options });
|
|
}
|
|
if (Object.keys(values).length > 0) await writeLocalState(values);
|
|
return result;
|
|
}
|
|
|
|
async function writeLinkedOverlayOptions(message) {
|
|
const prepared = await serialiseStorage(() => prepareOverlayOptionsWrite(message));
|
|
if (prepared.reply) return prepared.reply;
|
|
const forwarded = { ...message, options: prepared.shared, baseRevision: prepared.version.hostRevision };
|
|
const reply = await forwardToHost(forwarded, linkedOptionsCapability(forwarded));
|
|
return serialiseStorage(() => finishOverlayOptionsWrite(message, prepared, reply));
|
|
}
|
|
|
|
// Ordinary absence is a connection that never answered; an answer that refused
|
|
// or failed keeps its specific reason.
|
|
function ankiSetupFailure(error) {
|
|
const detail = describe(error);
|
|
const unavailable = /Open Anki with the AnkiConnect add-on|timed out/iu.test(detail);
|
|
return { status: unavailable ? "unavailable" : "needs-attention", detail, model: null, deck: null };
|
|
}
|
|
|
|
// One read-only conversation with Anki: an unconfigured profile is offered a
|
|
// proposal, and a mapping the user already saved is verified the way Settings
|
|
// verifies it, never replaced. Nothing here holds the storage queue.
|
|
async function checkAnkiSetup(anki) {
|
|
ankiGateway ??= createAnkiGateway();
|
|
const invoke = (action, params) => ankiGateway.invoke(action, params, anki.apiKey, undefined, anki.url);
|
|
try {
|
|
const proposal = anki.model === "" ? await detectAnkiSetup(invoke, anki) : await verifyAnkiSetup(invoke, anki);
|
|
return { proposal, outcome: { status: proposal.status, detail: proposal.detail, model: proposal.model, deck: proposal.deck } };
|
|
} catch (error) {
|
|
// Nothing is claimed about a mapping that could not be checked: the
|
|
// connection's own reason is the outcome, and the mapping is left untouched.
|
|
return { proposal: null, outcome: ankiSetupFailure(error) };
|
|
}
|
|
}
|
|
|
|
// The check runs outside the storage queue, so the mapping it judged can change
|
|
// while it runs. Such a check is stale: the write is abandoned and the mapping
|
|
// now stored is checked instead. Only a mapping that stops changing can be
|
|
// recorded, so a user still editing Anki settings gets that reason and the link.
|
|
const ANKI_SETUP_ATTEMPTS = 3;
|
|
const ANKI_SETUP_CHANGED = "Anki settings changed while setup checked them. Confirm the mapping in Settings.";
|
|
|
|
async function detectFirstRunLocalAudio(options) {
|
|
if (sharingLinked || findLocalAudioSource(options.audioSources) !== null) return null;
|
|
try {
|
|
return await detectLocalAudioSource({ fetch: globalThis.fetch });
|
|
} catch {
|
|
// Local audio is optional. Its absence never changes the Anki outcome or
|
|
// interrupts first-run setup.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function detectFirstRunAnki() {
|
|
let localAudioDetection = null;
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
const stored = await chrome.storage.local.get([SETUP_STATE_KEY, OPTIONS_KEY]);
|
|
const options = normaliseOptions(stored[OPTIONS_KEY]);
|
|
const last = attempt >= ANKI_SETUP_ATTEMPTS;
|
|
localAudioDetection ??= detectFirstRunLocalAudio(options);
|
|
const [{ proposal, outcome }, detectedAudio] = await Promise.all([
|
|
checkAnkiSetup(options.anki),
|
|
localAudioDetection,
|
|
]);
|
|
const written = await serialiseStorage(async () => {
|
|
const current = await chrome.storage.local.get([SETUP_STATE_KEY, OPTIONS_KEY]);
|
|
const setup = normaliseSetupState(current[SETUP_STATE_KEY]);
|
|
if (setup === null) throw new Error("Setup has not started on this installation.");
|
|
if (setup.anki !== null) return { state: setup };
|
|
const currentOptions = normaliseOptions(current[OPTIONS_KEY]);
|
|
const stale = !sameJsonValue(currentOptions.anki, options.anki);
|
|
if (stale && !last) return null;
|
|
const values = {};
|
|
const patch = {};
|
|
if (!stale && proposal?.status === "configured") {
|
|
const anki = { ...options.anki, model: proposal.model, deck: proposal.deck, fieldTemplates: proposal.fieldTemplates };
|
|
patch.anki = anki;
|
|
}
|
|
if (detectedAudio !== null && !sharingLinked && findLocalAudioSource(currentOptions.audioSources) === null) {
|
|
patch.audioSources = [createLocalAudioSource(crypto.randomUUID(), detectedAudio), ...currentOptions.audioSources];
|
|
}
|
|
if (Object.keys(patch).length > 0) {
|
|
const revision = optionsRevision(current[OPTIONS_KEY]);
|
|
values[OPTIONS_KEY] = {
|
|
...projectStoredOptions(current[OPTIONS_KEY]),
|
|
...validateOptionsPatch(patch),
|
|
revision: revision + 1,
|
|
};
|
|
}
|
|
const state = recordSetupAnki(setup, stale
|
|
? { status: "needs-attention", detail: ANKI_SETUP_CHANGED, model: null, deck: null }
|
|
: outcome);
|
|
values[SETUP_STATE_KEY] = state;
|
|
await writeLocalState(values);
|
|
return { state };
|
|
});
|
|
if (written !== null) return written;
|
|
}
|
|
}
|
|
|
|
// Dictionary-dependent initial preferences follow the committed entry's exact
|
|
// title, whether setup installed it or found it installed. Each is consumed
|
|
// once; an option the user already changed is left alone.
|
|
function firstInstallSelections(previousSelections, outcomes, dictionaryState, storedOptions) {
|
|
const dictionaries = dictionaryState?.dictionaries ?? [];
|
|
const effective = normaliseOptions(storedOptions);
|
|
const applied = [];
|
|
const patch = {};
|
|
for (const [sourceId, rule] of Object.entries(FIRST_INSTALL_SELECTIONS)) {
|
|
if (!["installed", "already-installed"].includes(outcomes[sourceId]?.status)
|
|
|| previousSelections.includes(sourceId)) continue;
|
|
// The same catalogue identity the installer uses, so a package carried in
|
|
// or imported by hand, which is recognised by its exact update index, is
|
|
// the entry the selection follows.
|
|
const source = recommendedDictionarySource(sourceId);
|
|
const committed = source === null ? null : installedRecommendedDictionary(source, dictionaries);
|
|
if (committed === null) continue;
|
|
applied.push(sourceId);
|
|
if (effective[rule.option] === "") patch[rule.option] = rule.select(committed.title);
|
|
}
|
|
if (Object.keys(patch).length === 0) return { applied, options: null };
|
|
const revision = optionsRevision(storedOptions);
|
|
const options = normaliseDictionarySelections(
|
|
{ ...projectStoredOptions(storedOptions), ...validateOptionsPatch(patch), revision }, dictionaries,
|
|
);
|
|
return { applied, options: sameJsonValue(options, { ...storedOptions, revision }) ? null : { ...options, revision: revision + 1 } };
|
|
}
|
|
|
|
// One read-then-write at a time, so the check above cannot be overtaken by
|
|
// another worker-mediated write between its get and its set.
|
|
let storageTail = Promise.resolve();
|
|
|
|
function serialiseStorage(job) {
|
|
const run = storageTail.then(job, job);
|
|
storageTail = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return run;
|
|
}
|
|
|
|
async function writeUpdateSettings(update) {
|
|
return serialiseStorage(async () => {
|
|
const current = await readUpdateSettings();
|
|
const next = update(current);
|
|
if (next === null) return { ok: false, error: "The update settings changed elsewhere. Review the current schedule before retrying.", settings: current };
|
|
if (sameJsonValue(next, current)) return { settings: current };
|
|
const settings = { ...next, revision: current.revision + 1 };
|
|
await writeLocalState({ [UPDATE_SETTINGS_KEY]: settings });
|
|
return { settings };
|
|
});
|
|
}
|
|
|
|
async function updateDictionaryCheck(fingerprint, lastUpdateCheck) {
|
|
return serialiseStorage(async () => {
|
|
const { state } = await readDictionaryStorage();
|
|
const index = state?.dictionaries?.findIndex(
|
|
(dictionary) => dictionary?.id === fingerprint.id,
|
|
) ?? -1;
|
|
if (index < 0 || !managedDictionaryMatches(state.dictionaries[index], fingerprint)) {
|
|
return null;
|
|
}
|
|
const dictionaries = [...state.dictionaries];
|
|
dictionaries[index] = { ...dictionaries[index], lastUpdateCheck };
|
|
const reply = await WORKER_HANDLERS.hd_state_cas({
|
|
baseRevision: state.revision,
|
|
dictionaries,
|
|
});
|
|
if (reply.ok === false) {
|
|
throw new Error(reply.error || "the dictionary update state could not be saved");
|
|
}
|
|
return reply.state.dictionaries.find((dictionary) => dictionary?.id === fingerprint.id) ?? null;
|
|
});
|
|
}
|
|
|
|
async function managedCandidates(dictionaryIds) {
|
|
const selected = dictionaryIds === null ? null : new Set(dictionaryIds);
|
|
const { state } = await serialiseStorage(readDictionaryStorage);
|
|
return (state?.dictionaries ?? []).flatMap((dictionary) => {
|
|
if (selected !== null && !selected.has(dictionary?.id)) {
|
|
return [];
|
|
}
|
|
const fingerprint = managedDictionaryFingerprint(dictionary);
|
|
return fingerprint === null ? [] : [{
|
|
id: dictionary.id,
|
|
title: dictionary.displayName || dictionary.title,
|
|
fingerprint,
|
|
}];
|
|
});
|
|
}
|
|
|
|
async function remoteUpdate(candidate) {
|
|
const { source } = candidate.fingerprint;
|
|
const response = await fetch(source.indexUrl, { credentials: "omit" });
|
|
if (!response.ok) {
|
|
throw new Error(`update index request failed with HTTP ${response.status}`);
|
|
}
|
|
if (source.kind === "recommended"
|
|
&& !recommendedIndexUrlMatches(recommendedDictionarySource(source.sourceId), response.url)) {
|
|
throw new Error("update index downloaded from an unexpected final URL");
|
|
}
|
|
if (source.kind === "generic" && httpsUrl(response.url) === null) {
|
|
throw new Error("update index redirected to a non-HTTPS URL");
|
|
}
|
|
const index = await response.json();
|
|
if (typeof index?.revision !== "string" || index.revision === "") {
|
|
throw new Error("update index did not declare a revision");
|
|
}
|
|
let archiveUrl = source.downloadUrl;
|
|
if (source.kind === "generic"
|
|
&& typeof index.downloadUrl === "string"
|
|
&& index.downloadUrl !== "") {
|
|
archiveUrl = httpsUrl(index.downloadUrl);
|
|
if (archiveUrl === null) {
|
|
throw new Error("update index returned a non-HTTPS download URL");
|
|
}
|
|
}
|
|
return { revision: index.revision, archiveUrl };
|
|
}
|
|
|
|
let updateRequestCounter = 0;
|
|
|
|
async function installManagedCandidate(candidate, update, checkedAt) {
|
|
updateRequestCounter += 1;
|
|
const { fingerprint } = candidate;
|
|
const reply = await relay({
|
|
target: TARGET,
|
|
type: "hd_import",
|
|
requestId: `managed-update-${updateRequestCounter}`,
|
|
managedFingerprint: fingerprint,
|
|
sourceId: fingerprint.source.kind === "recommended" ? fingerprint.source.sourceId : null,
|
|
archiveUrl: update.archiveUrl,
|
|
expectedRevision: update.revision,
|
|
checkedAt,
|
|
fileName: candidate.title,
|
|
});
|
|
if (!reply?.ok || !reply.report?.success) {
|
|
throw new Error(reply?.error || reply?.report?.error || "the dictionary update failed");
|
|
}
|
|
}
|
|
|
|
function changedManagedOutcome(candidate) {
|
|
return {
|
|
id: candidate.id,
|
|
status: "check-failed",
|
|
error: MANAGED_DICTIONARY_CHANGED,
|
|
};
|
|
}
|
|
|
|
async function recordManagedOutcome(candidate, lastUpdateCheck, outcome) {
|
|
const recorded = await updateDictionaryCheck(candidate.fingerprint, lastUpdateCheck);
|
|
return recorded === null ? changedManagedOutcome(candidate) : outcome;
|
|
}
|
|
|
|
async function checkManagedCandidate(candidate, checkedAt) {
|
|
let update;
|
|
try {
|
|
update = await remoteUpdate(candidate);
|
|
} catch (error) {
|
|
const message = describe(error);
|
|
return {
|
|
update: null,
|
|
available: null,
|
|
outcome: await recordManagedOutcome(
|
|
candidate,
|
|
{
|
|
checkedAt,
|
|
status: "check-failed",
|
|
remoteRevision: null,
|
|
error: message,
|
|
},
|
|
{ id: candidate.id, status: "check-failed", error: message },
|
|
),
|
|
};
|
|
}
|
|
|
|
if (update.revision === candidate.fingerprint.revision) {
|
|
const outcome = await recordManagedOutcome(
|
|
candidate,
|
|
{
|
|
checkedAt,
|
|
status: "up-to-date",
|
|
remoteRevision: update.revision,
|
|
error: null,
|
|
},
|
|
{ id: candidate.id, status: "up-to-date" },
|
|
);
|
|
return { update: null, available: null, outcome };
|
|
}
|
|
|
|
const available = {
|
|
checkedAt,
|
|
status: "update-available",
|
|
remoteRevision: update.revision,
|
|
error: null,
|
|
};
|
|
const outcome = await recordManagedOutcome(
|
|
candidate,
|
|
available,
|
|
{ id: candidate.id, status: "update-available" },
|
|
);
|
|
return {
|
|
update: outcome.status === "update-available" ? update : null,
|
|
available,
|
|
outcome,
|
|
};
|
|
}
|
|
|
|
async function installCheckedCandidate(candidate, checked, checkedAt) {
|
|
if (checked.update === null) {
|
|
return checked.outcome;
|
|
}
|
|
try {
|
|
await installManagedCandidate(candidate, checked.update, checkedAt);
|
|
return { id: candidate.id, status: "updated" };
|
|
} catch (error) {
|
|
let message = describe(error);
|
|
const failed = await updateDictionaryCheck(
|
|
candidate.fingerprint,
|
|
{ ...checked.available, error: message },
|
|
);
|
|
if (failed === null) message = MANAGED_DICTIONARY_CHANGED;
|
|
return {
|
|
id: candidate.id,
|
|
status: failed === null ? "check-failed" : "update-available",
|
|
error: message,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function readUpdatePlan() {
|
|
const stored = await chrome.storage.local.get([DICTIONARY_STATE_KEY, UPDATE_SETTINGS_KEY]);
|
|
return { dictionaries: stored[DICTIONARY_STATE_KEY]?.dictionaries ?? [],
|
|
settings: normaliseUpdateSettings(stored[UPDATE_SETTINGS_KEY]) };
|
|
}
|
|
|
|
async function scheduledCandidateIsDue(candidate) {
|
|
const { dictionaries, settings } = await serialiseStorage(readUpdatePlan);
|
|
const current = dictionaries.find(dictionary => dictionary.id === candidate.id);
|
|
if (!current || !managedDictionaryMatches(current, candidate.fingerprint)) return false;
|
|
const now = Date.now();
|
|
const due = nextDictionaryUpdateCheck(current, settings.schedule, now);
|
|
return due !== null && due <= now;
|
|
}
|
|
|
|
async function runManagedUpdateCycle({ dictionaryIds = null, install = false, dueOnly = false } = {}) {
|
|
const candidates = await managedCandidates(dictionaryIds);
|
|
const outcomes = [];
|
|
|
|
for (const candidate of candidates) {
|
|
// A later package can be switched Off while an earlier fetch is in flight.
|
|
if (dueOnly && !await scheduledCandidateIsDue(candidate)) continue;
|
|
const checkedAt = new Date().toISOString();
|
|
const checked = await checkManagedCandidate(candidate, checkedAt);
|
|
outcomes.push(install
|
|
? await installCheckedCandidate(candidate, checked, checkedAt)
|
|
: checked.outcome);
|
|
}
|
|
|
|
if (dueOnly && outcomes.length === 0) return { outcomes, settings: await readUpdateSettings() };
|
|
const { settings } = await writeUpdateSettings((current) => ({
|
|
...current,
|
|
lastCheckedAt: new Date().toISOString(),
|
|
}));
|
|
return { outcomes, settings };
|
|
}
|
|
|
|
let updateTail = Promise.resolve();
|
|
let updateCycleActive = false;
|
|
|
|
function queueManagedUpdate(options) {
|
|
const execute = async () => {
|
|
updateCycleActive = true;
|
|
try { return await runManagedUpdateCycle(options); }
|
|
finally {
|
|
updateCycleActive = false;
|
|
await refreshUpdateAlarm();
|
|
}
|
|
};
|
|
const run = updateTail.then(execute, execute);
|
|
updateTail = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return run;
|
|
}
|
|
|
|
let alarmTail = Promise.resolve();
|
|
|
|
function reconcileUpdateAlarm() {
|
|
const run = alarmTail.then(async () => {
|
|
await sharingReady;
|
|
if (sharingLinked) {
|
|
await alarms.clear(UPDATE_ALARM);
|
|
return;
|
|
}
|
|
if (updateCycleActive) return;
|
|
const { dictionaries, settings } = await serialiseStorage(readUpdatePlan);
|
|
const now = Date.now();
|
|
const when = nextManagedUpdateCheck(dictionaries, settings.schedule, now);
|
|
const existing = await alarms.get(UPDATE_ALARM);
|
|
if (updateCycleActive) return;
|
|
if (when === null) {
|
|
if (existing) await alarms.clear(UPDATE_ALARM);
|
|
return;
|
|
}
|
|
if (existing && existing.periodInMinutes === undefined
|
|
&& (existing.scheduledTime === when || (when === now && existing.scheduledTime <= now))) {
|
|
return;
|
|
}
|
|
await alarms.create(UPDATE_ALARM, { when });
|
|
});
|
|
alarmTail = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return run;
|
|
}
|
|
|
|
function refreshUpdateAlarm() {
|
|
return reconcileUpdateAlarm().catch(error => {
|
|
console.error("hoshidicts: could not reconcile the dictionary update alarm:", describe(error));
|
|
});
|
|
}
|
|
|
|
function updateTiming(dictionaries = []) {
|
|
return dictionaries.map(dictionary => [managedDictionarySource(dictionary) !== null,
|
|
dictionary.updateScheduleOverride ?? null, dictionary.lastUpdateCheck?.checkedAt ?? null]);
|
|
}
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area !== "local" || !changes[DICTIONARY_STATE_KEY]) return;
|
|
sharingHost?.setDictionaries(dictionaryCount(changes[DICTIONARY_STATE_KEY].newValue));
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area !== "local" || updateCycleActive) return;
|
|
const state = changes[DICTIONARY_STATE_KEY];
|
|
// Update-settings writers reconcile explicitly after releasing the storage queue.
|
|
if (state && !sameJsonValue(
|
|
updateTiming(state.oldValue?.dictionaries), updateTiming(state.newValue?.dictionaries),
|
|
)) void refreshUpdateAlarm();
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (area !== "local" || !changes[OPTIONS_KEY]) return;
|
|
void reconcileAnkiIndex();
|
|
void applyCustomJavaScript(chrome, normaliseOptions(changes[OPTIONS_KEY].newValue).customPopupJavascript);
|
|
});
|
|
|
|
async function applyAnkiIndexRole() {
|
|
const index = getAnkiDuplicateIndex();
|
|
if (sharingLinked && !OVERLAY_MODE) await index.suspend();
|
|
else await index.resume();
|
|
}
|
|
|
|
async function reconcileAnkiIndex() {
|
|
await sharingReady;
|
|
// A link suspends the old role before publishing the new one. An options
|
|
// event or alarm in that interval must not resume local Anki behind it.
|
|
await sharingTransitionTail;
|
|
await applyAnkiIndexRole();
|
|
}
|
|
|
|
const UPDATE_HANDLERS = {
|
|
async hd_updates_schedule(message) {
|
|
const schedule = managedUpdateSchedule(message?.schedule);
|
|
if (schedule === null) {
|
|
throw new Error("the dictionary update schedule is invalid");
|
|
}
|
|
const result = await writeUpdateSettings(current =>
|
|
message.baseRevision === current.revision ? { ...current, schedule } : null);
|
|
if (result.ok !== false) await reconcileUpdateAlarm();
|
|
return result;
|
|
},
|
|
|
|
async hd_updates_check() {
|
|
return queueManagedUpdate({ install: false });
|
|
},
|
|
|
|
async hd_updates_install(message) {
|
|
if (!Array.isArray(message?.dictionaryIds)) {
|
|
throw new TypeError("the dictionary update request carried no dictionary IDs");
|
|
}
|
|
return queueManagedUpdate({ dictionaryIds: message.dictionaryIds, install: true });
|
|
},
|
|
};
|
|
|
|
function workerReply(message, result) {
|
|
const { ok = true, error = null, ...payload } = result ?? {};
|
|
return { type: `${message.type}_result`, requestId: message.requestId ?? null, ok, error, ...payload };
|
|
}
|
|
|
|
function checkedOptionsResult(message, result) {
|
|
if (!responseFits(workerReply(message, result))) throw new Error(responseLimitError(message.type));
|
|
return result;
|
|
}
|
|
|
|
function failureReply(message, error) {
|
|
const description = describe(error);
|
|
let errorCode = typeof error?.code === "string" ? error.code : null;
|
|
if (errorCode === null && description === NOT_REACHABLE) {
|
|
errorCode = "sharing-disconnected";
|
|
}
|
|
return boundResponseFailure({
|
|
type: `${message?.type ?? "hd_unknown"}_result`,
|
|
requestId: message?.requestId ?? null,
|
|
ok: false,
|
|
error: description,
|
|
generation: 0,
|
|
...(errorCode === null ? {} : { errorCode }),
|
|
...(error?.outcomeUnknown === true ? { outcomeUnknown: true } : {}),
|
|
});
|
|
}
|
|
|
|
const ANKI_METHODS = { hd_anki_status: "status", hd_anki_view: "view", hd_anki_preflight: "preflight", hd_anki_submit: "submit",
|
|
hd_anki_browse: "browse", hd_anki_screenshot: "screenshot", hd_anki_screenshot_discard: "discardScreenshot",
|
|
hd_anki_maturity: "maturity" };
|
|
|
|
// Chrome rate-limits viewport captures, so a second mining action in the same
|
|
// second waits once rather than losing its screenshot.
|
|
const CAPTURE_VISIBLE_RETRY_MS = 600;
|
|
|
|
// Startup messages can include or omit sender.tab. Chrome's live extension
|
|
// contexts bind either shape to the same document; Firefox supplies the tab on
|
|
// the extension-page sender and has no getContexts equivalent.
|
|
async function screenshotOwnedTab(sender, startup) {
|
|
let tabId = sender.tab?.id;
|
|
if (startup && typeof chrome.runtime.getContexts === "function") {
|
|
const [context] = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentIds: [sender.documentId] });
|
|
if (!context) throw new Error("The reading document changed before the screenshot.");
|
|
tabId = context.tabId;
|
|
}
|
|
const tab = await chrome.tabs.get(tabId);
|
|
if (tab?.active !== true) throw new Error("The reading tab is no longer the active tab.");
|
|
// Tabs hides extension-page URLs; startup's exact live document was checked above.
|
|
if (!startup && (sender.frameId ?? 0) === 0 && tab.url !== sender.url) {
|
|
throw new Error("The reading tab moved to another page before the screenshot.");
|
|
}
|
|
if (!startup) {
|
|
// Address the exact content-script document, so a same-URL reload cannot
|
|
// answer on its predecessor's behalf.
|
|
const document = await chrome.tabs.sendMessage(tabId, {
|
|
target: "hachidori-anki-content", type: "hd_anki_document",
|
|
}, { documentId: sender.documentId }).catch(() => null);
|
|
if (document?.present !== true) throw new Error("The reading document changed before the screenshot.");
|
|
}
|
|
return tab;
|
|
}
|
|
|
|
// captureVisibleTab takes the window's active tab. Both the active page and its
|
|
// document owner are checked around every attempt, including a rate-limit retry.
|
|
async function captureSenderViewport(sender) {
|
|
const startup = startupSender(sender);
|
|
if (typeof sender.tab?.id !== "number" && !startup) {
|
|
throw new Error("Only a reading tab can be captured.");
|
|
}
|
|
if (!sender.documentId) throw new Error("The reading document identity is unavailable.");
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
const tab = await screenshotOwnedTab(sender, startup);
|
|
let captured;
|
|
try {
|
|
captured = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "jpeg" });
|
|
} catch (error) {
|
|
if (attempt >= 2 || !/per second|too many|MAX_CAPTURE/iu.test(describe(error))) throw error;
|
|
await sleep(CAPTURE_VISIBLE_RETRY_MS);
|
|
continue;
|
|
}
|
|
// Capturing stays bound to this window even if the active reading tab is
|
|
// dragged to another one before the pixels return.
|
|
const afterCapture = await screenshotOwnedTab(sender, startup);
|
|
if (afterCapture.windowId !== tab.windowId) {
|
|
throw new Error("The reading tab moved to another window during the screenshot.");
|
|
}
|
|
return captured;
|
|
}
|
|
}
|
|
|
|
const CAPTURE_CONTROL_TYPES = new Set([
|
|
"hd_capture_open",
|
|
"hd_capture_tabs",
|
|
"hd_capture_link",
|
|
"hd_capture_unlink",
|
|
"hd_capture_video_select",
|
|
"hd_capture_track_area",
|
|
"hd_capture_clear_area",
|
|
"hd_capture_status",
|
|
"hd_capture_start",
|
|
"hd_capture_stop",
|
|
]);
|
|
const CAPTURE_CONTENT_TYPES = new Set([
|
|
"hd_capture_content_identify",
|
|
"hd_capture_text_begin",
|
|
"hd_capture_text_close",
|
|
"hd_capture_text_source_close",
|
|
"hd_capture_page_status",
|
|
"hd_capture_pin",
|
|
"hd_capture_release",
|
|
"hd_capture_export",
|
|
"hd_capture_job_status",
|
|
"hd_capture_cancel",
|
|
]);
|
|
|
|
let captureConfigRevision = 0;
|
|
let captureConfigTail = Promise.resolve();
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
if (!HOST_CAPABILITIES.mediaCapture || area !== "local" || !changes[OPTIONS_KEY]) return;
|
|
const previous = globalThis.HDReaderOptions.normaliseOptions(changes[OPTIONS_KEY].oldValue).mediaCapture;
|
|
const mediaCapture = globalThis.HDReaderOptions.normaliseOptions(changes[OPTIONS_KEY].newValue).mediaCapture;
|
|
if (sameJsonValue(previous, mediaCapture)) return;
|
|
const revision = ++captureConfigRevision;
|
|
const apply = () => relayCapture({ type: "hd_capture_configure", mediaCapture },
|
|
() => revision === captureConfigRevision);
|
|
captureConfigTail = captureConfigTail.then(apply, apply).catch(error => {
|
|
console.error("hachidori: could not update media capture settings:", describe(error));
|
|
});
|
|
});
|
|
|
|
function assertCurrentCaptureLink(link, status = null) {
|
|
if (captureLink !== link || (status && (status.state !== "recording"
|
|
|| status.captureSessionId !== link.captureSessionId))) {
|
|
throw new Error("The capture session changed before the reading page was linked.");
|
|
}
|
|
}
|
|
|
|
async function linkCapturePage(message) {
|
|
const link = { tabId: message.tabId, captureSessionId: null, document: null };
|
|
captureLink = link;
|
|
let captureDocumentId;
|
|
try {
|
|
const status = await relayCapture({ type: "hd_capture_status" });
|
|
assertCurrentCaptureLink(link);
|
|
if (status.state !== "recording" || !status.captureSessionId) {
|
|
await unlinkCaptureContent();
|
|
throw new Error("Start capture before linking a reading page.");
|
|
}
|
|
link.captureSessionId = status.captureSessionId;
|
|
captureDocumentId = capturePage.documentId;
|
|
if (status.linkedPage) {
|
|
await relayCapture({ type: "hd_capture_unlinked", ...status.linkedPage,
|
|
reason: "Linking a reading page." }, () => captureLink === link);
|
|
assertCurrentCaptureLink(link);
|
|
}
|
|
await unlinkCaptureContent();
|
|
const stored = await chrome.storage.local.get(OPTIONS_KEY);
|
|
assertCurrentCaptureLink(link);
|
|
const mediaCapture = globalThis.HDReaderOptions.projectContentOptions(stored[OPTIONS_KEY]).mediaCapture;
|
|
const details = await commandCaptureContent(message.tabId, "hd_capture_link", {
|
|
mediaCapture, captureSessionId: link.captureSessionId,
|
|
});
|
|
const document = link.document;
|
|
if (!document?.documentId) {
|
|
throw new Error("The linked page did not establish a document identity.");
|
|
}
|
|
const tab = await chrome.tabs.get(message.tabId);
|
|
assertCurrentCaptureLink(link);
|
|
const page = {
|
|
tabId: message.tabId,
|
|
documentId: document.documentId,
|
|
title: String(tab.title || "").slice(0, 200),
|
|
url: String(tab.url || "").slice(0, 2048),
|
|
videos: Array.isArray(details?.videos) ? details.videos : [],
|
|
message: details?.message || "",
|
|
};
|
|
await relayCapture({ type: "hd_capture_linked", requestId: message.requestId,
|
|
captureSessionId: link.captureSessionId, page });
|
|
return { page };
|
|
} catch (error) {
|
|
if (link.document) {
|
|
await unlinkRetiredCapture({ captureDocumentId, captureSessionId: link.captureSessionId,
|
|
linkedPage: link.document }, captureDocumentId, link);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (captureLink === link) captureLink = null;
|
|
}
|
|
}
|
|
|
|
function sameCapturePage(page, expected) {
|
|
return page?.tabId === expected.tabId && page.documentId === expected.documentId;
|
|
}
|
|
|
|
function replacementCaptureLink(link) {
|
|
if (captureLink && captureLink !== link && captureLink.tabId === link.tabId) return true;
|
|
return captureContentDocument !== link.document && sameCapturePage(captureContentDocument, link.document);
|
|
}
|
|
|
|
async function unlinkRetiredCapture(message, documentId, retiringLink = null) {
|
|
if (message.captureDocumentId !== documentId) return;
|
|
const page = message.linkedPage;
|
|
assertCaptureTabId(page?.tabId);
|
|
if (!shortCaptureString(page.documentId) || !shortCaptureString(message.captureSessionId)) {
|
|
throw new Error("The retired capture identity is invalid.");
|
|
}
|
|
// A source-ended event can wake a fresh worker before routing has recovered.
|
|
// Check the live host without publishing a partly recovered capturePage.
|
|
const status = await chrome.runtime.sendMessage({
|
|
target: CAPTURE_PAGE_TARGET, type: "hd_capture_status", relayed: true, captureDocumentId: documentId,
|
|
}).catch(() => null);
|
|
if (retiringLink && replacementCaptureLink(retiringLink)) return;
|
|
if (status?.captureSessionId && status.captureSessionId !== message.captureSessionId
|
|
&& sameCapturePage(status.linkedPage, page)) return;
|
|
await chrome.tabs.sendMessage(page.tabId, {
|
|
target: CAPTURE_CONTENT_TARGET, type: "hd_capture_unlink",
|
|
}, { documentId: page.documentId }).catch(() => {});
|
|
if (sameCapturePage(captureContentDocument, page)) captureContentDocument = null;
|
|
}
|
|
|
|
async function handleCaptureHostMessage(message, sender) {
|
|
if (!capturePageSender(sender)) throw new Error("Only the offscreen document can register or stop the capture host.");
|
|
const contexts = await chrome.runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"],
|
|
documentUrls: [chrome.runtime.getURL(OFFSCREEN_DOCUMENT)] });
|
|
const documentId = contexts.length === 1 ? contexts[0].documentId : null;
|
|
if (!documentId) throw new Error("The capture host document is unavailable.");
|
|
if (message.type === "hd_capture_host_stopped") {
|
|
await unlinkRetiredCapture(message, documentId);
|
|
return { stopped: true };
|
|
}
|
|
capturePage = { documentId };
|
|
await recoverCaptureBinding(message.linkedPage);
|
|
const stored = await chrome.storage.local.get(OPTIONS_KEY);
|
|
return { documentId,
|
|
mediaCapture: globalThis.HDReaderOptions.normaliseOptions(stored[OPTIONS_KEY]).mediaCapture };
|
|
}
|
|
|
|
// A newly created tab can be reopened before its TAB context is published.
|
|
let captureControlsTabId = null;
|
|
let captureControlsOpening = null;
|
|
|
|
function openCaptureControls() {
|
|
captureControlsOpening ??= focusCaptureControls().finally(() => { captureControlsOpening = null; });
|
|
return captureControlsOpening;
|
|
}
|
|
|
|
async function focusCaptureControls() {
|
|
if (captureControlsTabId === null) {
|
|
const [existing] = await chrome.runtime.getContexts({ contextTypes: ["TAB"],
|
|
documentUrls: [chrome.runtime.getURL(CAPTURE_DOCUMENT)] });
|
|
captureControlsTabId = existing?.tabId ?? null;
|
|
}
|
|
if (captureControlsTabId !== null) {
|
|
try {
|
|
const tab = await chrome.tabs.update(captureControlsTabId, { active: true });
|
|
if (Number.isInteger(tab.windowId)) await chrome.windows.update(tab.windowId, { focused: true });
|
|
return { tabId: tab.id };
|
|
} catch { /* A closed controls tab can be reopened. */ }
|
|
}
|
|
const tab = await chrome.tabs.create({ url: chrome.runtime.getURL(CAPTURE_DOCUMENT), active: true });
|
|
captureControlsTabId = tab.id;
|
|
return { tabId: tab.id };
|
|
}
|
|
|
|
async function handleCaptureControl(message, sender) {
|
|
if (["hd_capture_register", "hd_capture_host_stopped"].includes(message.type)) {
|
|
return handleCaptureHostMessage(message, sender);
|
|
}
|
|
if (!CAPTURE_CONTROL_TYPES.has(message.type) || !trustedCaptureControl(sender)) {
|
|
throw new Error("Unknown or untrusted capture control request.");
|
|
}
|
|
if (message.type === "hd_capture_open") return openCaptureControls();
|
|
if (message.type === "hd_capture_tabs") return { tabs: await captureTabs() };
|
|
if (["hd_capture_status", "hd_capture_start", "hd_capture_stop"].includes(message.type)) return relayCapture(message);
|
|
assertCaptureTabId(message.tabId);
|
|
if (message.type === "hd_capture_link") {
|
|
return linkCapturePage(message);
|
|
}
|
|
if (message.type === "hd_capture_unlink") {
|
|
const result = await commandCaptureContent(message.tabId, "hd_capture_unlink", {});
|
|
if (captureContentDocument?.tabId === message.tabId) captureContentDocument = null;
|
|
return result;
|
|
}
|
|
const command = {
|
|
hd_capture_video_select: ["hd_capture_video_select", { videoId: message.videoId }],
|
|
hd_capture_track_area: ["hd_capture_track_area", {}],
|
|
hd_capture_clear_area: ["hd_capture_clear_area", {}],
|
|
}[message.type];
|
|
return commandCaptureContent(message.tabId, command[0], command[1]);
|
|
}
|
|
|
|
function authoritativeCaptureRecord(message, sender) {
|
|
const record = message.record;
|
|
if (!record || !["cue", "dom"].includes(record.sourceKind)
|
|
|| !shortCaptureString(record.sourceEpoch) || !shortCaptureString(record.occurrenceId)
|
|
|| typeof record.text !== "string" || record.text.length === 0 || record.text.length > 4096
|
|
|| !finiteCaptureTime(record.startMs)) throw new Error("The reading page sent an invalid text timing record.");
|
|
return {
|
|
sourceKind: record.sourceKind,
|
|
sourceId: captureDocumentKey(sender.tab.id),
|
|
sourceEpoch: `${sender.documentId}:${record.sourceEpoch}`,
|
|
occurrenceId: record.occurrenceId,
|
|
text: record.text,
|
|
startMs: record.startMs,
|
|
onsetKnown: record.onsetKnown !== false,
|
|
};
|
|
}
|
|
|
|
function authoritativeCaptureIdentity(message, sender) {
|
|
const identity = message.identity;
|
|
if (!identity || !["cue", "dom"].includes(identity.sourceKind)
|
|
|| !shortCaptureString(identity.sourceEpoch) || !shortCaptureString(identity.occurrenceId)
|
|
|| !finiteCaptureTime(message.endMs)) throw new Error("The reading page sent an invalid text close record.");
|
|
return {
|
|
sourceKind: identity.sourceKind,
|
|
sourceId: captureDocumentKey(sender.tab.id),
|
|
sourceEpoch: `${sender.documentId}:${identity.sourceEpoch}`,
|
|
occurrenceId: identity.occurrenceId,
|
|
};
|
|
}
|
|
|
|
async function handleCaptureContent(message, sender) {
|
|
if (!CAPTURE_CONTENT_TYPES.has(message.type) || sender.id !== chrome.runtime.id
|
|
|| !Number.isInteger(sender.tab?.id) || sender.frameId !== 0
|
|
|| typeof sender.documentId !== "string" || sender.documentId === "") {
|
|
throw new Error("Unknown or untrusted reading-page capture request.");
|
|
}
|
|
if (message.type === "hd_capture_content_identify") {
|
|
const link = captureLink;
|
|
if (link?.tabId !== sender.tab.id || link.captureSessionId !== message.captureSessionId) {
|
|
throw new Error("This reading page is not being linked to the capture session.");
|
|
}
|
|
const status = await relayCapture({ type: "hd_capture_status" });
|
|
assertCurrentCaptureLink(link, status);
|
|
link.document = { tabId: sender.tab.id, documentId: sender.documentId };
|
|
captureContentDocument = link.document;
|
|
return { documentId: sender.documentId, tabId: sender.tab.id };
|
|
}
|
|
if (!capturePage || captureRecovery) await recoverCaptureHost();
|
|
// Admitted exports outlive reader relinking. The offscreen job retains the
|
|
// original document and checks these authoritative sender fields itself.
|
|
if (["hd_capture_job_status", "hd_capture_cancel"].includes(message.type)) {
|
|
return relayCaptureContent(message, sender);
|
|
}
|
|
const document = captureContentDocument;
|
|
if (document?.tabId !== sender.tab.id || document.documentId !== sender.documentId) {
|
|
throw new Error("This reading document is not linked to the capture session.");
|
|
}
|
|
return relayCaptureContent(message, sender);
|
|
}
|
|
|
|
function assertCaptureLookup(lookup) {
|
|
if (!lookup || typeof lookup.lookupText !== "string" || lookup.lookupText.length === 0
|
|
|| lookup.lookupText.length > 4096 || !finiteCaptureTime(lookup.lookupTimeMs)
|
|
|| (lookup.occurrenceId !== "" && !shortCaptureString(lookup.occurrenceId))
|
|
|| !["", "dom", "cue"].includes(lookup.occurrenceSourceKind ?? "")) {
|
|
throw new Error("The reading page sent an invalid lookup capture request.");
|
|
}
|
|
}
|
|
|
|
function assertCaptureExport(message) {
|
|
if (!shortCaptureString(message.token)
|
|
|| !message.requirements || typeof message.requirements !== "object"
|
|
|| typeof message.requirements.includeAnimation !== "boolean"
|
|
|| typeof message.requirements.includeAudio !== "boolean"
|
|
|| (!message.requirements.includeAnimation && !message.requirements.includeAudio)) {
|
|
throw new Error("The capture export request is invalid.");
|
|
}
|
|
}
|
|
|
|
async function relayCaptureContent(message, sender) {
|
|
const authority = { tabId: sender.tab.id, documentId: sender.documentId };
|
|
if (message.type === "hd_capture_text_begin") {
|
|
return relayCapture({ ...message, ...authority, record: authoritativeCaptureRecord(message, sender) });
|
|
}
|
|
if (message.type === "hd_capture_text_close") {
|
|
return relayCapture({ ...message, ...authority, identity: authoritativeCaptureIdentity(message, sender) });
|
|
}
|
|
if (message.type === "hd_capture_text_source_close") {
|
|
if (!["cue", "dom"].includes(message.sourceKind) || !shortCaptureString(message.sourceEpoch)
|
|
|| !finiteCaptureTime(message.endMs)) throw new Error("The reading page sent an invalid source close.");
|
|
return relayCapture({
|
|
...message,
|
|
...authority,
|
|
sourceId: captureDocumentKey(sender.tab.id),
|
|
sourceEpoch: `${sender.documentId}:${message.sourceEpoch}`,
|
|
});
|
|
}
|
|
if (message.type === "hd_capture_pin") {
|
|
const lookup = message.lookup;
|
|
assertCaptureLookup(lookup);
|
|
return relayCapture({ ...message, ...authority,
|
|
lookup: { ...lookup, occurrenceId: lookup.occurrenceId || "",
|
|
occurrenceSourceKind: lookup.occurrenceSourceKind || "" } });
|
|
}
|
|
if (message.type === "hd_capture_release") {
|
|
if (!shortCaptureString(message.token)) throw new Error("The capture release token is invalid.");
|
|
return relayCapture({ ...message, ...authority });
|
|
}
|
|
if (message.type === "hd_capture_export") {
|
|
assertCaptureExport(message);
|
|
return relayCapture({
|
|
...message,
|
|
...authority,
|
|
requirements: {
|
|
includeAnimation: message.requirements.includeAnimation,
|
|
includeAudio: message.requirements.includeAudio,
|
|
},
|
|
});
|
|
}
|
|
if (message.type === "hd_capture_job_status" || message.type === "hd_capture_cancel") {
|
|
if (!shortCaptureString(message.jobId)) throw new Error("The capture export job is invalid.");
|
|
return relayCapture({ ...message, ...authority });
|
|
}
|
|
if (message.type === "hd_capture_page_status") {
|
|
return relayCapture({ ...message, ...authority });
|
|
}
|
|
throw new Error("Unknown reading-page capture request.");
|
|
}
|
|
|
|
function clearNavigatedCaptureDocument(tabId, reason) {
|
|
const document = captureContentDocument;
|
|
if (document?.tabId !== tabId) return;
|
|
captureContentDocument = null;
|
|
void relayCapture({
|
|
type: "hd_capture_unlinked",
|
|
requestId: `capture-navigation-${crypto.randomUUID()}`,
|
|
tabId,
|
|
documentId: document.documentId,
|
|
reason,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
chrome.tabs?.onUpdated?.addListener((tabId, changeInfo) => {
|
|
if (changeInfo.status === "loading") {
|
|
if (tabId === captureControlsTabId) captureControlsTabId = null;
|
|
clearNavigatedCaptureDocument(tabId, "The reading page navigated. Link it again.");
|
|
}
|
|
});
|
|
chrome.tabs?.onRemoved?.addListener(tabId => {
|
|
if (tabId === captureControlsTabId) captureControlsTabId = null;
|
|
clearNavigatedCaptureDocument(tabId, "The linked reading tab was closed.");
|
|
});
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== CAPTURE_TARGET || message.relayed === true) return false;
|
|
let operation;
|
|
if (!HOST_CAPABILITIES.mediaCapture) {
|
|
operation = Promise.reject(new Error(
|
|
IS_FIREFOX ? "Media capture is unavailable in Firefox."
|
|
: "Media capture is unavailable in this overlay.",
|
|
));
|
|
} else if (["hd_capture_register", "hd_capture_host_stopped"].includes(message.type)
|
|
|| CAPTURE_CONTROL_TYPES.has(message.type)) {
|
|
operation = handleCaptureControl(message, sender);
|
|
} else {
|
|
operation = handleCaptureContent(message, sender);
|
|
}
|
|
Promise.resolve(operation).then(
|
|
result => sendResponse(workerReply(message, result)),
|
|
error => sendResponse(failureReply(message, error)),
|
|
);
|
|
return true;
|
|
});
|
|
|
|
// Anki owns its own mutation queue. Discovery, DOM rendering and network I/O
|
|
// must never hold the dictionary storage queue while the engine calls into it.
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== "hachidori-anki") return false;
|
|
handleAnkiRequest(message, sender).then(sendResponse);
|
|
return true;
|
|
});
|
|
|
|
async function handleAnkiRequest(message, sender) {
|
|
await sharingReady;
|
|
// Linking waits for operations admitted under the old role. Requests which
|
|
// arrive during that transition wait too, so none can read one browser's
|
|
// configuration and finish after routing has moved to another.
|
|
await sharingTransitionTail;
|
|
return trackAnkiOperation(async () => {
|
|
if (sharingLinked && !OVERLAY_MODE) {
|
|
// The reading browser alone can capture or discard its viewport bytes.
|
|
if (["hd_anki_screenshot", "hd_anki_screenshot_discard"].includes(message.type)) {
|
|
return answerAnkiRequest(message, sender);
|
|
}
|
|
// Mature-word evidence has always belonged to the host, including hosts
|
|
// from before linked mining advertised a capability.
|
|
if (message.type === "hd_anki_maturity") return forwardToHost(message);
|
|
if (message.type === "hd_anki_submit") return submitToLinkedAnki(message);
|
|
if (["hd_anki_status", "hd_anki_view", "hd_anki_preflight", "hd_anki_browse"].includes(message.type)) {
|
|
try {
|
|
const reply = await getSharingClient().forward(message, { capability: LINKED_ANKI_CAPABILITY });
|
|
if (message.type === "hd_anki_preflight" && reply?.ok !== false && reply?.clientSpeech) {
|
|
await getAnkiMining().preflightClientSpeech({
|
|
...message.request,
|
|
clientSpeech: reply.clientSpeech,
|
|
});
|
|
}
|
|
return reply;
|
|
} catch (error) {
|
|
if (message.type === "hd_anki_status" && describe(error) === LINKED_ANKI_UNSUPPORTED) {
|
|
return workerReply(message, { available: false, configKey: "", error: LINKED_ANKI_UNSUPPORTED });
|
|
}
|
|
return failureReply(message, error);
|
|
}
|
|
}
|
|
}
|
|
return answerAnkiRequest(message, sender);
|
|
});
|
|
}
|
|
|
|
async function sendAnkiRequest(target, fields) {
|
|
const message = { ...fields, target, requestId: `anki-${crypto.randomUUID()}` };
|
|
const reply = target === TARGET ? await relayEngineRequest(message) : await relay(message);
|
|
if (!reply?.ok) throw new Error(reply?.error || "Anki preparation did not complete.");
|
|
return reply;
|
|
}
|
|
|
|
function getAnkiMining() {
|
|
if (!ankiMining) {
|
|
const send = sendAnkiRequest;
|
|
ankiGateway ??= createAnkiGateway();
|
|
ankiMining = createAnkiWorkerService({ gateway: ankiGateway,
|
|
readOptions: readAnkiOptions,
|
|
duplicateIndex: getAnkiDuplicateIndex(),
|
|
readDictionaries: async () => (await readDictionaryStorage()).state?.dictionaries ?? [],
|
|
engine: fields => send(TARGET, fields), offscreen: fields => send("hachidori-anki-render", fields),
|
|
capture: fields => relayCapture({ ...fields, requestId: `anki-capture-${crypto.randomUUID()}` }),
|
|
});
|
|
}
|
|
return ankiMining;
|
|
}
|
|
|
|
async function submitToLinkedAnki(message) {
|
|
const local = getAnkiMining();
|
|
let clientMedia;
|
|
try {
|
|
clientMedia = await local.clientMedia(message.request);
|
|
} catch (error) {
|
|
return failureReply(message, error);
|
|
}
|
|
let sent = false;
|
|
let reply;
|
|
try {
|
|
reply = await getSharingClient().forward({ ...message, clientMedia }, {
|
|
capability: LINKED_ANKI_CAPABILITY,
|
|
mutation: true,
|
|
onSent: () => { sent = true; },
|
|
});
|
|
} catch (error) {
|
|
if (!sent) return failureReply(message, error);
|
|
return workerReply(message, {
|
|
state: "uncertain",
|
|
error: `The write could not be confirmed. Check Anki before trying again. ${describe(error)}`,
|
|
});
|
|
}
|
|
const states = ["added", "updated", "duplicate", "invalid", "uncertain"];
|
|
if (!reply || reply.type !== `${message.type}_result` || reply.requestId !== message.requestId
|
|
|| typeof reply.ok !== "boolean" || (reply.ok === true && !states.includes(reply.state))) {
|
|
return workerReply(message, {
|
|
state: "uncertain",
|
|
error: "The write could not be confirmed. Check Anki before trying again. The linked Hachidori returned an unexpected response.",
|
|
});
|
|
}
|
|
const settlement = reply.ok === false ? "invalid"
|
|
: ["added", "updated", "duplicate", "invalid"].includes(reply.state) ? reply.state : null;
|
|
if (settlement !== null) {
|
|
try {
|
|
await local.settleClientMedia(message.request, settlement);
|
|
} catch (error) {
|
|
if (["added", "updated"].includes(settlement)) {
|
|
reply = { ...reply, warnings: [...(Array.isArray(reply.warnings) ? reply.warnings : []),
|
|
`Captured media cleanup: ${describe(error)}`] };
|
|
} else {
|
|
console.warn("hachidori: could not discard rejected linked media:", describe(error));
|
|
}
|
|
}
|
|
}
|
|
return reply;
|
|
}
|
|
|
|
function answerAnkiRequest(message, sender, linkedClient = false) {
|
|
return Promise.resolve().then(async () => {
|
|
if (sender.id !== chrome.runtime.id || !Object.hasOwn(ANKI_METHODS, message.type)) throw new Error("Unknown Anki request.");
|
|
const service = getAnkiMining();
|
|
// Only the screenshot needs to know which page asked, and it is given the
|
|
// capture rather than the sender, so nothing else can capture a tab.
|
|
if (message.type === "hd_anki_screenshot") {
|
|
return service.screenshot(() => captureSenderViewport(sender), message.templateId);
|
|
}
|
|
if (linkedClient && message.type === "hd_anki_status") {
|
|
const status = await service.status(message.templateId);
|
|
return { ...status, configKey: linkedAnkiConfigKey(status.configKey) };
|
|
}
|
|
if (linkedClient && message.type === "hd_anki_view") {
|
|
const result = await service.view(message.request);
|
|
return { ...result, configKey: linkedAnkiConfigKey(result.configKey) };
|
|
}
|
|
if (linkedClient && message.type === "hd_anki_preflight") {
|
|
return service.preflightClient(hostLinkedAnkiRequest(message.request));
|
|
}
|
|
if (linkedClient && message.type === "hd_anki_submit") {
|
|
return service.submitClient(hostLinkedAnkiRequest(message.request), message.clientMedia);
|
|
}
|
|
if (linkedClient && message.type === "hd_anki_browse") {
|
|
return service.browse(hostLinkedAnkiRequest(message.request));
|
|
}
|
|
if (message.type === "hd_anki_status") return service.status(message.templateId);
|
|
return service[ANKI_METHODS[message.type]](message.type === "hd_anki_browse"
|
|
? message.request ?? message.expression : message.request);
|
|
}).then(result => workerReply(message, result), error => failureReply(message, error));
|
|
}
|
|
|
|
const backupPreparations = new Map();
|
|
let backupCancelTail = Promise.resolve();
|
|
|
|
async function relayEngineRequest(message) {
|
|
await sharingReady;
|
|
if (sharingLinked && forwardableRequest(message)) return forwardToHost(message);
|
|
if (message.type === "hd_backup_cancel") {
|
|
const preparation = backupPreparations.get(message.token);
|
|
if (preparation) preparation.cancelled = true;
|
|
// Retire startup/retries now, then let an already-dispatched prepare finish
|
|
// before cancellation reaches the engine. Otherwise Chrome 128 can deliver
|
|
// pagehide while prepare is awaiting a storage reply: the early cancel sees
|
|
// no prepared token, then prepare publishes fresh roots with nobody left to
|
|
// discard them. Admit only one cleanup request at a time.
|
|
const cancel = async () => {
|
|
if (preparation) await preparation.settled;
|
|
return relay(message);
|
|
};
|
|
const cancelled = backupCancelTail.then(cancel, cancel);
|
|
backupCancelTail = cancelled.catch(() => {});
|
|
return cancelled;
|
|
}
|
|
if (!["hd_backup_prepare", "hd_backup_auto_prepare"].includes(message.type)) return relay(message);
|
|
let settlePreparation;
|
|
const preparation = {
|
|
cancelled: false,
|
|
settled: new Promise(resolve => { settlePreparation = resolve; }),
|
|
};
|
|
backupPreparations.set(message.token, preparation);
|
|
try {
|
|
return await relay(message, () => !preparation.cancelled);
|
|
} finally {
|
|
settlePreparation();
|
|
if (backupPreparations.get(message.token) === preparation) backupPreparations.delete(message.token);
|
|
}
|
|
}
|
|
|
|
function validBackupPreparationToken(token) {
|
|
return typeof token === "string" && token.length > 0 && token.length <= 128;
|
|
}
|
|
|
|
async function cancelOwnedBackupPreparation(token) {
|
|
const reply = await relayEngineRequest({
|
|
target: TARGET,
|
|
type: "hd_backup_cancel",
|
|
token,
|
|
requestId: `backup-lifecycle-${crypto.randomUUID()}`,
|
|
});
|
|
if (!reply?.ok) throw new Error(reply?.error || "The abandoned backup preparation could not be discarded.");
|
|
return reply;
|
|
}
|
|
|
|
chrome.runtime.onConnect.addListener((port) => {
|
|
if (port.name !== BACKUP_LIFECYCLE_PORT) return;
|
|
if (!ankiSettingsSender(port.sender)) {
|
|
port.disconnect();
|
|
return;
|
|
}
|
|
const owned = new Set();
|
|
port.onMessage.addListener((message) => {
|
|
if (!validBackupPreparationToken(message?.token)) return;
|
|
if (message.type === "track" && typeof message.active === "boolean") {
|
|
if (message.active) owned.add(message.token);
|
|
else owned.delete(message.token);
|
|
return;
|
|
}
|
|
if (message.type === "cancel" && owned.has(message.token)) {
|
|
void cancelOwnedBackupPreparation(message.token).then(
|
|
() => owned.delete(message.token),
|
|
error => console.warn("hachidori: could not discard an abandoned backup preparation:", describe(error)),
|
|
);
|
|
}
|
|
});
|
|
port.onDisconnect.addListener(() => {
|
|
const abandoned = [...owned];
|
|
owned.clear();
|
|
for (const token of abandoned) {
|
|
void cancelOwnedBackupPreparation(token).catch(
|
|
error => console.warn("hachidori: could not discard an abandoned backup preparation:", describe(error)),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// The reader's popup cancels browser zoom, which only extension APIs report.
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== PAGE_ZOOM_TARGET) return false;
|
|
const tabId = sender.tab?.id;
|
|
if (sender.id !== chrome.runtime.id || message.type !== "hd_page_zoom" || !Number.isInteger(tabId)) {
|
|
sendResponse(failureReply(message, new Error("Unknown page zoom request.")));
|
|
return false;
|
|
}
|
|
chrome.tabs.getZoom(tabId).then((zoomFactor) => sendResponse(workerReply(message, { zoomFactor })),
|
|
(error) => sendResponse(failureReply(message, error)));
|
|
return true;
|
|
});
|
|
|
|
// Startup and Settings attach to one recommended-install run. The welcome gate
|
|
// belongs to startup; opening Settings never requires an onboarding record.
|
|
async function handleRecommendedInstall(message, sender, shared = false) {
|
|
const startup = startupSender(sender);
|
|
const settings = sender?.id === chrome.runtime.id
|
|
&& sender.url?.split(/[?#]/u)[0] === chrome.runtime.getURL("settings.html");
|
|
if (!shared && !startup && !settings) throw new Error("Recommended installation is available only from Hachidori startup or Settings.");
|
|
if (message.type !== "hd_setup_install") throw new Error("Unknown recommended installation request.");
|
|
if (startup) {
|
|
const stored = await chrome.storage.local.get(SETUP_STATE_KEY);
|
|
const current = normaliseSetupState(stored[SETUP_STATE_KEY]);
|
|
if (current === null) throw new Error("Setup has not started on this installation.");
|
|
if (current.stage === "welcome") throw new Error("Start setup before downloading dictionaries.");
|
|
}
|
|
// Never hold the storage queue here: each engine commit calls back into it.
|
|
await sharingReady;
|
|
return sharingLinked ? forwardToHost(message) : relay({ ...message, recordSetup: startup });
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== SETUP_TARGET || message.relayed === true) return false;
|
|
handleRecommendedInstall(message, sender).then(sendResponse, (error) => sendResponse(failureReply(message, error)));
|
|
return true;
|
|
});
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (!message || (message.target !== TARGET && message.target !== AUDIO_TARGET) || message.relayed === true) {
|
|
return false;
|
|
}
|
|
let stillCurrent = null;
|
|
let operation = null;
|
|
if (message.target === AUDIO_TARGET) {
|
|
try {
|
|
if (!["hd_audio_test", "hd_audio_play", "hd_audio_candidates", "hd_audio_stop", "hd_audio_voices"].includes(message.type)) throw new Error("Unknown audio request.");
|
|
validateAudioRequest(message);
|
|
// Chrome supplies the document ID, so an old Settings tab cannot stop a
|
|
// pronunciation subsequently started by a different document.
|
|
message = { ...message, owner: sender.documentId };
|
|
if (["hd_audio_test", "hd_audio_play", "hd_audio_candidates"].includes(message.type)) {
|
|
operation = { ...message, tabId: sender.tab?.id, startup: startupSender(sender) };
|
|
latestAudioOperation = operation;
|
|
stillCurrent = () => latestAudioOperation === operation;
|
|
} else if (message.type === "hd_audio_stop"
|
|
&& latestAudioOperation?.owner === message.owner && latestAudioOperation?.requestId === message.playRequestId) {
|
|
// Retire it before awaiting offscreen startup. Otherwise its relay
|
|
// retry could start playback after this Stop has already completed.
|
|
latestAudioOperation = null;
|
|
}
|
|
} catch (error) {
|
|
sendResponse(failureReply(message, error));
|
|
return false;
|
|
}
|
|
}
|
|
const response = message.target === AUDIO_TARGET
|
|
? prepareAudioRequest(message).then(prepared => relay(prepared, stillCurrent)) : relayEngineRequest(message);
|
|
response.then(sendResponse, error => {
|
|
sendResponse(failureReply(message, error));
|
|
}).finally(() => { if (operation && latestAudioOperation === operation) latestAudioOperation = null; });
|
|
return true;
|
|
});
|
|
|
|
function validateAudioRequest(message) {
|
|
if (message.type === "hd_audio_test") {
|
|
globalThis.HDReaderOptions.validateOptionsPatch({ audioSources: [message.source] });
|
|
return;
|
|
}
|
|
if (message.type !== "hd_audio_play" && message.type !== "hd_audio_candidates") return;
|
|
if (typeof message.term?.expression !== "string" || !message.term.expression
|
|
|| typeof message.term.reading !== "string") throw new Error("A pronunciation needs an expression and reading.");
|
|
const choice = message.selection;
|
|
if (choice !== undefined && (!choice || !Number.isInteger(choice.index) || choice.index < 0
|
|
|| !["sourceId", "sourceKey", "expression", "reading", "name"].every(key => typeof choice[key] === "string")
|
|
|| (choice.url !== null && typeof choice.url !== "string"))) throw new Error("Invalid pronunciation selection.");
|
|
}
|
|
|
|
async function prepareAudioRequest(message) {
|
|
if (message.type !== "hd_audio_play" && message.type !== "hd_audio_candidates") return message;
|
|
const stored = await chrome.storage.local.get(OPTIONS_KEY);
|
|
const options = globalThis.HDReaderOptions.normaliseOptions(stored[OPTIONS_KEY]);
|
|
return { ...message, sources: options.audioSources.filter(source => source.enabled) };
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== "hachidori-audio-events") return false;
|
|
const operation = latestAudioOperation;
|
|
if (sender.id !== chrome.runtime.id || sender.url !== chrome.runtime.getURL(OFFSCREEN_DOCUMENT)
|
|
|| !operation || (!operation.startup && operation.tabId === undefined) || operation.owner !== message.owner
|
|
|| operation.requestId !== message.requestId || message.type !== "hd_audio_playing") return false;
|
|
const progress = { ...message, target: "hachidori-audio-content" };
|
|
// The packaged startup reader lives in an extension page, outside the
|
|
// content-script audience of tabs.sendMessage. Its controller accepts only
|
|
// its active random request ID; the worker already checked the document owner.
|
|
const delivery = operation.startup ? chrome.runtime.sendMessage(progress)
|
|
: chrome.tabs.sendMessage(operation.tabId, progress, { documentId: operation.owner });
|
|
delivery.then(() => sendResponse({ ok: true }), () => sendResponse({ ok: false }));
|
|
return true;
|
|
});
|
|
|
|
// Never relay(): a WORKER_TARGET request must be answered here, or the engine's
|
|
// storage reads would re-enter the offscreen document.
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (!message || message.target !== WORKER_TARGET) {
|
|
return false;
|
|
}
|
|
handleWorkerRequest(message, sender).then(sendResponse);
|
|
return true;
|
|
});
|
|
|
|
async function handleWorkerRequest(message, sender) {
|
|
const type = typeof message.type === "string" ? message.type : "";
|
|
if (!Object.prototype.hasOwnProperty.call(WORKER_HANDLERS, type)) {
|
|
return failureReply(message, new Error(`unknown worker request type ${JSON.stringify(type)}`));
|
|
}
|
|
if (type === "hd_backup_download" && typeof chrome.downloads?.download !== "function") {
|
|
return failureReply(message, new Error("Browser downloads are unavailable. Export the backup from Hachidori Settings."));
|
|
}
|
|
if (type === "hd_open_external" && HOST_CAPABILITIES.externalLinkHost) {
|
|
return failureReply(message, new Error(
|
|
"Custom toolbar links open only from lookup popups in this overlay; the Settings preview cannot launch them.",
|
|
));
|
|
}
|
|
await sharingReady;
|
|
if (["hd_anki_discover", "hd_anki_setup", "hd_setup_anki"].includes(type)) await sharingTransitionTail;
|
|
if (sharingLinked && !OVERLAY_MODE && ["hd_anki_discover", "hd_anki_setup"].includes(type)) {
|
|
try {
|
|
if (!ankiSettingsSender(sender)) {
|
|
throw new Error(`${type === "hd_anki_setup" ? "Anki setup discovery" : "Anki discovery"} is available only from Hachidori Settings`);
|
|
}
|
|
const allowed = type === "hd_anki_setup"
|
|
? allowLinkedAnkiSetupRequest(message) : allowLinkedAnkiDiscoveryRequest(message);
|
|
return await getSharingClient().forward(allowed, { capability: LINKED_ANKI_CAPABILITY });
|
|
} catch (error) {
|
|
return failureReply(message, error);
|
|
}
|
|
}
|
|
// The host owns the lookup-count rows a linked engine would otherwise prune.
|
|
if (sharingLinked && type === "hd_lookup_stats_cleanup") return workerReply(message, {});
|
|
if (type === "hd_options_write") {
|
|
let error = null;
|
|
if (!validResponseRequestId(message.requestId ?? null)) {
|
|
error = "the options write request carried an invalid request ID";
|
|
} else if (!responseFits(message)) {
|
|
error = responseLimitError(type);
|
|
}
|
|
if (error !== null) {
|
|
return failureReply(message, error);
|
|
}
|
|
}
|
|
const invoke = async () => WORKER_HANDLERS[type](await compatibleLinkedWorkerMessage(message, sender), sender);
|
|
if (sharingLinked && !engineSender(sender) && WORKER_FORWARDS.has(type)) {
|
|
return forwardWorkerRequest(message).catch(error => failureReply(message, error));
|
|
}
|
|
// Navigation and read-only Anki discovery must not hold up storage commits.
|
|
const run = () => [
|
|
"hd_open_external", "hd_anki_discover", "hd_anki_setup", "hd_setup_anki", "hd_backup_download",
|
|
"hd_lookup_stats_record", "hd_lookup_stats_read",
|
|
].includes(type) ? invoke() : serialiseStorage(invoke);
|
|
const operation = ["hd_anki_discover", "hd_anki_setup", "hd_setup_anki"].includes(type)
|
|
? trackAnkiOperation(run) : run();
|
|
return operation.then(
|
|
async (result) => {
|
|
if (type === "hd_backup_cas" && result.ok !== false) {
|
|
try { await reconcileUpdateAlarm(); }
|
|
catch (error) { result.warning = `Restored successfully; update alarm could not be refreshed: ${describe(error)}`; }
|
|
}
|
|
return workerReply(message, result);
|
|
},
|
|
(error) => failureReply(message, error),
|
|
);
|
|
}
|
|
|
|
// Update cycles relay imports back through the engine, which calls into the
|
|
// storage handlers above while committing. Keep this listener outside
|
|
// serialiseStorage() so the engine can complete that callback.
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== UPDATE_TARGET) {
|
|
return false;
|
|
}
|
|
handleUpdatesRequest(message).then(sendResponse);
|
|
return true;
|
|
});
|
|
|
|
async function handleUpdatesRequest(message) {
|
|
const type = typeof message.type === "string" ? message.type : "";
|
|
if (!Object.hasOwn(UPDATE_HANDLERS, type)) {
|
|
return failureReply(message, new Error(`unknown update request type ${JSON.stringify(type)}`));
|
|
}
|
|
await sharingReady;
|
|
if (sharingLinked) return forwardToHost(message);
|
|
return Promise.resolve().then(() => UPDATE_HANDLERS[type](message)).then(
|
|
(result) => {
|
|
const { ok = true, error = null, ...payload } = result ?? {};
|
|
return { type: `${type}_result`, requestId: message.requestId ?? null, ok, error, ...payload };
|
|
},
|
|
(error) => failureReply(message, error),
|
|
);
|
|
}
|
|
|
|
// A browser linked through the sharing bridge sends ordinary runtime messages;
|
|
// each one is answered by the handler for its target, as if a page sent it.
|
|
const SHARING_TARGET = "hachidori-sharing";
|
|
|
|
async function dispatchSharedRequest(message, clientId, capabilities = []) {
|
|
const sender = {
|
|
id: chrome.runtime.id,
|
|
url: `hachidori-sharing://client/${clientId}`,
|
|
linkedCapabilities: capabilities,
|
|
};
|
|
const ordinary = () => {
|
|
if (!forwardableRequest(message)) {
|
|
throw new Error(`unsupported shared request ${JSON.stringify(message.target)} ${JSON.stringify(message.type)}`);
|
|
}
|
|
};
|
|
try {
|
|
switch (message.target) {
|
|
case TARGET:
|
|
if (API_REQUESTS.has(message.type)) return await getApiHost()(message);
|
|
ordinary();
|
|
return await relayEngineRequest(message);
|
|
case WORKER_TARGET:
|
|
if (message.type === "hd_anki_discover") {
|
|
const allowed = allowLinkedAnkiDiscoveryRequest(message);
|
|
ankiGateway ??= createAnkiGateway();
|
|
const options = await readAnkiOptions();
|
|
return workerReply(allowed, await trackAnkiOperation(
|
|
() => ankiGateway.discover({ ...options.anki, model: allowed.model }),
|
|
));
|
|
}
|
|
if (message.type === "hd_anki_setup") {
|
|
const allowed = allowLinkedAnkiSetupRequest(message);
|
|
const options = await readAnkiOptions();
|
|
const config = ankiTemplateConfig(options.anki, allowed.templateId);
|
|
if (config === null) throw new Error("The selected Anki Template is no longer available.");
|
|
return workerReply(allowed, await trackAnkiOperation(() => checkAnkiSetup(config)));
|
|
}
|
|
ordinary();
|
|
return await handleWorkerRequest(message, sender);
|
|
case UPDATE_TARGET:
|
|
ordinary();
|
|
return await handleUpdatesRequest(message);
|
|
case SETUP_TARGET:
|
|
ordinary();
|
|
return await handleRecommendedInstall(message, sender, true);
|
|
case "hachidori-anki": return await trackAnkiOperation(
|
|
() => answerAnkiRequest(allowLinkedAnkiRequest(message), sender, true),
|
|
);
|
|
default: throw new Error(`unsupported shared request target ${JSON.stringify(message.target)}`);
|
|
}
|
|
} catch (error) {
|
|
return failureReply(message, error);
|
|
}
|
|
}
|
|
|
|
async function writeSharingConfig(patch) {
|
|
await serialiseStorage(async () => {
|
|
const stored = await chrome.storage.local.get(SHARING_KEY);
|
|
await writeLocalState({ [SHARING_KEY]: { ...stored[SHARING_KEY], ...patch } });
|
|
});
|
|
}
|
|
|
|
// An empty address means this computer, on the port set under Advanced.
|
|
function linkTarget(text) {
|
|
const trimmed = String(text ?? "").trim();
|
|
return parseLinkAddress(trimmed === "" ? `127.0.0.1:${getSharingHost().status().port}` : trimmed);
|
|
}
|
|
|
|
// Own the whole user action, including its probe, independently of storage.
|
|
// Network waits must leave the storage queue free for engine callbacks and
|
|
// local edits, and a failed action must not block the next Settings tab.
|
|
function serialiseSharingTransition(job) {
|
|
const run = sharingTransitionTail.then(() => sharingReady).then(job);
|
|
sharingTransitionTail = run.catch(() => {});
|
|
return run;
|
|
}
|
|
|
|
const SHARING_HANDLERS = {
|
|
hd_sharing_status() {
|
|
return { sharing: sharingStatus() };
|
|
},
|
|
async hd_sharing_client_probe(message) {
|
|
const { address, display } = linkTarget(message.address);
|
|
const hello = await getSharingClient().probe(address);
|
|
return { address, display, host: { version: hello.version, name: hello.name, dictionaryCount: hello.dictionaryCount } };
|
|
},
|
|
// A linked install has nothing of its own to share, and the relay holds one
|
|
// host, so this install stops hosting before it looks for the other one;
|
|
// linking to its own address then finds nothing. The install's own shared
|
|
// values are kept aside for unlinking, then the host's snapshot takes their
|
|
// place under the live keys.
|
|
async hd_sharing_client_link(message) {
|
|
const { address } = linkTarget(message.address);
|
|
const config = (await serialiseStorage(() => chrome.storage.local.get(SHARING_KEY)))[SHARING_KEY];
|
|
if (config?.client?.address === address) return { sharing: sharingStatus() };
|
|
const host = getSharingHost();
|
|
const hosting = host.status();
|
|
let suspendedIndex = false;
|
|
if (hosting.enabled) host.disable();
|
|
try {
|
|
const hello = await getSharingClient().probe(address);
|
|
await waitForAnkiIdle();
|
|
await getAnkiDuplicateIndex().suspend();
|
|
suspendedIndex = true;
|
|
await serialiseStorage(async () => {
|
|
const stored = await chrome.storage.local.get([...SHARED_STATE_KEYS, SHARING_KEY]);
|
|
if (!sameJsonValue(stored[SHARING_KEY], config)) {
|
|
throw new Error("Sharing changed while linking. Try again.");
|
|
}
|
|
const values = {
|
|
[SHARING_KEY]: { ...config, host: { ...config?.host, enabled: false }, client: { address } },
|
|
};
|
|
// Switching hosts keeps the original local state too. Only an install
|
|
// that is currently unlinked may capture the live keys as local data.
|
|
if (!config?.client?.address) {
|
|
values[SHARING_LOCAL_STATE_KEY] = Object.fromEntries(SHARED_STATE_KEYS.map(key => [key, stored[key] ?? null]));
|
|
}
|
|
if (OVERLAY_MODE) values[SHARING_OPTIONS_VERSION_KEY] = null;
|
|
await chrome.storage.local.set(values);
|
|
// Publish routing at the confirmed commit, before another storage job
|
|
// can let the local engine see (or clean up against) the host inventory.
|
|
sharingLinked = true;
|
|
sharingEpoch += 1;
|
|
getSharingClient().link(address);
|
|
await applyMirror(hello.snapshot, true);
|
|
});
|
|
} catch (error) {
|
|
if (suspendedIndex && !sharingLinked) await getAnkiDuplicateIndex().resume();
|
|
if (hosting.enabled && !sharingLinked) host.enable({ port: hosting.port, network: hosting.network.enabled });
|
|
throw error;
|
|
}
|
|
await reconcileUpdateAlarm();
|
|
await reconcileAutomaticBackupsAfterSharingTransition();
|
|
await applyAnkiIndexRole();
|
|
return { sharing: sharingStatus() };
|
|
},
|
|
// Restored values outrank the mirror in every reader's revision comparison,
|
|
// and the host's lookup-count rows leave with it.
|
|
async hd_sharing_client_unlink() {
|
|
// Finish any request admitted under the linked route before restoring the
|
|
// local route. In particular, do not let media exported for one host be
|
|
// sent to local Anki or abandoned merely because Unlink won a race.
|
|
await waitForAnkiIdle();
|
|
await serialiseStorage(async () => {
|
|
const stored = await chrome.storage.local.get(null);
|
|
// client:null is the durable completion marker. A repeated Unlink must
|
|
// not restore an old snapshot even if its final cleanup failed.
|
|
if (!stored[SHARING_KEY]?.client?.address) return;
|
|
const captured = stored[SHARING_LOCAL_STATE_KEY];
|
|
if (captured) {
|
|
const values = {};
|
|
const removals = [];
|
|
for (const key of SHARED_STATE_KEYS) {
|
|
const local = captured[key];
|
|
if (local === null || local === undefined) {
|
|
if (stored[key] !== undefined) removals.push(key);
|
|
continue;
|
|
}
|
|
values[key] = { ...local, revision: Math.max(optionsRevision(local), optionsRevision(stored[key])) + 1 };
|
|
}
|
|
const prefix = lookupStatsPrefix(values[LOOKUP_STATS_KEY] ?? emptyLookupStats());
|
|
removals.push(...Object.keys(stored).filter(key => key.startsWith(LOOKUP_STATS_ROW_PREFIX) && !key.startsWith(prefix)));
|
|
await writeLocalState(values);
|
|
if (removals.length > 0) await chrome.storage.local.remove(removals);
|
|
}
|
|
// An absent snapshot never authorizes deleting live user data. Keep
|
|
// both the snapshot and linked routing until restoration has succeeded.
|
|
await chrome.storage.local.set({ [SHARING_KEY]: { ...stored[SHARING_KEY], client: null } });
|
|
sharingLinked = false;
|
|
sharingEpoch += 1;
|
|
getSharingClient().unlink();
|
|
await chrome.storage.local.remove(OVERLAY_MODE ? [SHARING_LOCAL_STATE_KEY, SHARING_OPTIONS_VERSION_KEY] : SHARING_LOCAL_STATE_KEY).catch(error => {
|
|
console.warn("hachidori: could not clean up the restored sharing snapshot:", describe(error));
|
|
});
|
|
});
|
|
await reconcileUpdateAlarm();
|
|
await reconcileAutomaticBackupsAfterSharingTransition();
|
|
await applyAnkiIndexRole();
|
|
return { sharing: sharingStatus() };
|
|
},
|
|
async hd_sharing_host_enable(message) {
|
|
const host = getSharingHost();
|
|
host.enable({ port: message.port, network: message.network === true });
|
|
await writeSharingConfig({ host: { enabled: true, port: host.status().port, network: message.network === true } });
|
|
return { sharing: sharingStatus() };
|
|
},
|
|
async hd_sharing_host_disable() {
|
|
getSharingHost().disable();
|
|
await writeSharingConfig({ host: null });
|
|
return { sharing: sharingStatus() };
|
|
},
|
|
};
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message?.target !== SHARING_TARGET) return false;
|
|
const type = typeof message.type === "string" ? message.type : "";
|
|
Promise.resolve().then(() => {
|
|
if (!Object.hasOwn(SHARING_HANDLERS, type)) throw new Error(`unknown sharing request type ${JSON.stringify(type)}`);
|
|
const invoke = () => SHARING_HANDLERS[type](message, sender);
|
|
return ["hd_sharing_status", "hd_sharing_client_probe"].includes(type)
|
|
? sharingReady.then(invoke) : serialiseSharingTransition(invoke);
|
|
}).then(result => sendResponse(workerReply(message, result)), error => sendResponse(failureReply(message, error)));
|
|
return true;
|
|
});
|
|
|
|
chrome.storage.onChanged.addListener((changes, area) => {
|
|
sharingHost?.storageChanged(changes, area);
|
|
if (area !== "local") return;
|
|
const relevant = Object.keys(changes).some(key =>
|
|
SHARED_STATE_KEYS.includes(key) || key.startsWith(LOOKUP_STATS_ROW_PREFIX) || key === SHARING_KEY);
|
|
if (relevant && (automaticBackupWaitingForState
|
|
|| automaticBackupNextAt !== null && Date.now() >= automaticBackupNextAt)) {
|
|
void queueAutomaticBackup();
|
|
}
|
|
});
|
|
|
|
// A browser install shares by default; the overlay copy is a client, so it
|
|
// does not. Turning sharing off stores `host: null`; linking stores it off.
|
|
async function initialiseSharing() {
|
|
const stored = await chrome.storage.local.get([SHARING_KEY, DICTIONARY_STATE_KEY]);
|
|
const host = stored[SHARING_KEY]?.host;
|
|
if (host?.enabled === true || (host === undefined && !OVERLAY_MODE)) {
|
|
getSharingHost().enable({ port: host?.port, network: host?.network === true, dictionaries: dictionaryCount(stored[DICTIONARY_STATE_KEY]) });
|
|
}
|
|
const address = stored[SHARING_KEY]?.client?.address;
|
|
if (typeof address === "string" && address !== "") {
|
|
sharingLinked = true;
|
|
if (OVERLAY_MODE) await serialiseStorage(async () => {
|
|
const current = await chrome.storage.local.get(OVERLAY_OPTIONS_STORAGE_KEYS);
|
|
if (!current[SHARING_OPTIONS_VERSION_KEY]) await applyMirror({ options: current[OPTIONS_KEY] ?? null }, true);
|
|
});
|
|
getSharingClient().link(address);
|
|
}
|
|
}
|
|
|
|
function handleAlarm(alarm) {
|
|
if (alarm.name === AUTOMATIC_BACKUP_ALARM) {
|
|
automaticBackupNextAt = null;
|
|
void queueAutomaticBackup(true);
|
|
return;
|
|
}
|
|
if (alarm.name === ANKI_INDEX_ALARM) {
|
|
void reconcileAnkiIndex();
|
|
return;
|
|
}
|
|
if (alarm.name === SHARING_HOST_ALARM) {
|
|
getSharingHost().reconnect();
|
|
return;
|
|
}
|
|
if (alarm.name !== UPDATE_ALARM) {
|
|
return;
|
|
}
|
|
void sharingReady.then(() => (sharingLinked ? undefined : queueManagedUpdate({ install: true, dueOnly: true }))).catch((error) => {
|
|
console.error("hoshidicts: scheduled dictionary updates failed:", describe(error));
|
|
});
|
|
}
|
|
|
|
if (alarms === chrome.alarms) chrome.alarms.onAlarm.addListener(handleAlarm);
|
|
|
|
chrome.downloads?.onChanged?.addListener(delta => {
|
|
if (!delta.state || delta.state.current === "in_progress") return;
|
|
getBackupDownloads().changed(delta.id).catch(error => {
|
|
console.warn("hoshidicts: could not release a finished backup download:", describe(error));
|
|
});
|
|
});
|
|
|
|
function warmUp() {
|
|
void reconcileAnkiIndex();
|
|
void queueAutomaticBackup(true);
|
|
ensureOffscreen().catch((error) => {
|
|
console.error("hoshidicts: could not create the offscreen document:", describe(error));
|
|
});
|
|
reconcileUpdateAlarm().catch((error) => {
|
|
console.error("hoshidicts: could not reconcile the dictionary update alarm:", describe(error));
|
|
});
|
|
}
|
|
|
|
// A fresh installation seeds its setup state and initial preferences once, then
|
|
// opens one startup tab. Only values that are still absent are written, so a
|
|
// profile that already carries settings keeps them. Chrome reports "install"
|
|
// again on every launch for an unpacked extension loaded from the command line,
|
|
// so the absence of a setup record, not the reason alone, identifies a new
|
|
// installation.
|
|
async function beginFirstRunSetup() {
|
|
const created = await serialiseStorage(async () => {
|
|
const stored = await chrome.storage.local.get([SETUP_STATE_KEY, OPTIONS_KEY]);
|
|
const values = {};
|
|
if (stored[SETUP_STATE_KEY] === undefined) {
|
|
values[SETUP_STATE_KEY] = initialSetupState(new Date().toISOString());
|
|
}
|
|
if (stored[OPTIONS_KEY] === undefined) {
|
|
values[OPTIONS_KEY] = { ...validateOptionsPatch(FIRST_INSTALL_OPTIONS), revision: 1 };
|
|
}
|
|
if (Object.keys(values).length > 0) await writeLocalState(values);
|
|
return Object.hasOwn(values, SETUP_STATE_KEY);
|
|
});
|
|
if (created) await chrome.tabs.create({ url: chrome.runtime.getURL(STARTUP_PAGE) });
|
|
}
|
|
|
|
// An overlay host has no tab to show setup in, so its first launch only seeds
|
|
// the initial preferences. It runs on worker start because a host may never
|
|
// report onInstalled.
|
|
async function seedOverlayModeOptions() {
|
|
await serialiseStorage(async () => {
|
|
const stored = await chrome.storage.local.get(OPTIONS_KEY);
|
|
if (stored[OPTIONS_KEY] !== undefined) return;
|
|
const options = validateOptionsPatch({
|
|
...FIRST_INSTALL_OPTIONS,
|
|
...OVERLAY_MODE_OPTIONS,
|
|
anki: overlayAnkiOptions(DEFAULT_OPTIONS).anki,
|
|
});
|
|
await writeLocalState({ [OPTIONS_KEY]: { ...options, revision: 1 } });
|
|
});
|
|
}
|
|
|
|
// Load the dictionaries before the first hover asks for them. Extension updates,
|
|
// browser starts and service-worker restarts never reach the first-run path, so
|
|
// they cannot reopen setup or reset preferences.
|
|
chrome.runtime.onInstalled.addListener((details) => {
|
|
warmUp();
|
|
if (details.reason !== "install" || OVERLAY_MODE) return;
|
|
beginFirstRunSetup().catch((error) => {
|
|
console.error("hoshidicts: could not start first-run setup:", describe(error));
|
|
});
|
|
});
|
|
chrome.runtime.onStartup.addListener(warmUp);
|
|
|
|
// Yomitan's native browser shortcuts for the features Hachidori has. The toggle
|
|
// makes the toolbar switch's revisioned write inside the storage queue.
|
|
async function toggleLookupsFromCommand() {
|
|
await sharingReady;
|
|
const toggle = async () => {
|
|
const { options } = await readDictionaryStorage();
|
|
return { target: WORKER_TARGET, type: "hd_options_write", requestId: null,
|
|
baseRevision: optionsRevision(options), options: { hoverEnabled: !normaliseOptions(options).hoverEnabled } };
|
|
};
|
|
if (sharingLinked) return forwardWorkerRequest(await toggle());
|
|
return serialiseStorage(async () => WORKER_HANDLERS.hd_options_write(await toggle()));
|
|
}
|
|
|
|
// Popup-action shortcuts run their in-page keybind action in the active tab.
|
|
// Every frame's reader receives the command; one without an open popup, or
|
|
// without a selection for the scans, does nothing.
|
|
const READER_CONTENT_TARGET = "hachidori-reader";
|
|
const READER_COMMANDS = new Set(["close", "addNote", "viewNotes", "playAudio", "nextEntry", "previousEntry",
|
|
"firstEntry", "lastEntry", "nextEntryDifferentDictionary", "previousEntryDifferentDictionary", "historyBackward",
|
|
"scanSelectedText", "scanTextAtSelection"]);
|
|
|
|
chrome.commands?.onCommand?.addListener((command, tab) => {
|
|
if (command === "openSettingsPage") {
|
|
chrome.runtime.openOptionsPage().catch((error) => {
|
|
console.error("hachidori: could not open settings:", describe(error));
|
|
});
|
|
} else if (command === "toggleTextScanning") {
|
|
toggleLookupsFromCommand().catch((error) => {
|
|
console.error("hachidori: could not toggle lookups:", describe(error));
|
|
});
|
|
} else if (READER_COMMANDS.has(command) && tab?.id !== undefined) {
|
|
// Pages Chrome keeps content scripts out of, such as chrome://, have no reader.
|
|
chrome.tabs.sendMessage(tab.id, { target: READER_CONTENT_TARGET, type: "hd_reader_command", action: command })
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
|
|
// Alarms may be cleared across browser restarts. Module evaluation is the one
|
|
// startup path every MV3 worker takes, including starts not caused by either
|
|
// lifecycle event above.
|
|
async function initialiseUpdateAlarm() {
|
|
try {
|
|
await reconcileUpdateAlarm();
|
|
} catch (error) {
|
|
console.error("hoshidicts: could not reconcile the dictionary update alarm:", describe(error));
|
|
}
|
|
}
|
|
|
|
async function initialiseAutomaticBackupAlarm() {
|
|
try {
|
|
await sharingReady;
|
|
if (sharingLinked) {
|
|
await alarms.clear(AUTOMATIC_BACKUP_ALARM);
|
|
return;
|
|
}
|
|
const stored = (await chrome.storage.local.get(AUTOMATIC_BACKUPS_KEY))[AUTOMATIC_BACKUPS_KEY];
|
|
if (stored === undefined) {
|
|
await queueAutomaticBackup(true);
|
|
return;
|
|
}
|
|
const nextAt = nextAutomaticBackupTime(stored);
|
|
if (Date.now() >= nextAt) await queueAutomaticBackup(true);
|
|
else await scheduleAutomaticBackup(nextAt);
|
|
} catch (error) {
|
|
console.warn("hachidori: could not reconcile the automatic backup alarm:", describe(error));
|
|
}
|
|
}
|
|
|
|
sharingReady = initialiseSharing().catch((error) => {
|
|
console.error("hachidori: could not restore sharing:", describe(error));
|
|
});
|
|
void initialiseUpdateAlarm(); // NOSONAR -- top-level await prevents this MV3 worker from activating.
|
|
void initialiseAutomaticBackupAlarm(); // NOSONAR -- top-level await prevents this MV3 worker from activating.
|
|
void chrome.storage.local.get(OPTIONS_KEY).then(stored =>
|
|
applyCustomJavaScript(chrome, normaliseOptions(stored[OPTIONS_KEY]).customPopupJavascript));
|
|
|
|
if (OVERLAY_MODE) {
|
|
seedOverlayModeOptions().catch((error) => {
|
|
console.error("hoshidicts: could not seed overlay mode options:", describe(error));
|
|
});
|
|
}
|
|
void reconcileAnkiIndex(); // NOSONAR -- initialize without delaying worker activation.
|