sync-trigger.ts

11.7 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>;6667interface PeachSyncTriggerState {68  syncInFlight: boolean;69  lastState: SyncState;70  runner: RunSyncWithDevice | null;71}7273function getSyncTriggerState(): PeachSyncTriggerState {74  const g = globalThis as typeof globalThis & {75    __peachSyncTriggerState?: PeachSyncTriggerState;76  };77  if (!g.__peachSyncTriggerState) {78    g.__peachSyncTriggerState = {79      syncInFlight: false,80      lastState: { status: "idle" },81      runner: null,82    };83  }84  return g.__peachSyncTriggerState;85}8687export function setSyncRunner(fn: RunSyncWithDevice | null): void {88  getSyncTriggerState().runner = fn;89}9091export function _resetSyncTriggerForTest(): void {92  const state = getSyncTriggerState();93  state.syncInFlight = false;94  state.lastState = { status: "idle" };95  state.runner = null;96}9798export function getSyncState(): SyncState {99  return getSyncTriggerState().lastState;100}101102/**103 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service104 * worker dies and restarts on the slightest provocation; without this the105 * popup's "Last synced N minutes ago" indicator resets to "never" every106 * time the SW respawns. Called once at startup from `entry.ts`.107 */108export async function hydratePersistedSyncState(): Promise<void> {109  const state = getSyncTriggerState();110  if (state.lastState.lastSyncAt === undefined) {111    const persisted = await getLastSyncAt();112    if (typeof persisted === "number") {113      state.lastState = { ...state.lastState, lastSyncAt: persisted };114    }115  }116  // The outcome of the last attempt survives too, otherwise a failure that117  // happened before the SW died is unreportable — which is the case a real118  // device most needs explained.119  if (state.lastState.outcome === undefined) {120    const last = await getLastSyncResult();121    if (last) {122      state.lastState = {123        ...state.lastState,124        outcome: last.outcome,125        detail: last.detail,126        finishedAt: last.at,127        entriesReceived: last.entriesReceived,128        entriesSent: last.entriesSent,129      };130    }131  }132}133134export async function triggerSyncIfReachable(opts: {135  reason: SyncTriggerReason;136  force?: boolean;137}): Promise<void> {138  const state = getSyncTriggerState();139  // Claim the guard synchronously before any await, otherwise two140  // concurrent triggers could both pass the gate before either reached141  // the registry lookup.142  if (state.syncInFlight && !opts.force) {143    // Deliberately does NOT overwrite lastState — the in-flight run owns it.144    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);145    return;146  }147  state.syncInFlight = true;148149  try {150    const devices = await listPairedDevices();151    // Only native peers can accept a connection, so only they are sync hosts;152    // another extension is reachable solely via a relay, which is the relaying153    // device's job, not ours.154    //155    // "vouched" belongs here alongside "direct": a vouched device is one the156    // user has already confirmed out of the pending queue (MV-7), and the whole157    // point of mesh introduction is that it becomes reachable without its own158    // pairing code. Excluding it left confirmed devices permanently unsynced.159    const hosts = devices.filter(160      (d) =>161        (d.deviceType === "android" || d.deviceType === "ios") &&162        (d.trustSource === "direct" || d.trustSource === "vouched"),163    );164    if (devices.length === 0) {165      finish({ outcome: "no_paired_devices" });166      return;167    }168    if (hosts.length === 0) {169      // The device inventory is the diagnostic: it says exactly which170      // deviceType/trustSource combination the eligibility filter rejected.171      finish({ outcome: "no_eligible_host", detail: inventory(devices) });172      return;173    }174    if (!state.runner) {175      finish({ outcome: "runner_unavailable" });176      return;177    }178179    setState({180      status: "syncing",181      reason: opts.reason,182      error: undefined,183      outcome: undefined,184      detail: undefined,185    });186    log(187      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,188    );189    await runSyncRounds(hosts);190  } finally {191    state.syncInFlight = false;192  }193}194195async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {196  const state = getSyncTriggerState();197  if (!state.runner) {198    finish({ outcome: "runner_unavailable" });199    return;200  }201202  let entriesReceived = 0;203  let entriesSent = 0;204  let anySucceeded = false;205  let firstError: string | undefined;206207  for (const host of hosts) {208    try {209      const result = await state.runner(host);210      if (!result.ok) {211        log(`host ${describeHost(host)} refused: ${result.error}`);212        if (firstError === undefined) firstError = result.error;213        continue;214      }215      entriesReceived += result.entriesReceived;216      entriesSent += result.entriesSent;217      await markLastSyncedAt(host.deviceId, Date.now());218      anySucceeded = true;219    } catch (err) {220      const message = (err as Error)?.message ?? String(err);221      log(`host ${describeHost(host)} threw: ${message}`);222      if (firstError === undefined) firstError = message;223    }224  }225226  if (anySucceeded) {227    const now = Date.now();228    finish({229      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",230      // A partial success still names the host that failed — otherwise "synced"231      // hides the peer that never got the change.232      detail: firstError,233      entriesReceived,234      entriesSent,235    });236    // Persist so the popup's "last synced" indicator survives a SW restart.237    // Best-effort — storage failures must not surface as sync failures.238    void setLastSyncAt(now).catch(() => undefined);239  } else {240    const error = firstError ?? "no_host_reachable";241    finish({ outcome: classifySyncError(error), detail: error });242  }243}244245/**246 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any247 * throw). Mapping them here is the only place the popup's plain-English copy248 * can key off something stable.249 */250function classifySyncError(error: string): SyncOutcome {251  if (error.includes("VAULT_LOCKED")) return "vault_locked";252  if (error.includes("sync_disabled")) return "sync_disabled";253  if (error.includes("private_relay_unset")) return "private_relay_unset";254  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";255  if (error.includes("sync_timeout")) return "sync_timeout";256  // A transport that hung up mid-protocol. The alarm retries, and the peer may257  // well have applied what we already sent, so this is network-class (yellow)258  // rather than "the app is broken" (red). A close during the *dial* is excluded259  // here and falls to the branch below, where `relay_unreachable` is the more260  // specific diagnosis.261  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {262    return "connection_lost";263  }264  if (error.startsWith("relay_sync_failed")) {265    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)266      ? "handshake_failed"267      : "relay_unreachable";268  }269  return "run_failed";270}271272interface SyncFinish {273  outcome: SyncOutcome;274  detail?: string;275  entriesReceived?: number;276  entriesSent?: number;277}278279/**280 * The single exit for every terminating path. Nothing may `return` out of a281 * sync run without going through here — that silence is the bug this whole282 * module was reworked to remove.283 */284function finish(result: SyncFinish): void {285  const now = Date.now();286  const failed = isSyncFailure(result.outcome);287  const entriesReceived = result.entriesReceived ?? 0;288  const entriesSent = result.entriesSent ?? 0;289  setState({290    status: failed ? "error" : "idle",291    outcome: result.outcome,292    detail: result.detail,293    error: failed ? result.detail ?? result.outcome : undefined,294    finishedAt: now,295    entriesReceived,296    entriesSent,297    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"298      ? { lastSyncAt: now }299      : {}),300  });301  log(302    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +303      (entriesReceived + entriesSent > 0304        ? ` (sent ${entriesSent}, received ${entriesReceived})`305        : ""),306  );307  void setLastSyncResult({308    outcome: result.outcome,309    detail: result.detail,310    at: now,311    entriesReceived,312    entriesSent,313  }).catch(() => undefined);314}315316/** What the eligibility filter actually saw. Names, not just counts. */317function inventory(devices: PairedDevice[]): string {318  return devices.map(describeHost).join("; ");319}320321function describeHost(d: PairedDevice): string {322  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;323}324325/**326 * MV3 kills the service worker constantly, so devtools on the SW is the only327 * post-hoc record of a sync attempt. Prefixed so it can be filtered.328 */329function log(message: string): void {330  console.info(`[Peach sync] ${message}`);331}332333function setState(patch: Partial<SyncState>): void {334  const state = getSyncTriggerState();335  state.lastState = { ...state.lastState, ...patch };336  broadcast(state.lastState);337}338339function broadcast(state: SyncState): void {340  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;341  try {342    const maybeThenable = chrome.runtime.sendMessage({343      type: "SYNC_STATE_CHANGED",344      payload: state,345    });346    if (347      maybeThenable &&348      typeof (maybeThenable as Promise<unknown>).catch === "function"349    ) {350      (maybeThenable as Promise<unknown>).catch(() => {351        /* popup not listening — fine */352      });353    }354  } catch {355    /* messaging unavailable */356  }357}