sync-trigger.ts

4.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 {14  getLastSyncAt,15  listPairedDevices,16  markLastSyncedAt,17  setLastSyncAt,18  setLastSyncResult,19} from "./paired-devices";20import type { PairedDevice } from "../shared/sync-types";2122export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";2324export type SyncRunStatus = "idle" | "syncing" | "error";2526export interface SyncState {27  status: SyncRunStatus;28  reason?: SyncTriggerReason;29  lastSyncAt?: number;30  entriesReceived?: number;31  entriesSent?: number;32  error?: string;33}3435export interface SyncRunResult {36  entriesReceived: number;37  entriesSent: number;38}3940export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4142let syncInFlight = false;43let lastState: SyncState = { status: "idle" };44let runner: RunSyncWithDevice | null = null;4546export function setSyncRunner(fn: RunSyncWithDevice | null): void {47  runner = fn;48}4950export function _resetSyncTriggerForTest(): void {51  syncInFlight = false;52  lastState = { status: "idle" };53  runner = null;54}5556export function getSyncState(): SyncState {57  return lastState;58}5960/**61 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service62 * worker dies and restarts on the slightest provocation; without this the63 * popup's "Last synced N minutes ago" indicator resets to "never" every64 * time the SW respawns. Called once at startup from `entry.ts`.65 */66export async function hydratePersistedSyncState(): Promise<void> {67  if (lastState.lastSyncAt !== undefined) return;68  const persisted = await getLastSyncAt();69  if (typeof persisted === "number") {70    lastState = { ...lastState, lastSyncAt: persisted };71  }72}7374export async function triggerSyncIfReachable(opts: {75  reason: SyncTriggerReason;76  force?: boolean;77}): Promise<void> {78  // Claim the guard synchronously before any await, otherwise two79  // concurrent triggers could both pass the gate before either reached80  // the registry lookup.81  if (syncInFlight && !opts.force) return;82  syncInFlight = true;8384  try {85    const devices = await listPairedDevices();86    const hosts = devices.filter(87      (d) =>88        (d.deviceType === "android" || d.deviceType === "ios") &&89        d.trustSource === "direct",90    );91    if (hosts.length === 0) return;92    if (!runner) return;9394    setState({ status: "syncing", reason: opts.reason, error: undefined });95    await runSyncRounds(hosts);96  } finally {97    syncInFlight = false;98  }99}100101async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {102  if (!runner) return;103104  let entriesReceived = 0;105  let entriesSent = 0;106  let anySucceeded = false;107  let firstError: string | undefined;108109  for (const host of hosts) {110    try {111      const result = await runner(host);112      entriesReceived += result.entriesReceived;113      entriesSent += result.entriesSent;114      await markLastSyncedAt(host.deviceId, Date.now());115      anySucceeded = true;116    } catch (err) {117      if (firstError === undefined) {118        firstError = (err as Error)?.message ?? String(err);119      }120    }121  }122123  if (anySucceeded) {124    const now = Date.now();125    setState({126      status: "idle",127      lastSyncAt: now,128      entriesReceived,129      entriesSent,130      error: undefined,131    });132    // Persist so the popup's "last synced" indicator and entry counts133    // survive a SW restart. Best-effort — storage failures must not134    // surface as sync failures.135    void setLastSyncAt(now).catch(() => undefined);136    void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);137  } else {138    setState({139      status: "error",140      error: firstError ?? "no_host_reachable",141    });142  }143}144145function setState(patch: Partial<SyncState>): void {146  lastState = { ...lastState, ...patch };147  broadcast(lastState);148}149150function broadcast(state: SyncState): void {151  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;152  try {153    const maybeThenable = chrome.runtime.sendMessage({154      type: "SYNC_STATE_CHANGED",155      payload: state,156    });157    if (158      maybeThenable &&159      typeof (maybeThenable as Promise<unknown>).catch === "function"160    ) {161      (maybeThenable as Promise<unknown>).catch(() => {162        /* popup not listening — fine */163      });164    }165  } catch {166    /* messaging unavailable */167  }168}