sync-trigger.ts

3.8 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 { listPairedDevices, markLastSyncedAt } from "./paired-devices";14import type { PairedDevice } from "../shared/sync-types";1516export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";1718export type SyncRunStatus = "idle" | "syncing" | "error";1920export interface SyncState {21  status: SyncRunStatus;22  reason?: SyncTriggerReason;23  lastSyncAt?: number;24  entriesReceived?: number;25  entriesSent?: number;26  error?: string;27}2829export interface SyncRunResult {30  entriesReceived: number;31  entriesSent: number;32}3334export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;3536let syncInFlight = false;37let lastState: SyncState = { status: "idle" };38let runner: RunSyncWithDevice | null = null;3940export function setSyncRunner(fn: RunSyncWithDevice | null): void {41  runner = fn;42}4344export function _resetSyncTriggerForTest(): void {45  syncInFlight = false;46  lastState = { status: "idle" };47  runner = null;48}4950export function getSyncState(): SyncState {51  return lastState;52}5354export async function triggerSyncIfReachable(opts: {55  reason: SyncTriggerReason;56  force?: boolean;57}): Promise<void> {58  // Claim the guard synchronously before any await, otherwise two59  // concurrent triggers could both pass the gate before either reached60  // the registry lookup.61  if (syncInFlight && !opts.force) return;62  syncInFlight = true;6364  try {65    const devices = await listPairedDevices();66    const hosts = devices.filter(67      (d) => d.deviceType === "android" || d.deviceType === "ios",68    );69    if (hosts.length === 0) return;70    if (!runner) return;7172    setState({ status: "syncing", reason: opts.reason, error: undefined });73    await runSyncRounds(hosts);74  } finally {75    syncInFlight = false;76  }77}7879async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {80  if (!runner) return;8182  let entriesReceived = 0;83  let entriesSent = 0;84  let anySucceeded = false;85  let firstError: string | undefined;8687  for (const host of hosts) {88    try {89      const result = await runner(host);90      entriesReceived += result.entriesReceived;91      entriesSent += result.entriesSent;92      await markLastSyncedAt(host.deviceId, Date.now());93      anySucceeded = true;94    } catch (err) {95      if (firstError === undefined) {96        firstError = (err as Error)?.message ?? String(err);97      }98    }99  }100101  if (anySucceeded) {102    setState({103      status: "idle",104      lastSyncAt: Date.now(),105      entriesReceived,106      entriesSent,107      error: undefined,108    });109  } else {110    setState({111      status: "error",112      error: firstError ?? "no_host_reachable",113    });114  }115}116117function setState(patch: Partial<SyncState>): void {118  lastState = { ...lastState, ...patch };119  broadcast(lastState);120}121122function broadcast(state: SyncState): void {123  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;124  try {125    const maybeThenable = chrome.runtime.sendMessage({126      type: "SYNC_STATE_CHANGED",127      payload: state,128    });129    if (130      maybeThenable &&131      typeof (maybeThenable as Promise<unknown>).catch === "function"132    ) {133      (maybeThenable as Promise<unknown>).catch(() => {134        /* popup not listening — fine */135      });136    }137  } catch {138    /* messaging unavailable */139  }140}