mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
This commit is contained in:
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* Host side of sharing: one outbound WebSocket to the relay the Anki add-on
|
||||
* runs, the browsers linked through it, and the storage batches pushed to
|
||||
* them. Requests arrive as ordinary runtime messages and are answered by the
|
||||
* service worker's own handlers.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_SHARING_PORT, PROTOCOL_VERSION, SHARING_CAPABILITIES, formatHostAddress, parseClientFrame,
|
||||
} from "./sharing-protocol.js";
|
||||
|
||||
export const SHARING_KEY = "sharing";
|
||||
export const SHARING_HOST_ALARM = "hachidori-sharing-host";
|
||||
|
||||
const LOOPBACK_PEERS = new Set(["127.0.0.1", "::1"]);
|
||||
|
||||
function describe(error) {
|
||||
return error instanceof Error ? error.message || String(error) : String(error);
|
||||
}
|
||||
|
||||
function relayAddresses(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
return entries.map(entry => ({ address: String(entry?.address ?? ""), kind: entry?.kind === "tailscale" ? "tailscale" : "local" }))
|
||||
.filter(entry => entry.address !== "");
|
||||
}
|
||||
|
||||
// `dispatch(message, clientId, capabilities)` answers a forwarded request
|
||||
// with the same reply object a runtime sender would receive. `readSnapshot()`
|
||||
// returns the shared storage keys as stored. `sharedKey(key)` says whether a
|
||||
// storage change belongs to the mirror. `name` is what linked browsers call
|
||||
// this one.
|
||||
export function createSharingHost({
|
||||
WebSocket, alarms, dispatch, readSnapshot, sharedKey, version, name, capabilities = SHARING_CAPABILITIES,
|
||||
}) {
|
||||
const clients = new Map();
|
||||
let enabled = false;
|
||||
let configuredPort = DEFAULT_SHARING_PORT;
|
||||
// The preference, and what the relay last confirmed.
|
||||
let network = false;
|
||||
let networkState = { active: false, addresses: [], error: null };
|
||||
let dictionaries = 0;
|
||||
let socket = null;
|
||||
let listeningPort = null;
|
||||
let error = null;
|
||||
let attempt = 0;
|
||||
let retryTimer = null;
|
||||
|
||||
function status() {
|
||||
const connected = socket !== null && listeningPort !== null;
|
||||
return {
|
||||
enabled,
|
||||
connected,
|
||||
port: listeningPort ?? configuredPort,
|
||||
dictionaries,
|
||||
error,
|
||||
network: { enabled: network, active: connected && networkState.active, addresses: connected ? networkState.addresses : [], error: networkState.error },
|
||||
clients: [...clients.values()],
|
||||
};
|
||||
}
|
||||
|
||||
function postTo(target, frame) {
|
||||
if (socket !== target || target.readyState !== 1) return false;
|
||||
try {
|
||||
target.send(JSON.stringify(frame));
|
||||
return true;
|
||||
} catch (sendError) {
|
||||
console.warn("hachidori: could not reach the sharing relay:", describe(sendError));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function post(frame) {
|
||||
if (socket !== null) postTo(socket, frame);
|
||||
}
|
||||
|
||||
function sendTo(target, clientId, client, frame) {
|
||||
if (clients.get(clientId) !== client) return false;
|
||||
return postTo(target, { kind: "send", clientId, text: JSON.stringify(frame) });
|
||||
}
|
||||
|
||||
async function handleClientText(target, clientId, text) {
|
||||
const client = clients.get(clientId);
|
||||
if (socket !== target || client === undefined) return;
|
||||
let frame;
|
||||
try {
|
||||
frame = parseClientFrame(text);
|
||||
} catch (parseError) {
|
||||
if (sendTo(target, clientId, client, { kind: "bye", reason: describe(parseError) })) {
|
||||
postTo(target, { kind: "close", clientId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "hello") {
|
||||
Object.assign(client, { name: frame.name, version: frame.version, capabilities: frame.capabilities });
|
||||
const snapshot = await readSnapshot();
|
||||
const dictionaryCount = Array.isArray(snapshot.dictionaryState?.dictionaries) ? snapshot.dictionaryState.dictionaries.length : 0;
|
||||
sendTo(target, clientId, client,
|
||||
{ kind: "hello", protocol: PROTOCOL_VERSION, version, name, dictionaryCount, capabilities, snapshot });
|
||||
return;
|
||||
}
|
||||
if (frame.kind === "request") {
|
||||
const response = await dispatch(frame.message, clientId, [...client.capabilities]);
|
||||
sendTo(target, clientId, client, { kind: "reply", id: frame.id, response });
|
||||
}
|
||||
}
|
||||
|
||||
function onRelayMessage(target, text) {
|
||||
if (socket !== target) return;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(text);
|
||||
} catch {
|
||||
console.warn("hachidori: dropped an unreadable relay message");
|
||||
return;
|
||||
}
|
||||
switch (message?.kind) {
|
||||
case "listening":
|
||||
listeningPort = Number(message.port) || configuredPort;
|
||||
error = null;
|
||||
attempt = 0;
|
||||
alarms.clear(SHARING_HOST_ALARM);
|
||||
if (network) post({ kind: "network", enabled: true });
|
||||
return;
|
||||
case "listen-failed":
|
||||
error = String(message.error ?? "The relay refused this Hachidori.");
|
||||
return;
|
||||
case "network":
|
||||
networkState = { active: message.enabled === true, addresses: relayAddresses(message.addresses),
|
||||
error: typeof message.error === "string" ? message.error : null };
|
||||
return;
|
||||
case "client-open": {
|
||||
const address = String(message.address ?? "");
|
||||
clients.set(message.clientId, { id: message.clientId, origin: String(message.origin ?? ""), address, local: LOOPBACK_PEERS.has(address),
|
||||
name: "", version: "", capabilities: [], connectedAt: Date.now() });
|
||||
return;
|
||||
}
|
||||
case "client-close":
|
||||
clients.delete(message.clientId);
|
||||
return;
|
||||
case "client-text":
|
||||
void handleClientText(target, message.clientId, String(message.text));
|
||||
return;
|
||||
case "ping":
|
||||
return;
|
||||
default:
|
||||
console.warn("hachidori: unknown sharing relay message", message?.kind);
|
||||
}
|
||||
}
|
||||
|
||||
// While the relay is away, retry quickly for as long as this worker lives and
|
||||
// once a minute through the alarm after Chrome has put it to sleep.
|
||||
function scheduleRetry() {
|
||||
if (!enabled || retryTimer !== null) return;
|
||||
const delay = Math.min(10_000, 500 * 2 ** Math.min(attempt, 5));
|
||||
attempt += 1;
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
alarms.create(SHARING_HOST_ALARM, { delayInMinutes: 1 });
|
||||
}
|
||||
|
||||
// Sharing waits for something to share: an empty install must not take the
|
||||
// host slot ahead of the browser that has the dictionaries.
|
||||
function connect() {
|
||||
if (!enabled || socket !== null || dictionaries === 0) return;
|
||||
let next;
|
||||
try {
|
||||
next = new WebSocket(formatHostAddress({ port: configuredPort }));
|
||||
} catch (connectError) {
|
||||
error = describe(connectError);
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
socket = next;
|
||||
listeningPort = null;
|
||||
next.onmessage = (event) => onRelayMessage(next, String(event.data));
|
||||
next.onclose = () => {
|
||||
if (socket !== next) return;
|
||||
socket = null;
|
||||
listeningPort = null;
|
||||
networkState = { active: false, addresses: [], error: null };
|
||||
clients.clear();
|
||||
if (enabled) scheduleRetry();
|
||||
};
|
||||
}
|
||||
|
||||
function dropSocket() {
|
||||
const previous = socket;
|
||||
socket = null;
|
||||
listeningPort = null;
|
||||
networkState = { active: false, addresses: [], error: null };
|
||||
clients.clear();
|
||||
previous?.close();
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
// A changed port reconnects; a changed network preference alone is told to
|
||||
// the relay over the open socket.
|
||||
enable({ port = DEFAULT_SHARING_PORT, network: wantNetwork = false, dictionaries: count = dictionaries } = {}) {
|
||||
const nextPort = Number(port) || DEFAULT_SHARING_PORT;
|
||||
const reconnect = !enabled || socket === null || nextPort !== configuredPort;
|
||||
enabled = true;
|
||||
configuredPort = nextPort;
|
||||
dictionaries = Number(count) || 0;
|
||||
network = wantNetwork === true;
|
||||
if (reconnect) {
|
||||
error = null;
|
||||
attempt = 0;
|
||||
dropSocket();
|
||||
connect();
|
||||
} else if (listeningPort !== null) {
|
||||
post({ kind: "network", enabled: network });
|
||||
}
|
||||
},
|
||||
setDictionaries(count) {
|
||||
dictionaries = Number(count) || 0;
|
||||
if (enabled && socket === null && retryTimer === null) connect();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
if (retryTimer !== null) clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
alarms.clear(SHARING_HOST_ALARM);
|
||||
dropSocket();
|
||||
error = null;
|
||||
},
|
||||
// The alarm and worker restarts land here.
|
||||
reconnect() {
|
||||
if (enabled && socket === null && retryTimer === null) connect();
|
||||
},
|
||||
storageChanged(changes, area) {
|
||||
if (area !== "local" || socket === null || clients.size === 0) return;
|
||||
const shared = Object.entries(changes).filter(([key]) => sharedKey(key));
|
||||
if (shared.length === 0) return;
|
||||
post({ kind: "broadcast", text: JSON.stringify({
|
||||
kind: "storage", changes: Object.fromEntries(shared.map(([key, change]) => [key, change.newValue ?? null])),
|
||||
}) });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user