mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
// Wire contract shared by the service worker and the relay the Anki add-on
|
|
// runs (https://github.com/bee-san/hachidori-anki, addon/server.py).
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
export const PROTOCOL_VERSION = 1;
|
|
export const DEFAULT_SHARING_PORT = 8771;
|
|
// Nearby discovery must not offer this installation's own host or replace a link.
|
|
export function canDiscoverSharingHost(sharing) {
|
|
return sharing != null && sharing.client?.linked !== true
|
|
&& !(sharing.enabled === true && sharing.connected === true);
|
|
}
|
|
|
|
// v1 carries the singleton Anki configuration. v2 adds stable Template
|
|
// identity to every readiness, write and browse operation. Advertise both so
|
|
// older readers can still use the first Template without a mixed-version
|
|
// reader silently sending a custom button through the wrong destination.
|
|
export const LEGACY_LINKED_ANKI_CAPABILITY = "linked-anki-v1";
|
|
export const LINKED_ANKI_CAPABILITY = "linked-anki-v2";
|
|
export const SHARING_CAPABILITIES = Object.freeze([
|
|
LEGACY_LINKED_ANKI_CAPABILITY,
|
|
LINKED_ANKI_CAPABILITY,
|
|
]);
|
|
// The relay's own client for its Yomitan-compatible API (hachidori-anki
|
|
// docs/host-contract.md). Never a remote computer, never a linked browser.
|
|
export const API_CAPABILITY = "hoshidicts-api-v1";
|
|
export const API_CLIENT_ORIGIN = "relay://yomitan-api";
|
|
export const LINKED_ANKI_UNSUPPORTED = "The linked Hachidori does not support host-owned Anki mining. Update it and try again.";
|
|
export const MAX_LINKED_ANKI_FRAME_BYTES = 16 * 1024 * 1024;
|
|
const HOST_PATH = "/host";
|
|
const LINK_PATH = "/link";
|
|
|
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
const ADDRESS_HINT = "Enter the address shown under Sharing on the other computer, like 100.101.102.103.";
|
|
|
|
export const LINKED_ANKI_REQUESTS = new Set([
|
|
"hd_anki_status", "hd_anki_view", "hd_anki_preflight", "hd_anki_submit", "hd_anki_browse", "hd_anki_maturity",
|
|
]);
|
|
|
|
// Which runtime messages a linked client sends to the host instead of its own
|
|
// engine or worker. Screenshot capture/discard and captured-media sessions stay
|
|
// in the reading browser; the host owns every Anki and generation decision.
|
|
export const FORWARDED_REQUESTS = {
|
|
"hoshidicts-offscreen": new Set([
|
|
"hd_lookup", "hd_lookup_dictionary", "hd_kanji", "hd_styles", "hd_media", "hd_status", "hd_frequencies",
|
|
"hd_custom_append", "hd_custom_save", "hd_apply_state", "hd_reload", "hd_remove", "hd_import",
|
|
]),
|
|
"hoshidicts-worker": new Set([
|
|
"hd_state_read", "hd_state_cas", "hd_custom_read", "hd_custom_cas", "hd_options_write",
|
|
"hd_lookup_stats_read", "hd_lookup_stats_record",
|
|
]),
|
|
"hachidori-updates": new Set(["hd_updates_schedule", "hd_updates_check", "hd_updates_install"]),
|
|
"hachidori-setup": new Set(["hd_setup_install"]),
|
|
"hachidori-anki": LINKED_ANKI_REQUESTS,
|
|
};
|
|
|
|
const MUTATING_FORWARDED_REQUESTS = {
|
|
"hoshidicts-offscreen": new Set([
|
|
"hd_custom_append", "hd_custom_save", "hd_apply_state", "hd_reload", "hd_remove", "hd_import",
|
|
]),
|
|
"hoshidicts-worker": new Set([
|
|
"hd_state_cas", "hd_custom_cas", "hd_options_write", "hd_lookup_stats_record",
|
|
]),
|
|
"hachidori-updates": new Set(["hd_updates_schedule", "hd_updates_check", "hd_updates_install"]),
|
|
"hachidori-setup": new Set(["hd_setup_install"]),
|
|
"hachidori-anki": new Set(["hd_anki_submit"]),
|
|
};
|
|
|
|
export function forwardableRequest(message) {
|
|
if (!message || typeof message !== "object") return false;
|
|
const types = FORWARDED_REQUESTS[message.target];
|
|
if (!types?.has(message.type)) return false;
|
|
// A blob: URL only resolves inside the browser that created it; the host can
|
|
// download an archive itself.
|
|
if (message.type === "hd_import") return typeof message.archiveUrl === "string" && message.blobUrl === undefined;
|
|
return true;
|
|
}
|
|
|
|
export function mutatingForwardedRequest(message) {
|
|
return forwardableRequest(message) && MUTATING_FORWARDED_REQUESTS[message.target]?.has(message.type) === true;
|
|
}
|
|
|
|
export function formatLinkAddress({ host = "127.0.0.1", port = DEFAULT_SHARING_PORT } = {}) {
|
|
return `ws://${host}:${port}${LINK_PATH}`;
|
|
}
|
|
|
|
export function formatHostAddress({ port = DEFAULT_SHARING_PORT } = {}) {
|
|
return `ws://127.0.0.1:${port}${HOST_PATH}`;
|
|
}
|
|
|
|
// What a person types or is shown: a host, `host:port` when the port is not
|
|
// the default, or the full ws:// address. Empty means this computer.
|
|
export function parseLinkAddress(text) {
|
|
const trimmed = String(text ?? "").trim();
|
|
let withScheme = trimmed;
|
|
if (trimmed === "") withScheme = formatLinkAddress();
|
|
else if (!trimmed.includes("://")) withScheme = `ws://${trimmed}`;
|
|
let url;
|
|
try {
|
|
url = new URL(withScheme);
|
|
} catch {
|
|
throw new Error(ADDRESS_HINT);
|
|
}
|
|
if (url.protocol !== "ws:" || url.hostname === "" || !["", "/", LINK_PATH].includes(url.pathname)) {
|
|
throw new Error(ADDRESS_HINT);
|
|
}
|
|
const port = url.port === "" ? DEFAULT_SHARING_PORT : Number(url.port);
|
|
const host = LOOPBACK_HOSTS.has(url.hostname) ? "127.0.0.1" : url.hostname;
|
|
let display = `${host}:${port}`;
|
|
if (host === "127.0.0.1") display = "this computer";
|
|
else if (port === DEFAULT_SHARING_PORT) display = host;
|
|
return { host, port, address: formatLinkAddress({ host, port }), display };
|
|
}
|
|
|
|
// The brand a Chromium browser reports about itself, for "the Hachidori in
|
|
// Chrome": the first brand that is not the placeholder and not plain Chromium.
|
|
export function browserName(navigator) {
|
|
const brands = (navigator?.userAgentData?.brands ?? []).map(entry => String(entry?.brand ?? "")).filter(brand => brand !== "" && !/not.?a.?brand/iu.test(brand));
|
|
const brand = brands.find(name => name !== "Chromium") ?? brands[0];
|
|
if (brand) return brand;
|
|
if (/\bFirefox\//u.test(String(navigator?.userAgent ?? ""))) return "Firefox";
|
|
return "another browser";
|
|
}
|
|
|
|
function parseJsonObject(text) {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(text);
|
|
} catch {
|
|
throw new Error("malformed sharing frame");
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("malformed sharing frame");
|
|
return value;
|
|
}
|
|
|
|
function parseCapabilities(value) {
|
|
if (value === undefined) return [];
|
|
if (!Array.isArray(value) || value.length > 32
|
|
|| value.some(item => typeof item !== "string" || item === "" || item.length > 100)) {
|
|
throw new Error("malformed sharing capabilities");
|
|
}
|
|
return [...new Set(value)];
|
|
}
|
|
|
|
function linkedAnkiSubmission(message) {
|
|
return message?.target === "hachidori-anki" && message.type === "hd_anki_submit";
|
|
}
|
|
|
|
export function assertLinkedAnkiFrame(text) {
|
|
if (new TextEncoder().encode(text).byteLength > MAX_LINKED_ANKI_FRAME_BYTES) {
|
|
throw new Error("The linked Anki submission exceeds the 16 MiB frame limit.");
|
|
}
|
|
}
|
|
|
|
const MINING_REQUEST_FIELDS = [
|
|
"term", "trace", "generation", "sentence", "matchOffset", "matched", "popupSelectionText",
|
|
"searchQuery", "documentTitle", "audioSelection", "capturePin", "dictionaryAliases", "dictionaryIds",
|
|
"frequencyDictionaries", "configKey", "screenshot", "captureJobId", "captureUnavailable",
|
|
"clientSpeech", "templateId",
|
|
];
|
|
|
|
function selectedFields(value, fields) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("malformed linked Anki request");
|
|
return Object.fromEntries(fields.filter(field => Object.hasOwn(value, field)).map(field => [field, value[field]]));
|
|
}
|
|
|
|
function selectedTemplateId(value) {
|
|
if (!Object.hasOwn(value ?? {}, "templateId")) return {};
|
|
if (typeof value.templateId !== "string" || value.templateId === "" || value.templateId.length > 256
|
|
|| /[\u0000-\u001f\u007f]/u.test(value.templateId)) {
|
|
throw new Error("malformed linked Anki Template");
|
|
}
|
|
return { templateId: value.templateId };
|
|
}
|
|
|
|
// A linked browser is untrusted at the host boundary. Rebuild only the request
|
|
// shape each operation needs; endpoint credentials and local-only operations
|
|
// never enter the host's ordinary Anki handler.
|
|
export function allowLinkedAnkiRequest(message) {
|
|
if (!message || typeof message !== "object" || message.target !== "hachidori-anki"
|
|
|| !LINKED_ANKI_REQUESTS.has(message.type)) {
|
|
throw new Error("unsupported linked Anki request");
|
|
}
|
|
const requestId = typeof message.requestId === "string" || Number.isFinite(message.requestId)
|
|
? message.requestId : null;
|
|
const base = { target: "hachidori-anki", type: message.type, requestId };
|
|
if (message.type === "hd_anki_status") return { ...base, ...selectedTemplateId(message) };
|
|
if (message.type === "hd_anki_view") {
|
|
const request = selectedFields(message.request, ["term", "templateId"]);
|
|
Object.assign(request, selectedTemplateId(request));
|
|
request.term = selectedFields(request.term, ["expression", "reading"]);
|
|
return { ...base, request };
|
|
}
|
|
if (message.type === "hd_anki_maturity") {
|
|
const request = selectedFields(message.request, ["term"]);
|
|
request.term = selectedFields(request.term, ["expression", "reading"]);
|
|
return { ...base, request };
|
|
}
|
|
if (message.type === "hd_anki_browse") {
|
|
const request = selectedFields(message.request, ["noteIds", "expression", "configKey", "templateId"]);
|
|
Object.assign(request, selectedTemplateId(request));
|
|
return { ...base, request };
|
|
}
|
|
const request = selectedFields(message.request, MINING_REQUEST_FIELDS);
|
|
Object.assign(request, selectedTemplateId(request));
|
|
return message.type === "hd_anki_submit"
|
|
? { ...base, request, clientMedia: message.clientMedia }
|
|
: { ...base, request };
|
|
}
|
|
|
|
// Settings discovery is a worker request rather than a mining request. The
|
|
// linked browser may choose a prospective note type, but its endpoint and API
|
|
// key never cross the host boundary.
|
|
export function allowLinkedAnkiDiscoveryRequest(message) {
|
|
if (!message || typeof message !== "object" || message.target !== "hoshidicts-worker"
|
|
|| message.type !== "hd_anki_discover" || typeof message.model !== "string"
|
|
|| message.model.length > 4096) {
|
|
throw new Error("unsupported linked Anki discovery request");
|
|
}
|
|
const requestId = typeof message.requestId === "string" || Number.isFinite(message.requestId)
|
|
? message.requestId : null;
|
|
return {
|
|
target: "hoshidicts-worker",
|
|
type: "hd_anki_discover",
|
|
requestId,
|
|
model: message.model,
|
|
};
|
|
}
|
|
|
|
// Full setup detection reads the selected host Template as well as its shared
|
|
// endpoint. The client supplies only that stable identity, never its endpoint,
|
|
// key or mapping.
|
|
export function allowLinkedAnkiSetupRequest(message) {
|
|
if (!message || typeof message !== "object" || message.target !== "hoshidicts-worker"
|
|
|| message.type !== "hd_anki_setup") {
|
|
throw new Error("unsupported linked Anki setup request");
|
|
}
|
|
const requestId = typeof message.requestId === "string" || Number.isFinite(message.requestId)
|
|
? message.requestId : null;
|
|
return {
|
|
target: "hoshidicts-worker",
|
|
type: "hd_anki_setup",
|
|
requestId,
|
|
...selectedTemplateId(message),
|
|
};
|
|
}
|
|
|
|
// A frame a client sends to the host.
|
|
export function parseClientFrame(text) {
|
|
const frame = parseJsonObject(text);
|
|
switch (frame.kind) {
|
|
case "hello":
|
|
if (frame.protocol !== PROTOCOL_VERSION) throw new Error(`unsupported sharing protocol ${JSON.stringify(frame.protocol)}`);
|
|
return { kind: "hello", version: String(frame.version ?? ""), name: String(frame.name ?? ""),
|
|
capabilities: parseCapabilities(frame.capabilities) };
|
|
case "request":
|
|
if (!frame.message || typeof frame.message !== "object" || Array.isArray(frame.message)
|
|
|| typeof frame.message.target !== "string" || typeof frame.message.type !== "string"
|
|
|| (typeof frame.id !== "string" && typeof frame.id !== "number")) throw new Error("malformed sharing request");
|
|
if (linkedAnkiSubmission(frame.message)) assertLinkedAnkiFrame(text);
|
|
return { kind: "request", id: frame.id, message: frame.message };
|
|
case "pong":
|
|
return { kind: "pong" };
|
|
default:
|
|
throw new Error(`unknown sharing frame ${JSON.stringify(frame.kind)}`);
|
|
}
|
|
}
|
|
|
|
// A frame the host or the relay sends to a client.
|
|
export function parseHostFrame(text) {
|
|
const frame = parseJsonObject(text);
|
|
switch (frame.kind) {
|
|
case "hello":
|
|
if (frame.protocol !== PROTOCOL_VERSION) throw new Error(`unsupported sharing protocol ${JSON.stringify(frame.protocol)}`);
|
|
if (!frame.snapshot || typeof frame.snapshot !== "object") throw new Error("malformed sharing hello");
|
|
return { kind: "hello", version: String(frame.version ?? ""), name: String(frame.name ?? ""),
|
|
dictionaryCount: Number(frame.dictionaryCount) || 0, capabilities: parseCapabilities(frame.capabilities),
|
|
snapshot: frame.snapshot };
|
|
case "reply":
|
|
if (typeof frame.id !== "string" && typeof frame.id !== "number") throw new Error("malformed sharing reply");
|
|
return { kind: "reply", id: frame.id, response: frame.response };
|
|
case "storage":
|
|
if (!frame.changes || typeof frame.changes !== "object" || Array.isArray(frame.changes)) throw new Error("malformed sharing storage frame");
|
|
return { kind: "storage", changes: frame.changes };
|
|
case "ping":
|
|
return { kind: "ping" };
|
|
case "bye":
|
|
return { kind: "bye", reason: String(frame.reason ?? "") };
|
|
default:
|
|
throw new Error(`unknown sharing frame ${JSON.stringify(frame.kind)}`);
|
|
}
|
|
}
|