sync-trigger.ts

4.9 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 type SyncRunResult =36  | { ok: true; entriesReceived: number; entriesSent: number }37  | { ok: false; error: string };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) =>87        (d.deviceType === "android" || d.deviceType === "ios") &&88        d.trustSource === "direct",89    );90    if (hosts.length === 0) return;91    if (!runner) return;9293    setState({ status: "syncing", reason: opts.reason, error: undefined });94    await runSyncRounds(hosts);95  } finally {96    syncInFlight = false;97  }98}99100async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {101  if (!runner) return;102103  let entriesReceived = 0;104  let entriesSent = 0;105  let anySucceeded = false;106  let firstError: string | undefined;107108  for (const host of hosts) {109    try {110      const result = await runner(host);111      if (!result.ok) {112        if (firstError === undefined) firstError = result.error;113        continue;114      }115      entriesReceived += result.entriesReceived;116      entriesSent += result.entriesSent;117      await markLastSyncedAt(host.deviceId, Date.now());118      anySucceeded = true;119    } catch (err) {120      if (firstError === undefined) {121        firstError = (err as Error)?.message ?? String(err);122      }123    }124  }125126  if (anySucceeded) {127    const now = Date.now();128    setState({129      status: "idle",130      lastSyncAt: now,131      entriesReceived,132      entriesSent,133      error: undefined,134    });135    // Persist so the popup's "last synced" indicator and entry counts136    // survive a SW restart. Best-effort — storage failures must not137    // surface as sync failures.138    void setLastSyncAt(now).catch(() => undefined);139    void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);140  } else {141    setState({142      status: "error",143      error: firstError ?? "no_host_reachable",144    });145  }146}147148function setState(patch: Partial<SyncState>): void {149  lastState = { ...lastState, ...patch };150  broadcast(lastState);151}152153function broadcast(state: SyncState): void {154  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;155  try {156    const maybeThenable = chrome.runtime.sendMessage({157      type: "SYNC_STATE_CHANGED",158      payload: state,159    });160    if (161      maybeThenable &&162      typeof (maybeThenable as Promise<unknown>).catch === "function"163    ) {164      (maybeThenable as Promise<unknown>).catch(() => {165        /* popup not listening — fine */166      });167    }168  } catch {169    /* messaging unavailable */170  }171}