// sync-trigger.ts — central sync dispatcher. // // SEV-1-A fix: the sync coordinator + transport were complete but // never invoked from production code paths. This module is the single // entry point for the three trigger conditions (vault change, popup // foreground, and manual "Sync Now") and is the // only place that calls `runSyncInitiator` outside tests. // // The actual per-device session runner is injected so the orchestrator // can be unit-tested without WebSocket/Noise transport. Production wires // in `productionSyncRunner` from `./sync-runner` at module load. import * as vault from "./vault"; import { getLastSyncAt, getLastSyncResult, listPairedDevices, markLastSyncedAt, setLastSyncAt, setLastSyncResult, } from "./paired-devices"; import type { PairedDevice } from "../shared/sync-types"; import { isConnectionClosedError, isSyncFailure, type SyncOutcome, } from "../shared/sync-outcome"; // `alarm` — the 10-minute chrome.alarms background tick. The only // trigger that survives MV3 service-worker death. // `startup` — first fire after the SW wakes (install, update, or // post-kill revival). Makes the popup feel instant. // `device_list_changed` — paired/unpaired/revoked, so a fresh peer should // get a dial without waiting for the next alarm. // `vault_change` — a committed write in vault.ts. // `foreground` — the popup asked for a sync with that reason. // `manual` — the user pressed "Sync Now". export type SyncTriggerReason = | "alarm" | "startup" | "device_list_changed" | "vault_change" | "foreground" | "manual"; export type SyncRunStatus = "idle" | "syncing" | "error"; export interface SyncState { status: SyncRunStatus; reason?: SyncTriggerReason; /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */ lastSyncAt?: number; entriesReceived?: number; entriesSent?: number; error?: string; outcome?: SyncOutcome; /** Raw internal code, or the device inventory behind `no_eligible_host`. */ detail?: string; finishedAt?: number; } export type SyncRunResult = | { ok: true; entriesReceived: number; entriesSent: number } | { ok: false; error: string }; export type RunSyncWithDevice = (device: PairedDevice) => Promise; export type SelectSyncDialTargets = ( devices: PairedDevice[], ) => Promise; interface PeachSyncTriggerState { syncInFlight: boolean; lastState: SyncState; runner: RunSyncWithDevice | null; selectDialTargets: SelectSyncDialTargets | null; } function getSyncTriggerState(): PeachSyncTriggerState { const g = globalThis as typeof globalThis & { __peachSyncTriggerState?: PeachSyncTriggerState; }; if (!g.__peachSyncTriggerState) { g.__peachSyncTriggerState = { syncInFlight: false, lastState: { status: "idle" }, runner: null, selectDialTargets: null, }; } return g.__peachSyncTriggerState; } export function setSyncRunner(fn: RunSyncWithDevice | null): void { getSyncTriggerState().runner = fn; } export function setSyncDialTargetSelector( fn: SelectSyncDialTargets | null, ): void { getSyncTriggerState().selectDialTargets = fn; } export function _resetSyncTriggerForTest(): void { const state = getSyncTriggerState(); state.syncInFlight = false; state.lastState = { status: "idle" }; state.runner = null; state.selectDialTargets = null; } export function getSyncState(): SyncState { return getSyncTriggerState().lastState; } /** * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service * worker dies and restarts on the slightest provocation; without this the * popup's "Last synced N minutes ago" indicator resets to "never" every * time the SW respawns. Called once at startup from `entry.ts`. */ export async function hydratePersistedSyncState(): Promise { const state = getSyncTriggerState(); if (state.lastState.lastSyncAt === undefined) { const persisted = await getLastSyncAt(); if (typeof persisted === "number") { state.lastState = { ...state.lastState, lastSyncAt: persisted }; } } // The outcome of the last attempt survives too, otherwise a failure that // happened before the SW died is unreportable — which is the case a real // device most needs explained. if (state.lastState.outcome === undefined) { const last = await getLastSyncResult(); if (last) { state.lastState = { ...state.lastState, outcome: last.outcome, detail: last.detail, finishedAt: last.at, entriesReceived: last.entriesReceived, entriesSent: last.entriesSent, }; } } } export async function triggerSyncIfReachable(opts: { reason: SyncTriggerReason; force?: boolean; }): Promise { if (vault.isDecoyMode()) return; const state = getSyncTriggerState(); // Claim the guard synchronously before any await, otherwise two // concurrent triggers could both pass the gate before either reached // the registry lookup. if (state.syncInFlight && !opts.force) { // Deliberately does NOT overwrite lastState — the in-flight run owns it. log(`trigger (${opts.reason}) ignored, a sync is already in flight`); return; } state.syncInFlight = true; try { const devices = await listPairedDevices(); const routeCapable = devices.filter( (d) => ( d.deviceType === "extension" || d.deviceType === "android" || d.deviceType === "ios" ) && (d.trustSource === "direct" || d.trustSource === "vouched"), ); // Production resolves the symmetric key-order role here: lower static key // dials, higher static key is kept online by mesh-relay-listener. Tests // without the production selector retain the pre-mesh native-host default. const hosts = state.selectDialTargets ? await state.selectDialTargets(routeCapable) : routeCapable.filter( (d) => d.deviceType === "android" || d.deviceType === "ios", ); if (devices.length === 0) { finish({ outcome: "no_paired_devices" }); return; } if (hosts.length === 0) { if (routeCapable.length > 0 && state.selectDialTargets) { finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) }); return; } // The device inventory is the diagnostic: it says exactly which // deviceType/trustSource combination the eligibility filter rejected. finish({ outcome: "no_eligible_host", detail: inventory(devices) }); return; } if (!state.runner) { finish({ outcome: "runner_unavailable" }); return; } setState({ status: "syncing", reason: opts.reason, error: undefined, outcome: undefined, detail: undefined, }); log( `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`, ); await runSyncRounds(hosts); } finally { state.syncInFlight = false; } } async function runSyncRounds(hosts: PairedDevice[]): Promise { const state = getSyncTriggerState(); if (!state.runner) { finish({ outcome: "runner_unavailable" }); return; } let entriesReceived = 0; let entriesSent = 0; let anySucceeded = false; let firstError: string | undefined; for (const host of hosts) { try { const result = await state.runner(host); if (!result.ok) { log(`host ${describeHost(host)} refused: ${result.error}`); if (firstError === undefined) firstError = result.error; continue; } entriesReceived += result.entriesReceived; entriesSent += result.entriesSent; if (vault.isDecoyMode()) return; await markLastSyncedAt(host.deviceId, Date.now()); if (vault.isDecoyMode()) return; anySucceeded = true; } catch (err) { const message = (err as Error)?.message ?? String(err); log(`host ${describeHost(host)} threw: ${message}`); if (firstError === undefined) firstError = message; } } if (vault.isDecoyMode()) return; if (anySucceeded) { const now = Date.now(); finish({ outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes", // A partial success still names the host that failed — otherwise "synced" // hides the peer that never got the change. detail: firstError, entriesReceived, entriesSent, }); // Persist so the popup's "last synced" indicator survives a SW restart. // Best-effort — storage failures must not surface as sync failures. void Promise.resolve() .then(() => { if (vault.isDecoyMode()) return; return setLastSyncAt(now); }) .catch(() => undefined); } else { const error = firstError ?? "no_host_reachable"; finish({ outcome: classifySyncError(error), detail: error }); } } /** * Runner errors are opaque strings by contract (`SyncRunResult.error` and any * throw). Mapping them here is the only place the popup's plain-English copy * can key off something stable. */ function classifySyncError(error: string): SyncOutcome { if (error.includes("VAULT_LOCKED")) return "vault_locked"; if (error.includes("sync_disabled")) return "sync_disabled"; if (error.includes("private_relay_unset")) return "private_relay_unset"; if (error.includes("vault_id_mismatch")) return "vault_id_mismatch"; if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated"; if (error.includes("sync_timeout")) return "sync_timeout"; // A transport that hung up mid-protocol. The alarm retries, and the peer may // well have applied what we already sent, so this is network-class (yellow) // rather than "the app is broken" (red). A close during the *dial* is excluded // here and falls to the branch below, where `relay_unreachable` is the more // specific diagnosis. if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) { return "connection_lost"; } if (error.startsWith("relay_sync_failed")) { return /handshake|PAIR_IDENTITY|identity does not match/i.test(error) ? "handshake_failed" : "relay_unreachable"; } return "run_failed"; } interface SyncFinish { outcome: SyncOutcome; detail?: string; entriesReceived?: number; entriesSent?: number; } /** * The single exit for every terminating path. Nothing may `return` out of a * sync run without going through here — that silence is the bug this whole * module was reworked to remove. */ function finish(result: SyncFinish): void { const now = Date.now(); const failed = isSyncFailure(result.outcome); const entriesReceived = result.entriesReceived ?? 0; const entriesSent = result.entriesSent ?? 0; setState({ status: failed ? "error" : "idle", outcome: result.outcome, detail: result.detail, error: failed ? result.detail ?? result.outcome : undefined, finishedAt: now, entriesReceived, entriesSent, ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes" ? { lastSyncAt: now } : {}), }); log( `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` + (entriesReceived + entriesSent > 0 ? ` (sent ${entriesSent}, received ${entriesReceived})` : ""), ); void Promise.resolve() .then(() => { if (vault.isDecoyMode()) return; return setLastSyncResult({ outcome: result.outcome, detail: result.detail, at: now, entriesReceived, entriesSent, }); }) .catch(() => undefined); } /** What the eligibility filter actually saw. Names, not just counts. */ function inventory(devices: PairedDevice[]): string { return devices.map(describeHost).join("; "); } function describeHost(d: PairedDevice): string { return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`; } /** * MV3 kills the service worker constantly, so devtools on the SW is the only * post-hoc record of a sync attempt. Prefixed so it can be filtered. */ function log(message: string): void { console.info(`[Peach sync] ${message}`); } function setState(patch: Partial): void { const state = getSyncTriggerState(); state.lastState = { ...state.lastState, ...patch }; broadcast(state.lastState); } function broadcast(state: SyncState): void { if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return; try { const maybeThenable = chrome.runtime.sendMessage({ type: "SYNC_STATE_CHANGED", payload: state, }); if ( maybeThenable && typeof (maybeThenable as Promise).catch === "function" ) { (maybeThenable as Promise).catch(() => { /* popup not listening — fine */ }); } } catch { /* messaging unavailable */ } }