// sync-trigger.ts — central sync dispatcher. // // SEV-1-A fix: the chirp-sync coordinator + transport were complete but // never invoked from production code paths. This module is the single // entry point for the four trigger conditions (vault change, popup // foreground, manual "Sync Now", and mDNS peer discovery) and is the // only place that calls `runChirpSyncInitiator` outside tests. // // The actual per-device session runner is injected so the orchestrator // can be unit-tested without WebSocket/Noise transport. Production wires // in `runChirpSyncWithDevice` 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" | "mdns"; export type SyncRunStatus = "idle" | "syncing" | "error"; export interface SyncState { status: SyncRunStatus; reason?: SyncTriggerReason; lastSyncAt?: number; entriesReceived?: number; entriesSent?: number; error?: string; } export interface SyncRunResult { entriesReceived: number; entriesSent: number; } export type RunSyncWithDevice = (device: PairedDevice) => Promise; let syncInFlight = false; let lastState: SyncState = { status: "idle" }; let runner: RunSyncWithDevice | null = null; export function setSyncRunner(fn: RunSyncWithDevice | null): void { runner = fn; } export function _resetSyncTriggerForTest(): void { syncInFlight = false; lastState = { status: "idle" }; runner = null; } export function getSyncState(): SyncState { return 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 { if (lastState.lastSyncAt !== undefined) return; const persisted = await getLastSyncAt(); if (typeof persisted === "number") { lastState = { ...lastState, lastSyncAt: persisted }; } } export async function triggerSyncIfReachable(opts: { reason: SyncTriggerReason; force?: boolean; }): Promise { // Claim the guard synchronously before any await, otherwise two // concurrent triggers could both pass the gate before either reached // the registry lookup. if (syncInFlight && !opts.force) return; syncInFlight = true; try { const devices = await listPairedDevices(); const hosts = devices.filter( (d) => (d.deviceType === "android" || d.deviceType === "ios") && d.trustSource === "direct", ); if (hosts.length === 0) return; if (!runner) return; setState({ status: "syncing", reason: opts.reason, error: undefined }); await runSyncRounds(hosts); } finally { syncInFlight = false; } } async function runSyncRounds(hosts: PairedDevice[]): Promise { if (!runner) return; let entriesReceived = 0; let entriesSent = 0; let anySucceeded = false; let firstError: string | undefined; for (const host of hosts) { try { const result = await runner(host); 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 { lastState = { ...lastState, ...patch }; broadcast(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 */ } }