sync-trigger.ts

12.2 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      if (routeCapable.length > 0 && state.selectDialTargets) {186        finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });187        return;188      }189      // The device inventory is the diagnostic: it says exactly which190      // deviceType/trustSource combination the eligibility filter rejected.191      finish({ outcome: "no_eligible_host", detail: inventory(devices) });192      return;193    }194    if (!state.runner) {195      finish({ outcome: "runner_unavailable" });196      return;197    }198199    setState({200      status: "syncing",201      reason: opts.reason,202      error: undefined,203      outcome: undefined,204      detail: undefined,205    });206    log(207      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,208    );209    await runSyncRounds(hosts);210  } finally {211    state.syncInFlight = false;212  }213}214215async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {216  const state = getSyncTriggerState();217  if (!state.runner) {218    finish({ outcome: "runner_unavailable" });219    return;220  }221222  let entriesReceived = 0;223  let entriesSent = 0;224  let anySucceeded = false;225  let firstError: string | undefined;226227  for (const host of hosts) {228    try {229      const result = await state.runner(host);230      if (!result.ok) {231        log(`host ${describeHost(host)} refused: ${result.error}`);232        if (firstError === undefined) firstError = result.error;233        continue;234      }235      entriesReceived += result.entriesReceived;236      entriesSent += result.entriesSent;237      await markLastSyncedAt(host.deviceId, Date.now());238      anySucceeded = true;239    } catch (err) {240      const message = (err as Error)?.message ?? String(err);241      log(`host ${describeHost(host)} threw: ${message}`);242      if (firstError === undefined) firstError = message;243    }244  }245246  if (anySucceeded) {247    const now = Date.now();248    finish({249      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",250      // A partial success still names the host that failed — otherwise "synced"251      // hides the peer that never got the change.252      detail: firstError,253      entriesReceived,254      entriesSent,255    });256    // Persist so the popup's "last synced" indicator survives a SW restart.257    // Best-effort — storage failures must not surface as sync failures.258    void setLastSyncAt(now).catch(() => undefined);259  } else {260    const error = firstError ?? "no_host_reachable";261    finish({ outcome: classifySyncError(error), detail: error });262  }263}264265/**266 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any267 * throw). Mapping them here is the only place the popup's plain-English copy268 * can key off something stable.269 */270function classifySyncError(error: string): SyncOutcome {271  if (error.includes("VAULT_LOCKED")) return "vault_locked";272  if (error.includes("sync_disabled")) return "sync_disabled";273  if (error.includes("private_relay_unset")) return "private_relay_unset";274  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";275  if (error.includes("sync_timeout")) return "sync_timeout";276  // A transport that hung up mid-protocol. The alarm retries, and the peer may277  // well have applied what we already sent, so this is network-class (yellow)278  // rather than "the app is broken" (red). A close during the *dial* is excluded279  // here and falls to the branch below, where `relay_unreachable` is the more280  // specific diagnosis.281  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {282    return "connection_lost";283  }284  if (error.startsWith("relay_sync_failed")) {285    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)286      ? "handshake_failed"287      : "relay_unreachable";288  }289  return "run_failed";290}291292interface SyncFinish {293  outcome: SyncOutcome;294  detail?: string;295  entriesReceived?: number;296  entriesSent?: number;297}298299/**300 * The single exit for every terminating path. Nothing may `return` out of a301 * sync run without going through here — that silence is the bug this whole302 * module was reworked to remove.303 */304function finish(result: SyncFinish): void {305  const now = Date.now();306  const failed = isSyncFailure(result.outcome);307  const entriesReceived = result.entriesReceived ?? 0;308  const entriesSent = result.entriesSent ?? 0;309  setState({310    status: failed ? "error" : "idle",311    outcome: result.outcome,312    detail: result.detail,313    error: failed ? result.detail ?? result.outcome : undefined,314    finishedAt: now,315    entriesReceived,316    entriesSent,317    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"318      ? { lastSyncAt: now }319      : {}),320  });321  log(322    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +323      (entriesReceived + entriesSent > 0324        ? ` (sent ${entriesSent}, received ${entriesReceived})`325        : ""),326  );327  void setLastSyncResult({328    outcome: result.outcome,329    detail: result.detail,330    at: now,331    entriesReceived,332    entriesSent,333  }).catch(() => undefined);334}335336/** What the eligibility filter actually saw. Names, not just counts. */337function inventory(devices: PairedDevice[]): string {338  return devices.map(describeHost).join("; ");339}340341function describeHost(d: PairedDevice): string {342  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;343}344345/**346 * MV3 kills the service worker constantly, so devtools on the SW is the only347 * post-hoc record of a sync attempt. Prefixed so it can be filtered.348 */349function log(message: string): void {350  console.info(`[Peach sync] ${message}`);351}352353function setState(patch: Partial<SyncState>): void {354  const state = getSyncTriggerState();355  state.lastState = { ...state.lastState, ...patch };356  broadcast(state.lastState);357}358359function broadcast(state: SyncState): void {360  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;361  try {362    const maybeThenable = chrome.runtime.sendMessage({363      type: "SYNC_STATE_CHANGED",364      payload: state,365    });366    if (367      maybeThenable &&368      typeof (maybeThenable as Promise<unknown>).catch === "function"369    ) {370      (maybeThenable as Promise<unknown>).catch(() => {371        /* popup not listening — fine */372      });373    }374  } catch {375    /* messaging unavailable */376  }377}