sync-trigger.ts

6.2 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    // Only native peers can accept a connection, so only they are sync hosts;105    // another extension is reachable solely via a relay, which is the relaying106    // device's job, not ours.107    //108    // "vouched" belongs here alongside "direct": a vouched device is one the109    // user has already confirmed out of the pending queue (MV-7), and the whole110    // point of mesh introduction is that it becomes reachable without its own111    // pairing code. Excluding it left confirmed devices permanently unsynced.112    const hosts = devices.filter(113      (d) =>114        (d.deviceType === "android" ||115          d.deviceType === "ios" ||116          d.deviceType === "desktop") &&117        (d.trustSource === "direct" || d.trustSource === "vouched"),118    );119    if (hosts.length === 0) return;120    if (!state.runner) return;121122    setState({ status: "syncing", reason: opts.reason, error: undefined });123    await runSyncRounds(hosts);124  } finally {125    state.syncInFlight = false;126  }127}128129async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {130  const state = getSyncTriggerState();131  if (!state.runner) return;132133  let entriesReceived = 0;134  let entriesSent = 0;135  let anySucceeded = false;136  let firstError: string | undefined;137138  for (const host of hosts) {139    try {140      const result = await state.runner(host);141      if (!result.ok) {142        if (firstError === undefined) firstError = result.error;143        continue;144      }145      entriesReceived += result.entriesReceived;146      entriesSent += result.entriesSent;147      await markLastSyncedAt(host.deviceId, Date.now());148      anySucceeded = true;149    } catch (err) {150      if (firstError === undefined) {151        firstError = (err as Error)?.message ?? String(err);152      }153    }154  }155156  if (anySucceeded) {157    const now = Date.now();158    setState({159      status: "idle",160      lastSyncAt: now,161      entriesReceived,162      entriesSent,163      error: undefined,164    });165    // Persist so the popup's "last synced" indicator and entry counts166    // survive a SW restart. Best-effort — storage failures must not167    // surface as sync failures.168    void setLastSyncAt(now).catch(() => undefined);169    void setLastSyncResult({ entriesReceived, entriesSent }).catch(() => undefined);170  } else {171    setState({172      status: "error",173      error: firstError ?? "no_host_reachable",174    });175  }176}177178function setState(patch: Partial<SyncState>): void {179  const state = getSyncTriggerState();180  state.lastState = { ...state.lastState, ...patch };181  broadcast(state.lastState);182}183184function broadcast(state: SyncState): void {185  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;186  try {187    const maybeThenable = chrome.runtime.sendMessage({188      type: "SYNC_STATE_CHANGED",189      payload: state,190    });191    if (192      maybeThenable &&193      typeof (maybeThenable as Promise<unknown>).catch === "function"194    ) {195      (maybeThenable as Promise<unknown>).catch(() => {196        /* popup not listening — fine */197      });198    }199  } catch {200    /* messaging unavailable */201  }202}