sync-trigger.ts

13.5 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";14import { assertSyncAllowed, browserGenerationIo } from "./sync-generation";1516import {17  getLastSyncAt,18  getLastSyncResult,19  listPairedDevices,20  markLastSyncedAt,21  setLastSyncAt,22  setLastSyncResult,23  TrustDataCorruptedError,24} from "./paired-devices";25import type { PairedDevice } from "../shared/sync-types";26import {27  isConnectionClosedError,28  isSyncFailure,29  type SyncOutcome,30} from "../shared/sync-outcome";3132// `alarm`         — the 10-minute chrome.alarms background tick. The only33//                    trigger that survives MV3 service-worker death.34// `startup`       — first fire after the SW wakes (install, update, or35//                    post-kill revival). Makes the popup feel instant.36// `device_list_changed` — paired/unpaired/revoked, so a fresh peer should37//                    get a dial without waiting for the next alarm.38// `vault_change`  — a committed write in vault.ts.39// `foreground`    — the popup asked for a sync with that reason.40// `manual`        — the user pressed "Sync Now".41export type SyncTriggerReason =42  | "alarm"43  | "startup"44  | "device_list_changed"45  | "vault_change"46  | "foreground"47  | "manual";4849export type SyncRunStatus = "idle" | "syncing" | "error";5051export interface SyncState {52  status: SyncRunStatus;53  reason?: SyncTriggerReason;54  /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */55  lastSyncAt?: number;56  entriesReceived?: number;57  entriesSent?: number;58  error?: string;59  outcome?: SyncOutcome;60  /** Raw internal code, or the device inventory behind `no_eligible_host`. */61  detail?: string;62  finishedAt?: number;63}6465export type SyncRunResult =66  | { ok: true; entriesReceived: number; entriesSent: number }67  | { ok: false; error: string };6869export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;70export type SelectSyncDialTargets = (71  devices: PairedDevice[],72) => Promise<PairedDevice[]>;7374interface PeachSyncTriggerState {75  syncInFlight: boolean;76  lastState: SyncState;77  runner: RunSyncWithDevice | null;78  selectDialTargets: SelectSyncDialTargets | null;79}8081function getSyncTriggerState(): PeachSyncTriggerState {82  const g = globalThis as typeof globalThis & {83    __peachSyncTriggerState?: PeachSyncTriggerState;84  };85  if (!g.__peachSyncTriggerState) {86    g.__peachSyncTriggerState = {87      syncInFlight: false,88      lastState: { status: "idle" },89      runner: null,90      selectDialTargets: null,91    };92  }93  return g.__peachSyncTriggerState;94}9596export function setSyncRunner(fn: RunSyncWithDevice | null): void {97  getSyncTriggerState().runner = fn;98}99100export function setSyncDialTargetSelector(101  fn: SelectSyncDialTargets | null,102): void {103  getSyncTriggerState().selectDialTargets = fn;104}105106export function _resetSyncTriggerForTest(): void {107  const state = getSyncTriggerState();108  state.syncInFlight = false;109  state.lastState = { status: "idle" };110  state.runner = null;111  state.selectDialTargets = null;112}113114export function getSyncState(): SyncState {115  return getSyncTriggerState().lastState;116}117118/**119 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service120 * worker dies and restarts on the slightest provocation; without this the121 * popup's "Last synced N minutes ago" indicator resets to "never" every122 * time the SW respawns. Called once at startup from `entry.ts`.123 */124export async function hydratePersistedSyncState(): Promise<void> {125  const state = getSyncTriggerState();126  if (state.lastState.lastSyncAt === undefined) {127    const persisted = await getLastSyncAt();128    if (typeof persisted === "number") {129      state.lastState = { ...state.lastState, lastSyncAt: persisted };130    }131  }132  // The outcome of the last attempt survives too, otherwise a failure that133  // happened before the SW died is unreportable — which is the case a real134  // device most needs explained.135  if (state.lastState.outcome === undefined) {136    const last = await getLastSyncResult();137    if (last) {138      state.lastState = {139        ...state.lastState,140        outcome: last.outcome,141        detail: last.detail,142        finishedAt: last.at,143        entriesReceived: last.entriesReceived,144        entriesSent: last.entriesSent,145      };146    }147  }148}149export async function triggerSyncIfReachable(opts: {150  reason: SyncTriggerReason;151  force?: boolean;152}): Promise<void> {153  if (vault.isDecoyMode()) return;154  const state = getSyncTriggerState();155  // Claim the guard synchronously before any await, otherwise two156  // concurrent triggers could both pass the gate before either reached157  // the registry lookup.158  if (state.syncInFlight && !opts.force) {159    // Deliberately does NOT overwrite lastState — the in-flight run owns it.160    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);161    return;162  }163  state.syncInFlight = true;164165  try {166    // §11: a device that owes a secure reset, or whose generation cannot be167    // read, opens no session at all. Checked before the device list so the168    // refusal is reported as itself rather than as "no paired devices".169    const decision = await assertSyncAllowed(browserGenerationIo);170    if (decision !== "allowed") {171      finish({ outcome: decision });172      return;173    }174    let devices: PairedDevice[];175    try {176      devices = await listPairedDevices();177    } catch (err) {178      if (err instanceof TrustDataCorruptedError) {179        finish({ outcome: "trust_data_corrupted", detail: err.message });180        return;181      }182      throw err;183    }184    const routeCapable = devices.filter(185      (d) =>186        (187          d.deviceType === "extension" ||188          d.deviceType === "android" ||189          d.deviceType === "ios"190        ) &&191        (d.trustSource === "direct" || d.trustSource === "vouched"),192    );193    // Production resolves the symmetric key-order role here: lower static key194    // dials, higher static key is kept online by mesh-relay-listener. Tests195    // without the production selector retain the pre-mesh native-host default.196    const hosts = state.selectDialTargets197      ? await state.selectDialTargets(routeCapable)198      : routeCapable.filter(199          (d) => d.deviceType === "android" || d.deviceType === "ios",200        );201    if (devices.length === 0) {202      finish({ outcome: "no_paired_devices" });203      return;204    }205    if (hosts.length === 0) {206      if (routeCapable.length > 0 && state.selectDialTargets) {207        finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });208        return;209      }210      // The device inventory is the diagnostic: it says exactly which211      // deviceType/trustSource combination the eligibility filter rejected.212      finish({ outcome: "no_eligible_host", detail: inventory(devices) });213      return;214    }215    if (!state.runner) {216      finish({ outcome: "runner_unavailable" });217      return;218    }219220    setState({221      status: "syncing",222      reason: opts.reason,223      error: undefined,224      outcome: undefined,225      detail: undefined,226    });227    log(228      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,229    );230    await runSyncRounds(hosts);231  } finally {232    state.syncInFlight = false;233  }234}235236async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {237  const state = getSyncTriggerState();238  if (!state.runner) {239    finish({ outcome: "runner_unavailable" });240    return;241  }242243  let entriesReceived = 0;244  let entriesSent = 0;245  let anySucceeded = false;246  let firstError: string | undefined;247248  for (const host of hosts) {249    try {250      const result = await state.runner(host);251      if (!result.ok) {252        log(`host ${describeHost(host)} refused: ${result.error}`);253        if (firstError === undefined) firstError = result.error;254        continue;255      }256      entriesReceived += result.entriesReceived;257      entriesSent += result.entriesSent;258      if (vault.isDecoyMode()) return;259      await markLastSyncedAt(host.deviceId, Date.now());260      if (vault.isDecoyMode()) return;261      anySucceeded = true;262    } catch (err) {263      const message = (err as Error)?.message ?? String(err);264      log(`host ${describeHost(host)} threw: ${message}`);265      if (firstError === undefined) firstError = message;266    }267  }268269  if (vault.isDecoyMode()) return;270  if (anySucceeded) {271    const now = Date.now();272    finish({273      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",274      // A partial success still names the host that failed — otherwise "synced"275      // hides the peer that never got the change.276      detail: firstError,277      entriesReceived,278      entriesSent,279    });280    // Persist so the popup's "last synced" indicator survives a SW restart.281    // Best-effort — storage failures must not surface as sync failures.282    void Promise.resolve()283      .then(() => {284        if (vault.isDecoyMode()) return;285        return setLastSyncAt(now);286      })287      .catch(() => undefined);288  } else {289    const error = firstError ?? "no_host_reachable";290    finish({ outcome: classifySyncError(error), detail: error });291  }292}293294/**295 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any296 * throw). Mapping them here is the only place the popup's plain-English copy297 * can key off something stable.298 */299function classifySyncError(error: string): SyncOutcome {300  if (error.includes("VAULT_LOCKED")) return "vault_locked";301  if (error.includes("sync_disabled")) return "sync_disabled";302  if (error.includes("private_relay_unset")) return "private_relay_unset";303  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";304  if (error.includes("trust_data_corrupted")) return "trust_data_corrupted";305  if (error.includes("sync_timeout")) return "sync_timeout";306  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";307  // A transport that hung up mid-protocol. The alarm retries, and the peer may308  // well have applied what we already sent, so this is network-class (yellow)309  // rather than "the app is broken" (red). A close during the *dial* is excluded310  // here and falls to the branch below, where `relay_unreachable` is the more311  // specific diagnosis.312  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {313    return "connection_lost";314  }315  if (error.startsWith("relay_sync_failed")) {316    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)317      ? "handshake_failed"318      : "relay_unreachable";319  }320  return "run_failed";321}322323interface SyncFinish {324  outcome: SyncOutcome;325  detail?: string;326  entriesReceived?: number;327  entriesSent?: number;328}329330/**331 * The single exit for every terminating path. Nothing may `return` out of a332 * sync run without going through here — that silence is the bug this whole333 * module was reworked to remove.334 */335function finish(result: SyncFinish): void {336  const now = Date.now();337  const failed = isSyncFailure(result.outcome);338  const entriesReceived = result.entriesReceived ?? 0;339  const entriesSent = result.entriesSent ?? 0;340  setState({341    status: failed ? "error" : "idle",342    outcome: result.outcome,343    detail: result.detail,344    error: failed ? result.detail ?? result.outcome : undefined,345    finishedAt: now,346    entriesReceived,347    entriesSent,348    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"349      ? { lastSyncAt: now }350      : {}),351  });352  log(353    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +354      (entriesReceived + entriesSent > 0355        ? ` (sent ${entriesSent}, received ${entriesReceived})`356        : ""),357  );358  void Promise.resolve()359    .then(() => {360      if (vault.isDecoyMode()) return;361      return setLastSyncResult({362        outcome: result.outcome,363        detail: result.detail,364        at: now,365        entriesReceived,366        entriesSent,367      });368    })369    .catch(() => undefined);370}371372/** What the eligibility filter actually saw. Names, not just counts. */373function inventory(devices: PairedDevice[]): string {374  return devices.map(describeHost).join("; ");375}376377function describeHost(d: PairedDevice): string {378  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;379}380381/**382 * MV3 kills the service worker constantly, so devtools on the SW is the only383 * post-hoc record of a sync attempt. Prefixed so it can be filtered.384 */385function log(message: string): void {386  console.info(`[Peach sync] ${message}`);387}388389function setState(patch: Partial<SyncState>): void {390  const state = getSyncTriggerState();391  state.lastState = { ...state.lastState, ...patch };392  broadcast(state.lastState);393}394395function broadcast(state: SyncState): void {396  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;397  try {398    const maybeThenable = chrome.runtime.sendMessage({399      type: "SYNC_STATE_CHANGED",400      payload: state,401    });402    if (403      maybeThenable &&404      typeof (maybeThenable as Promise<unknown>).catch === "function"405    ) {406      (maybeThenable as Promise<unknown>).catch(() => {407        /* popup not listening — fine */408      });409    }410  } catch {411    /* messaging unavailable */412  }413}