sync-trigger.ts

12.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 * as vault from "./vault";1415import {16  getLastSyncAt,17  getLastSyncResult,18  listPairedDevices,19  markLastSyncedAt,20  setLastSyncAt,21  setLastSyncResult,22} from "./paired-devices";23import type { PairedDevice } from "../shared/sync-types";24import {25  isConnectionClosedError,26  isSyncFailure,27  type SyncOutcome,28} from "../shared/sync-outcome";2930// `alarm`         — the 10-minute chrome.alarms background tick. The only31//                    trigger that survives MV3 service-worker death.32// `startup`       — first fire after the SW wakes (install, update, or33//                    post-kill revival). Makes the popup feel instant.34// `device_list_changed` — paired/unpaired/revoked, so a fresh peer should35//                    get a dial without waiting for the next alarm.36// `vault_change`  — a committed write in vault.ts.37// `foreground`    — the popup asked for a sync with that reason.38// `manual`        — the user pressed "Sync Now".39export type SyncTriggerReason =40  | "alarm"41  | "startup"42  | "device_list_changed"43  | "vault_change"44  | "foreground"45  | "manual";4647export type SyncRunStatus = "idle" | "syncing" | "error";4849export interface SyncState {50  status: SyncRunStatus;51  reason?: SyncTriggerReason;52  /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */53  lastSyncAt?: number;54  entriesReceived?: number;55  entriesSent?: number;56  error?: string;57  outcome?: SyncOutcome;58  /** Raw internal code, or the device inventory behind `no_eligible_host`. */59  detail?: string;60  finishedAt?: number;61}6263export type SyncRunResult =64  | { ok: true; entriesReceived: number; entriesSent: number }65  | { ok: false; error: string };6667export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;68export type SelectSyncDialTargets = (69  devices: PairedDevice[],70) => Promise<PairedDevice[]>;7172interface PeachSyncTriggerState {73  syncInFlight: boolean;74  lastState: SyncState;75  runner: RunSyncWithDevice | null;76  selectDialTargets: SelectSyncDialTargets | null;77}7879function getSyncTriggerState(): PeachSyncTriggerState {80  const g = globalThis as typeof globalThis & {81    __peachSyncTriggerState?: PeachSyncTriggerState;82  };83  if (!g.__peachSyncTriggerState) {84    g.__peachSyncTriggerState = {85      syncInFlight: false,86      lastState: { status: "idle" },87      runner: null,88      selectDialTargets: null,89    };90  }91  return g.__peachSyncTriggerState;92}9394export function setSyncRunner(fn: RunSyncWithDevice | null): void {95  getSyncTriggerState().runner = fn;96}9798export function setSyncDialTargetSelector(99  fn: SelectSyncDialTargets | null,100): void {101  getSyncTriggerState().selectDialTargets = fn;102}103104export function _resetSyncTriggerForTest(): void {105  const state = getSyncTriggerState();106  state.syncInFlight = false;107  state.lastState = { status: "idle" };108  state.runner = null;109  state.selectDialTargets = null;110}111112export function getSyncState(): SyncState {113  return getSyncTriggerState().lastState;114}115116/**117 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service118 * worker dies and restarts on the slightest provocation; without this the119 * popup's "Last synced N minutes ago" indicator resets to "never" every120 * time the SW respawns. Called once at startup from `entry.ts`.121 */122export async function hydratePersistedSyncState(): Promise<void> {123  const state = getSyncTriggerState();124  if (state.lastState.lastSyncAt === undefined) {125    const persisted = await getLastSyncAt();126    if (typeof persisted === "number") {127      state.lastState = { ...state.lastState, lastSyncAt: persisted };128    }129  }130  // The outcome of the last attempt survives too, otherwise a failure that131  // happened before the SW died is unreportable — which is the case a real132  // device most needs explained.133  if (state.lastState.outcome === undefined) {134    const last = await getLastSyncResult();135    if (last) {136      state.lastState = {137        ...state.lastState,138        outcome: last.outcome,139        detail: last.detail,140        finishedAt: last.at,141        entriesReceived: last.entriesReceived,142        entriesSent: last.entriesSent,143      };144    }145  }146}147export async function triggerSyncIfReachable(opts: {148  reason: SyncTriggerReason;149  force?: boolean;150}): Promise<void> {151  if (vault.isDecoyMode()) return;152  const state = getSyncTriggerState();153  // Claim the guard synchronously before any await, otherwise two154  // concurrent triggers could both pass the gate before either reached155  // the registry lookup.156  if (state.syncInFlight && !opts.force) {157    // Deliberately does NOT overwrite lastState — the in-flight run owns it.158    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);159    return;160  }161  state.syncInFlight = true;162163  try {164    const devices = await listPairedDevices();165    const routeCapable = devices.filter(166      (d) =>167        (168          d.deviceType === "extension" ||169          d.deviceType === "android" ||170          d.deviceType === "ios"171        ) &&172        (d.trustSource === "direct" || d.trustSource === "vouched"),173    );174    // Production resolves the symmetric key-order role here: lower static key175    // dials, higher static key is kept online by mesh-relay-listener. Tests176    // without the production selector retain the pre-mesh native-host default.177    const hosts = state.selectDialTargets178      ? await state.selectDialTargets(routeCapable)179      : routeCapable.filter(180          (d) => d.deviceType === "android" || d.deviceType === "ios",181        );182    if (devices.length === 0) {183      finish({ outcome: "no_paired_devices" });184      return;185    }186    if (hosts.length === 0) {187      if (routeCapable.length > 0 && state.selectDialTargets) {188        finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });189        return;190      }191      // The device inventory is the diagnostic: it says exactly which192      // deviceType/trustSource combination the eligibility filter rejected.193      finish({ outcome: "no_eligible_host", detail: inventory(devices) });194      return;195    }196    if (!state.runner) {197      finish({ outcome: "runner_unavailable" });198      return;199    }200201    setState({202      status: "syncing",203      reason: opts.reason,204      error: undefined,205      outcome: undefined,206      detail: undefined,207    });208    log(209      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,210    );211    await runSyncRounds(hosts);212  } finally {213    state.syncInFlight = false;214  }215}216217async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {218  const state = getSyncTriggerState();219  if (!state.runner) {220    finish({ outcome: "runner_unavailable" });221    return;222  }223224  let entriesReceived = 0;225  let entriesSent = 0;226  let anySucceeded = false;227  let firstError: string | undefined;228229  for (const host of hosts) {230    try {231      const result = await state.runner(host);232      if (!result.ok) {233        log(`host ${describeHost(host)} refused: ${result.error}`);234        if (firstError === undefined) firstError = result.error;235        continue;236      }237      entriesReceived += result.entriesReceived;238      entriesSent += result.entriesSent;239      if (vault.isDecoyMode()) return;240      await markLastSyncedAt(host.deviceId, Date.now());241      if (vault.isDecoyMode()) return;242      anySucceeded = true;243    } catch (err) {244      const message = (err as Error)?.message ?? String(err);245      log(`host ${describeHost(host)} threw: ${message}`);246      if (firstError === undefined) firstError = message;247    }248  }249250  if (vault.isDecoyMode()) return;251  if (anySucceeded) {252    const now = Date.now();253    finish({254      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",255      // A partial success still names the host that failed — otherwise "synced"256      // hides the peer that never got the change.257      detail: firstError,258      entriesReceived,259      entriesSent,260    });261    // Persist so the popup's "last synced" indicator survives a SW restart.262    // Best-effort — storage failures must not surface as sync failures.263    void Promise.resolve()264      .then(() => {265        if (vault.isDecoyMode()) return;266        return setLastSyncAt(now);267      })268      .catch(() => undefined);269  } else {270    const error = firstError ?? "no_host_reachable";271    finish({ outcome: classifySyncError(error), detail: error });272  }273}274275/**276 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any277 * throw). Mapping them here is the only place the popup's plain-English copy278 * can key off something stable.279 */280function classifySyncError(error: string): SyncOutcome {281  if (error.includes("VAULT_LOCKED")) return "vault_locked";282  if (error.includes("sync_disabled")) return "sync_disabled";283  if (error.includes("private_relay_unset")) return "private_relay_unset";284  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";285  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";286  if (error.includes("sync_timeout")) return "sync_timeout";287  // A transport that hung up mid-protocol. The alarm retries, and the peer may288  // well have applied what we already sent, so this is network-class (yellow)289  // rather than "the app is broken" (red). A close during the *dial* is excluded290  // here and falls to the branch below, where `relay_unreachable` is the more291  // specific diagnosis.292  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {293    return "connection_lost";294  }295  if (error.startsWith("relay_sync_failed")) {296    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)297      ? "handshake_failed"298      : "relay_unreachable";299  }300  return "run_failed";301}302303interface SyncFinish {304  outcome: SyncOutcome;305  detail?: string;306  entriesReceived?: number;307  entriesSent?: number;308}309310/**311 * The single exit for every terminating path. Nothing may `return` out of a312 * sync run without going through here — that silence is the bug this whole313 * module was reworked to remove.314 */315function finish(result: SyncFinish): void {316  const now = Date.now();317  const failed = isSyncFailure(result.outcome);318  const entriesReceived = result.entriesReceived ?? 0;319  const entriesSent = result.entriesSent ?? 0;320  setState({321    status: failed ? "error" : "idle",322    outcome: result.outcome,323    detail: result.detail,324    error: failed ? result.detail ?? result.outcome : undefined,325    finishedAt: now,326    entriesReceived,327    entriesSent,328    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"329      ? { lastSyncAt: now }330      : {}),331  });332  log(333    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +334      (entriesReceived + entriesSent > 0335        ? ` (sent ${entriesSent}, received ${entriesReceived})`336        : ""),337  );338  void Promise.resolve()339    .then(() => {340      if (vault.isDecoyMode()) return;341      return setLastSyncResult({342        outcome: result.outcome,343        detail: result.detail,344        at: now,345        entriesReceived,346        entriesSent,347      });348    })349    .catch(() => undefined);350}351352/** What the eligibility filter actually saw. Names, not just counts. */353function inventory(devices: PairedDevice[]): string {354  return devices.map(describeHost).join("; ");355}356357function describeHost(d: PairedDevice): string {358  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;359}360361/**362 * MV3 kills the service worker constantly, so devtools on the SW is the only363 * post-hoc record of a sync attempt. Prefixed so it can be filtered.364 */365function log(message: string): void {366  console.info(`[Peach sync] ${message}`);367}368369function setState(patch: Partial<SyncState>): void {370  const state = getSyncTriggerState();371  state.lastState = { ...state.lastState, ...patch };372  broadcast(state.lastState);373}374375function broadcast(state: SyncState): void {376  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;377  try {378    const maybeThenable = chrome.runtime.sendMessage({379      type: "SYNC_STATE_CHANGED",380      payload: state,381    });382    if (383      maybeThenable &&384      typeof (maybeThenable as Promise<unknown>).catch === "function"385    ) {386      (maybeThenable as Promise<unknown>).catch(() => {387        /* popup not listening — fine */388      });389    }390  } catch {391    /* messaging unavailable */392  }393}