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.