sync-trigger.ts

12.0 KB · raw

1// sync-trigger.ts — central sync dispatcher.2//3// SEV-1-A fix: the sync coordinator + transport were complete but4// never invoked from production code paths. This module is the single5// entry point for the three trigger conditions (vault change, popup6// foreground, and manual "Sync Now") and is the7// only place that calls `runSyncInitiator` 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 `productionSyncRunner` from `./sync-runner` at module load.1213import {14  getLastSyncAt,15  getLastSyncResult,16  listPairedDevices,17  markLastSyncedAt,18  setLastSyncAt,19  setLastSyncResult,20} from "./paired-devices";21import type { PairedDevice } from "../shared/sync-types";22import {23  isConnectionClosedError,24  isSyncFailure,25  type SyncOutcome,26} from "../shared/sync-outcome";2728// `alarm`         — the 10-minute chrome.alarms background tick. The only29//                    trigger that survives MV3 service-worker death.30// `startup`       — first fire after the SW wakes (install, update, or31//                    post-kill revival). Makes the popup feel instant.32// `device_list_changed` — paired/unpaired/revoked, so a fresh peer should33//                    get a dial without waiting for the next alarm.34// `vault_change`  — a committed write in vault.ts.35// `foreground`    — the popup asked for a sync with that reason.36// `manual`        — the user pressed "Sync Now".37export type SyncTriggerReason =38  | "alarm"39  | "startup"40  | "device_list_changed"41  | "vault_change"42  | "foreground"43  | "manual";4445export type SyncRunStatus = "idle" | "syncing" | "error";4647export interface SyncState {48  status: SyncRunStatus;49  reason?: SyncTriggerReason;50  /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */51  lastSyncAt?: number;52  entriesReceived?: number;53  entriesSent?: number;54  error?: string;55  outcome?: SyncOutcome;56  /** Raw internal code, or the device inventory behind `no_eligible_host`. */57  detail?: string;58  finishedAt?: number;59}6061export type SyncRunResult =62  | { ok: true; entriesReceived: number; entriesSent: number }63  | { ok: false; error: string };6465export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;66export type SelectSyncDialTargets = (67  devices: PairedDevice[],68) => Promise<PairedDevice[]>;6970interface PeachSyncTriggerState {71  syncInFlight: boolean;72  lastState: SyncState;73  runner: RunSyncWithDevice | null;74  selectDialTargets: SelectSyncDialTargets | null;75}7677function getSyncTriggerState(): PeachSyncTriggerState {78  const g = globalThis as typeof globalThis & {79    __peachSyncTriggerState?: PeachSyncTriggerState;80  };81  if (!g.__peachSyncTriggerState) {82    g.__peachSyncTriggerState = {83      syncInFlight: false,84      lastState: { status: "idle" },85      runner: null,86      selectDialTargets: null,87    };88  }89  return g.__peachSyncTriggerState;90}9192export function setSyncRunner(fn: RunSyncWithDevice | null): void {93  getSyncTriggerState().runner = fn;94}9596export function setSyncDialTargetSelector(97  fn: SelectSyncDialTargets | null,98): void {99  getSyncTriggerState().selectDialTargets = fn;100}101102export function _resetSyncTriggerForTest(): void {103  const state = getSyncTriggerState();104  state.syncInFlight = false;105  state.lastState = { status: "idle" };106  state.runner = null;107  state.selectDialTargets = null;108}109110export function getSyncState(): SyncState {111  return getSyncTriggerState().lastState;112}113114/**115 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service116 * worker dies and restarts on the slightest provocation; without this the117 * popup's "Last synced N minutes ago" indicator resets to "never" every118 * time the SW respawns. Called once at startup from `entry.ts`.119 */120export async function hydratePersistedSyncState(): Promise<void> {121  const state = getSyncTriggerState();122  if (state.lastState.lastSyncAt === undefined) {123    const persisted = await getLastSyncAt();124    if (typeof persisted === "number") {125      state.lastState = { ...state.lastState, lastSyncAt: persisted };126    }127  }128  // The outcome of the last attempt survives too, otherwise a failure that129  // happened before the SW died is unreportable — which is the case a real130  // device most needs explained.131  if (state.lastState.outcome === undefined) {132    const last = await getLastSyncResult();133    if (last) {134      state.lastState = {135        ...state.lastState,136        outcome: last.outcome,137        detail: last.detail,138        finishedAt: last.at,139        entriesReceived: last.entriesReceived,140        entriesSent: last.entriesSent,141      };142    }143  }144}145146export async function triggerSyncIfReachable(opts: {147  reason: SyncTriggerReason;148  force?: boolean;149}): Promise<void> {150  const state = getSyncTriggerState();151  // Claim the guard synchronously before any await, otherwise two152  // concurrent triggers could both pass the gate before either reached153  // the registry lookup.154  if (state.syncInFlight && !opts.force) {155    // Deliberately does NOT overwrite lastState — the in-flight run owns it.156    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);157    return;158  }159  state.syncInFlight = true;160161  try {162    const devices = await listPairedDevices();163    const routeCapable = devices.filter(164      (d) =>165        (166          d.deviceType === "extension" ||167          d.deviceType === "android" ||168          d.deviceType === "ios"169        ) &&170        (d.trustSource === "direct" || d.trustSource === "vouched"),171    );172    // Production resolves the symmetric key-order role here: lower static key173    // dials, higher static key is kept online by mesh-relay-listener. Tests174    // without the production selector retain the pre-mesh native-host default.175    const hosts = state.selectDialTargets176      ? await state.selectDialTargets(routeCapable)177      : routeCapable.filter(178          (d) => d.deviceType === "android" || d.deviceType === "ios",179        );180    if (devices.length === 0) {181      finish({ outcome: "no_paired_devices" });182      return;183    }184    if (hosts.length === 0) {185      // The device inventory is the diagnostic: it says exactly which186      // deviceType/trustSource combination the eligibility filter rejected.187      finish({ outcome: "no_eligible_host", detail: inventory(devices) });188      return;189    }190    if (!state.runner) {191      finish({ outcome: "runner_unavailable" });192      return;193    }194195    setState({196      status: "syncing",197      reason: opts.reason,198      error: undefined,199      outcome: undefined,200      detail: undefined,201    });202    log(203      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,204    );205    await runSyncRounds(hosts);206  } finally {207    state.syncInFlight = false;208  }209}210211async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {212  const state = getSyncTriggerState();213  if (!state.runner) {214    finish({ outcome: "runner_unavailable" });215    return;216  }217218  let entriesReceived = 0;219  let entriesSent = 0;220  let anySucceeded = false;221  let firstError: string | undefined;222223  for (const host of hosts) {224    try {225      const result = await state.runner(host);226      if (!result.ok) {227        log(`host ${describeHost(host)} refused: ${result.error}`);228        if (firstError === undefined) firstError = result.error;229        continue;230      }231      entriesReceived += result.entriesReceived;232      entriesSent += result.entriesSent;233      await markLastSyncedAt(host.deviceId, Date.now());234      anySucceeded = true;235    } catch (err) {236      const message = (err as Error)?.message ?? String(err);237      log(`host ${describeHost(host)} threw: ${message}`);238      if (firstError === undefined) firstError = message;239    }240  }241242  if (anySucceeded) {243    const now = Date.now();244    finish({245      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",246      // A partial success still names the host that failed — otherwise "synced"247      // hides the peer that never got the change.248      detail: firstError,249      entriesReceived,250      entriesSent,251    });252    // Persist so the popup's "last synced" indicator survives a SW restart.253    // Best-effort — storage failures must not surface as sync failures.254    void setLastSyncAt(now).catch(() => undefined);255  } else {256    const error = firstError ?? "no_host_reachable";257    finish({ outcome: classifySyncError(error), detail: error });258  }259}260261/**262 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any263 * throw). Mapping them here is the only place the popup's plain-English copy264 * can key off something stable.265 */266function classifySyncError(error: string): SyncOutcome {267  if (error.includes("VAULT_LOCKED")) return "vault_locked";268  if (error.includes("sync_disabled")) return "sync_disabled";269  if (error.includes("private_relay_unset")) return "private_relay_unset";270  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";271  if (error.includes("sync_timeout")) return "sync_timeout";272  // A transport that hung up mid-protocol. The alarm retries, and the peer may273  // well have applied what we already sent, so this is network-class (yellow)274  // rather than "the app is broken" (red). A close during the *dial* is excluded275  // here and falls to the branch below, where `relay_unreachable` is the more276  // specific diagnosis.277  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {278    return "connection_lost";279  }280  if (error.startsWith("relay_sync_failed")) {281    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)282      ? "handshake_failed"283      : "relay_unreachable";284  }285  return "run_failed";286}287288interface SyncFinish {289  outcome: SyncOutcome;290  detail?: string;291  entriesReceived?: number;292  entriesSent?: number;293}294295/**296 * The single exit for every terminating path. Nothing may `return` out of a297 * sync run without going through here — that silence is the bug this whole298 * module was reworked to remove.299 */300function finish(result: SyncFinish): void {301  const now = Date.now();302  const failed = isSyncFailure(result.outcome);303  const entriesReceived = result.entriesReceived ?? 0;304  const entriesSent = result.entriesSent ?? 0;305  setState({306    status: failed ? "error" : "idle",307    outcome: result.outcome,308    detail: result.detail,309    error: failed ? result.detail ?? result.outcome : undefined,310    finishedAt: now,311    entriesReceived,312    entriesSent,313    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"314      ? { lastSyncAt: now }315      : {}),316  });317  log(318    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +319      (entriesReceived + entriesSent > 0320        ? ` (sent ${entriesSent}, received ${entriesReceived})`321        : ""),322  );323  void setLastSyncResult({324    outcome: result.outcome,325    detail: result.detail,326    at: now,327    entriesReceived,328    entriesSent,329  }).catch(() => undefined);330}331332/** What the eligibility filter actually saw. Names, not just counts. */333function inventory(devices: PairedDevice[]): string {334  return devices.map(describeHost).join("; ");335}336337function describeHost(d: PairedDevice): string {338  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;339}340341/**342 * MV3 kills the service worker constantly, so devtools on the SW is the only343 * post-hoc record of a sync attempt. Prefixed so it can be filtered.344 */345function log(message: string): void {346  console.info(`[Peach sync] ${message}`);347}348349function setState(patch: Partial<SyncState>): void {350  const state = getSyncTriggerState();351  state.lastState = { ...state.lastState, ...patch };352  broadcast(state.lastState);353}354355function broadcast(state: SyncState): void {356  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;357  try {358    const maybeThenable = chrome.runtime.sendMessage({359      type: "SYNC_STATE_CHANGED",360      payload: state,361    });362    if (363      maybeThenable &&364      typeof (maybeThenable as Promise<unknown>).catch === "function"365    ) {366      (maybeThenable as Promise<unknown>).catch(() => {367        /* popup not listening — fine */368      });369    }370  } catch {371    /* messaging unavailable */372  }373}