sync-trigger.ts

4.6 KB · raw

1// sync-trigger.ts — central sync dispatcher.2//3// SEV-1-A fix: the chirp-sync coordinator + transport were complete but4// never invoked from production code paths. This module is the single5// entry point for the four trigger conditions (vault change, popup6// foreground, manual "Sync Now", and mDNS peer discovery) and is the7// only place that calls `runChirpSyncInitiator` outside tests.8//9// The actual per-device session runner is injected so the orchestrator10// can be unit-tested without WebSocket/Noise transport. Production wires11// in `runChirpSyncWithDevice` from `./sync-runner` at module load.1213import {14  getLastSyncAt,15  listPairedDevices,16  markLastSyncedAt,17  setLastSyncAt,18} from "./paired-devices";19import type { PairedDevice } from "../shared/sync-types";2021export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";2223export type SyncRunStatus = "idle" | "syncing" | "error";2425export interface SyncState {26  status: SyncRunStatus;27  reason?: SyncTriggerReason;28  lastSyncAt?: number;29  entriesReceived?: number;30  entriesSent?: number;31  error?: string;32}3334export interface SyncRunResult {35  entriesReceived: number;36  entriesSent: number;37}3839export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4041let syncInFlight = false;42let lastState: SyncState = { status: "idle" };43let runner: RunSyncWithDevice | null = null;4445export function setSyncRunner(fn: RunSyncWithDevice | null): void {46  runner = fn;47}4849export function _resetSyncTriggerForTest(): void {50  syncInFlight = false;51  lastState = { status: "idle" };52  runner = null;53}5455export function getSyncState(): SyncState {56  return lastState;57}5859/**60 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service61 * worker dies and restarts on the slightest provocation; without this the62 * popup's "Last synced N minutes ago" indicator resets to "never" every63 * time the SW respawns. Called once at startup from `entry.ts`.64 */65export async function hydratePersistedSyncState(): Promise<void> {66  if (lastState.lastSyncAt !== undefined) return;67  const persisted = await getLastSyncAt();68  if (typeof persisted === "number") {69    lastState = { ...lastState, lastSyncAt: persisted };70  }71}7273export async function triggerSyncIfReachable(opts: {74  reason: SyncTriggerReason;75  force?: boolean;76}): Promise<void> {77  // Claim the guard synchronously before any await, otherwise two78  // concurrent triggers could both pass the gate before either reached79  // the registry lookup.80  if (syncInFlight && !opts.force) return;81  syncInFlight = true;8283  try {84    const devices = await listPairedDevices();85    const hosts = devices.filter(86      (d) => d.deviceType === "android" || d.deviceType === "ios",87    );88    if (hosts.length === 0) return;89    if (!runner) return;9091    setState({ status: "syncing", reason: opts.reason, error: undefined });92    await runSyncRounds(hosts);93  } finally {94    syncInFlight = false;95  }96}9798async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {99  if (!runner) return;100101  let entriesReceived = 0;102  let entriesSent = 0;103  let anySucceeded = false;104  let firstError: string | undefined;105106  for (const host of hosts) {107    try {108      const result = await runner(host);109      entriesReceived += result.entriesReceived;110      entriesSent += result.entriesSent;111      await markLastSyncedAt(host.deviceId, Date.now());112      anySucceeded = true;113    } catch (err) {114      if (firstError === undefined) {115        firstError = (err as Error)?.message ?? String(err);116      }117    }118  }119120  if (anySucceeded) {121    const now = Date.now();122    setState({123      status: "idle",124      lastSyncAt: now,125      entriesReceived,126      entriesSent,127      error: undefined,128    });129    // Persist so the popup's "last synced" indicator survives a SW restart.130    // Best-effort — a storage failure must not surface as a sync failure.131    void setLastSyncAt(now).catch(() => undefined);132  } else {133    setState({134      status: "error",135      error: firstError ?? "no_host_reachable",136    });137  }138}139140function setState(patch: Partial<SyncState>): void {141  lastState = { ...lastState, ...patch };142  broadcast(lastState);143}144145function broadcast(state: SyncState): void {146  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;147  try {148    const maybeThenable = chrome.runtime.sendMessage({149      type: "SYNC_STATE_CHANGED",150      payload: state,151    });152    if (153      maybeThenable &&154      typeof (maybeThenable as Promise<unknown>).catch === "function"155    ) {156      (maybeThenable as Promise<unknown>).catch(() => {157        /* popup not listening — fine */158      });159    }160  } catch {161    /* messaging unavailable */162  }163}