// 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 { getLastSyncAt, listPairedDevices, markLastSyncedAt, setLastSyncAt, setLastSyncResult, } from "./paired-devices"; import type { PairedDevice } from "../shared/sync-types"; export type SyncTriggerReason = "vault_change" | "foreground" | "manual"; export type SyncRunStatus = "idle" | "syncing" | "error"; export interface SyncState { status: SyncRunStatus; reason?: SyncTriggerReason; lastSyncAt?: number; entriesReceived?: number; entriesSent?: number; error?: string; } export type SyncRunResult = | { ok: true; entriesReceived: number; entriesSent: number } | { ok: false; error: string }; export type RunSyncWithDevice = (device: PairedDevice) => Promise; interface PeachSyncTriggerState { syncInFlight: boolean; lastState: SyncState; runner: RunSyncWithDevice | null; } function getSyncTriggerState(): PeachSyncTriggerState { const g = globalThis as typeof globalThis & { __peachSyncTriggerState?: PeachSyncTriggerState; }; if (!g.__peachSyncTriggerState) { g.__peachSyncTriggerState = { syncInFlight: false, lastState: { status: "idle" }, runner: null, }; } return g.__peachSyncTriggerState; } export function setSyncRunner(fn: RunSyncWithDevice | null): void { getSyncTriggerState().runner = fn; } export function _resetSyncTriggerForTest(): void { const state = getSyncTriggerState(); state.syncInFlight = false; state.lastState = { status: "idle" }; state.runner = 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) return; const persisted = await getLastSyncAt(); if (typeof persisted === "number") { state.lastState = { ...state.lastState, lastSyncAt: persisted }; } } export async function triggerSyncIfReachable(opts: { reason: SyncTriggerReason; force?: boolean; }): Promise { 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) return; state.syncInFlight = true; try { const devices = await listPairedDevices(); // Only native peers can accept a connection, so only they are sync hosts; // another extension is reachable solely via a relay, which is the relaying // device's job, not ours. // // "vouched" belongs here alongside "direct": a vouched device is one the // user has already confirmed out of the pending queue (MV-7), and the whole // point of mesh introduction is that it becomes reachable without its own // pairing code. Excluding it left confirmed devices permanently unsynced. const hosts = devices.filter( (d) => (d.deviceType === "android" || d.deviceType === "ios") && (d.trustSource === "direct" || d.trustSource === "vouched"), ); if (hosts.length === 0) return; if (!state.runner) return; setState({ status: "syncing", reason: opts.reason, error: undefined }); await runSyncRounds(hosts); } finally { state.syncInFlight = false; } } async function runSyncRounds(hosts: PairedDevice[]): Promise { const state = getSyncTriggerState(); if (!state.runner) 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) { if (firstError === undefined) firstError = result.error; continue; } entriesReceived += result.entriesReceived; entriesSent += result.entriesSent; await markLastSyncedAt(host.deviceId, Date.now()); anySucceeded = true; } catch (err) { if (firstError === undefined) { firstError = (err as Error)?.message ?? String(err); } } } if (anySucceeded) { const now = Date.now(); setState({ status: "idle", lastSyncAt: now, entriesReceived, entriesSent, error: undefined, }); // Persist so the popup's "last synced" indicator and entry counts // survive a SW restart. Best-effort — storage failures must not // surface as sync failures. void setLastSyncAt(now).catch(() => undefined); void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined); } else { setState({ status: "error", error: firstError ?? "no_host_reachable", }); } } 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 */ } }