sync-trigger.ts

5.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  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>;4041interface PeachSyncTriggerState {42  syncInFlight: boolean;43  lastState: SyncState;44  runner: RunSyncWithDevice | null;45}4647function getSyncTriggerState(): PeachSyncTriggerState {48  const g = globalThis as typeof globalThis & {49    __peachSyncTriggerState?: PeachSyncTriggerState;50  };51  if (!g.__peachSyncTriggerState) {52    g.__peachSyncTriggerState = {53      syncInFlight: false,54      lastState: { status: "idle" },55      runner: null,56    };57  }58  return g.__peachSyncTriggerState;59}6061export function setSyncRunner(fn: RunSyncWithDevice | null): void {62  getSyncTriggerState().runner = fn;63}6465export function _resetSyncTriggerForTest(): void {66  const state = getSyncTriggerState();67  state.syncInFlight = false;68  state.lastState = { status: "idle" };69  state.runner = null;70}7172export function getSyncState(): SyncState {73  return getSyncTriggerState().lastState;74}7576/**77 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service78 * worker dies and restarts on the slightest provocation; without this the79 * popup's "Last synced N minutes ago" indicator resets to "never" every80 * time the SW respawns. Called once at startup from `entry.ts`.81 */82export async function hydratePersistedSyncState(): Promise<void> {83  const state = getSyncTriggerState();84  if (state.lastState.lastSyncAt !== undefined) return;85  const persisted = await getLastSyncAt();86  if (typeof persisted === "number") {87    state.lastState = { ...state.lastState, lastSyncAt: persisted };88  }89}9091export async function triggerSyncIfReachable(opts: {92  reason: SyncTriggerReason;93  force?: boolean;94}): Promise<void> {95  const state = getSyncTriggerState();96  // Claim the guard synchronously before any await, otherwise two97  // concurrent triggers could both pass the gate before either reached98  // the registry lookup.99  if (state.syncInFlight && !opts.force) return;100  state.syncInFlight = true;101102  try {103    const devices = await listPairedDevices();104    const hosts = devices.filter(105      (d) =>106        (d.deviceType === "android" || d.deviceType === "ios") &&107        d.trustSource === "direct",108    );109    if (hosts.length === 0) return;110    if (!state.runner) return;111112    setState({ status: "syncing", reason: opts.reason, error: undefined });113    await runSyncRounds(hosts);114  } finally {115    state.syncInFlight = false;116  }117}118119async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {120  const state = getSyncTriggerState();121  if (!state.runner) return;122123  let entriesReceived = 0;124  let entriesSent = 0;125  let anySucceeded = false;126  let firstError: string | undefined;127128  for (const host of hosts) {129    try {130      const result = await state.runner(host);131      if (!result.ok) {132        if (firstError === undefined) firstError = result.error;133        continue;134      }135      entriesReceived += result.entriesReceived;136      entriesSent += result.entriesSent;137      await markLastSyncedAt(host.deviceId, Date.now());138      anySucceeded = true;139    } catch (err) {140      if (firstError === undefined) {141        firstError = (err as Error)?.message ?? String(err);142      }143    }144  }145146  if (anySucceeded) {147    const now = Date.now();148    setState({149      status: "idle",150      lastSyncAt: now,151      entriesReceived,152      entriesSent,153      error: undefined,154    });155    // Persist so the popup's "last synced" indicator and entry counts156    // survive a SW restart. Best-effort — storage failures must not157    // surface as sync failures.158    void setLastSyncAt(now).catch(() => undefined);159    void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);160  } else {161    setState({162      status: "error",163      error: firstError ?? "no_host_reachable",164    });165  }166}167168function setState(patch: Partial<SyncState>): void {169  const state = getSyncTriggerState();170  state.lastState = { ...state.lastState, ...patch };171  broadcast(state.lastState);172}173174function broadcast(state: SyncState): void {175  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;176  try {177    const maybeThenable = chrome.runtime.sendMessage({178      type: "SYNC_STATE_CHANGED",179      payload: state,180    });181    if (182      maybeThenable &&183      typeof (maybeThenable as Promise<unknown>).catch === "function"184    ) {185      (maybeThenable as Promise<unknown>).catch(() => {186        /* popup not listening — fine */187      });188    }189  } catch {190    /* messaging unavailable */191  }192}