sync-trigger.ts

13.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 * as vault from "./vault";14import { assertSyncAllowed, browserGenerationIo } from "./sync-generation";1516import {17  getLastSyncAt,18  getLastSyncResult,19  listPairedDevices,20  markLastSyncedAt,21  setLastSyncAt,22  setLastSyncResult,23} from "./paired-devices";24import type { PairedDevice } from "../shared/sync-types";25import {26  isConnectionClosedError,27  isSyncFailure,28  type SyncOutcome,29} from "../shared/sync-outcome";3031// `alarm`         — the 10-minute chrome.alarms background tick. The only32//                    trigger that survives MV3 service-worker death.33// `startup`       — first fire after the SW wakes (install, update, or34//                    post-kill revival). Makes the popup feel instant.35// `device_list_changed` — paired/unpaired/revoked, so a fresh peer should36//                    get a dial without waiting for the next alarm.37// `vault_change`  — a committed write in vault.ts.38// `foreground`    — the popup asked for a sync with that reason.39// `manual`        — the user pressed "Sync Now".40export type SyncTriggerReason =41  | "alarm"42  | "startup"43  | "device_list_changed"44  | "vault_change"45  | "foreground"46  | "manual";4748export type SyncRunStatus = "idle" | "syncing" | "error";4950export interface SyncState {51  status: SyncRunStatus;52  reason?: SyncTriggerReason;53  /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */54  lastSyncAt?: number;55  entriesReceived?: number;56  entriesSent?: number;57  error?: string;58  outcome?: SyncOutcome;59  /** Raw internal code, or the device inventory behind `no_eligible_host`. */60  detail?: string;61  finishedAt?: number;62}6364export type SyncRunResult =65  | { ok: true; entriesReceived: number; entriesSent: number }66  | { ok: false; error: string };6768export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;69export type SelectSyncDialTargets = (70  devices: PairedDevice[],71) => Promise<PairedDevice[]>;7273interface PeachSyncTriggerState {74  syncInFlight: boolean;75  lastState: SyncState;76  runner: RunSyncWithDevice | null;77  selectDialTargets: SelectSyncDialTargets | null;78}7980function getSyncTriggerState(): PeachSyncTriggerState {81  const g = globalThis as typeof globalThis & {82    __peachSyncTriggerState?: PeachSyncTriggerState;83  };84  if (!g.__peachSyncTriggerState) {85    g.__peachSyncTriggerState = {86      syncInFlight: false,87      lastState: { status: "idle" },88      runner: null,89      selectDialTargets: null,90    };91  }92  return g.__peachSyncTriggerState;93}9495export function setSyncRunner(fn: RunSyncWithDevice | null): void {96  getSyncTriggerState().runner = fn;97}9899export function setSyncDialTargetSelector(100  fn: SelectSyncDialTargets | null,101): void {102  getSyncTriggerState().selectDialTargets = fn;103}104105export function _resetSyncTriggerForTest(): void {106  const state = getSyncTriggerState();107  state.syncInFlight = false;108  state.lastState = { status: "idle" };109  state.runner = null;110  state.selectDialTargets = null;111}112113export function getSyncState(): SyncState {114  return getSyncTriggerState().lastState;115}116117/**118 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service119 * worker dies and restarts on the slightest provocation; without this the120 * popup's "Last synced N minutes ago" indicator resets to "never" every121 * time the SW respawns. Called once at startup from `entry.ts`.122 */123export async function hydratePersistedSyncState(): Promise<void> {124  const state = getSyncTriggerState();125  if (state.lastState.lastSyncAt === undefined) {126    const persisted = await getLastSyncAt();127    if (typeof persisted === "number") {128      state.lastState = { ...state.lastState, lastSyncAt: persisted };129    }130  }131  // The outcome of the last attempt survives too, otherwise a failure that132  // happened before the SW died is unreportable — which is the case a real133  // device most needs explained.134  if (state.lastState.outcome === undefined) {135    const last = await getLastSyncResult();136    if (last) {137      state.lastState = {138        ...state.lastState,139        outcome: last.outcome,140        detail: last.detail,141        finishedAt: last.at,142        entriesReceived: last.entriesReceived,143        entriesSent: last.entriesSent,144      };145    }146  }147}148export async function triggerSyncIfReachable(opts: {149  reason: SyncTriggerReason;150  force?: boolean;151}): Promise<void> {152  if (vault.isDecoyMode()) return;153  const state = getSyncTriggerState();154  // Claim the guard synchronously before any await, otherwise two155  // concurrent triggers could both pass the gate before either reached156  // the registry lookup.157  if (state.syncInFlight && !opts.force) {158    // Deliberately does NOT overwrite lastState — the in-flight run owns it.159    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);160    return;161  }162  state.syncInFlight = true;163164  try {165    // §11: a device that owes a secure reset, or whose generation cannot be166    // read, opens no session at all. Checked before the device list so the167    // refusal is reported as itself rather than as "no paired devices".168    const decision = await assertSyncAllowed(browserGenerationIo);169    if (decision !== "allowed") {170      finish({ outcome: decision });171      return;172    }173    const devices = await listPairedDevices();174    const routeCapable = devices.filter(175      (d) =>176        (177          d.deviceType === "extension" ||178          d.deviceType === "android" ||179          d.deviceType === "ios"180        ) &&181        (d.trustSource === "direct" || d.trustSource === "vouched"),182    );183    // Production resolves the symmetric key-order role here: lower static key184    // dials, higher static key is kept online by mesh-relay-listener. Tests185    // without the production selector retain the pre-mesh native-host default.186    const hosts = state.selectDialTargets187      ? await state.selectDialTargets(routeCapable)188      : routeCapable.filter(189          (d) => d.deviceType === "android" || d.deviceType === "ios",190        );191    if (devices.length === 0) {192      finish({ outcome: "no_paired_devices" });193      return;194    }195    if (hosts.length === 0) {196      if (routeCapable.length > 0 && state.selectDialTargets) {197        finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });198        return;199      }200      // The device inventory is the diagnostic: it says exactly which201      // deviceType/trustSource combination the eligibility filter rejected.202      finish({ outcome: "no_eligible_host", detail: inventory(devices) });203      return;204    }205    if (!state.runner) {206      finish({ outcome: "runner_unavailable" });207      return;208    }209210    setState({211      status: "syncing",212      reason: opts.reason,213      error: undefined,214      outcome: undefined,215      detail: undefined,216    });217    log(218      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,219    );220    await runSyncRounds(hosts);221  } finally {222    state.syncInFlight = false;223  }224}225226async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {227  const state = getSyncTriggerState();228  if (!state.runner) {229    finish({ outcome: "runner_unavailable" });230    return;231  }232233  let entriesReceived = 0;234  let entriesSent = 0;235  let anySucceeded = false;236  let firstError: string | undefined;237238  for (const host of hosts) {239    try {240      const result = await state.runner(host);241      if (!result.ok) {242        log(`host ${describeHost(host)} refused: ${result.error}`);243        if (firstError === undefined) firstError = result.error;244        continue;245      }246      entriesReceived += result.entriesReceived;247      entriesSent += result.entriesSent;248      if (vault.isDecoyMode()) return;249      await markLastSyncedAt(host.deviceId, Date.now());250      if (vault.isDecoyMode()) return;251      anySucceeded = true;252    } catch (err) {253      const message = (err as Error)?.message ?? String(err);254      log(`host ${describeHost(host)} threw: ${message}`);255      if (firstError === undefined) firstError = message;256    }257  }258259  if (vault.isDecoyMode()) return;260  if (anySucceeded) {261    const now = Date.now();262    finish({263      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",264      // A partial success still names the host that failed — otherwise "synced"265      // hides the peer that never got the change.266      detail: firstError,267      entriesReceived,268      entriesSent,269    });270    // Persist so the popup's "last synced" indicator survives a SW restart.271    // Best-effort — storage failures must not surface as sync failures.272    void Promise.resolve()273      .then(() => {274        if (vault.isDecoyMode()) return;275        return setLastSyncAt(now);276      })277      .catch(() => undefined);278  } else {279    const error = firstError ?? "no_host_reachable";280    finish({ outcome: classifySyncError(error), detail: error });281  }282}283284/**285 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any286 * throw). Mapping them here is the only place the popup's plain-English copy287 * can key off something stable.288 */289function classifySyncError(error: string): SyncOutcome {290  if (error.includes("VAULT_LOCKED")) return "vault_locked";291  if (error.includes("sync_disabled")) return "sync_disabled";292  if (error.includes("private_relay_unset")) return "private_relay_unset";293  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";294  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";295  if (error.includes("sync_timeout")) return "sync_timeout";296  // A transport that hung up mid-protocol. The alarm retries, and the peer may297  // well have applied what we already sent, so this is network-class (yellow)298  // rather than "the app is broken" (red). A close during the *dial* is excluded299  // here and falls to the branch below, where `relay_unreachable` is the more300  // specific diagnosis.301  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {302    return "connection_lost";303  }304  if (error.startsWith("relay_sync_failed")) {305    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)306      ? "handshake_failed"307      : "relay_unreachable";308  }309  return "run_failed";310}311312interface SyncFinish {313  outcome: SyncOutcome;314  detail?: string;315  entriesReceived?: number;316  entriesSent?: number;317}318319/**320 * The single exit for every terminating path. Nothing may `return` out of a321 * sync run without going through here — that silence is the bug this whole322 * module was reworked to remove.323 */324function finish(result: SyncFinish): void {325  const now = Date.now();326  const failed = isSyncFailure(result.outcome);327  const entriesReceived = result.entriesReceived ?? 0;328  const entriesSent = result.entriesSent ?? 0;329  setState({330    status: failed ? "error" : "idle",331    outcome: result.outcome,332    detail: result.detail,333    error: failed ? result.detail ?? result.outcome : undefined,334    finishedAt: now,335    entriesReceived,336    entriesSent,337    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"338      ? { lastSyncAt: now }339      : {}),340  });341  log(342    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +343      (entriesReceived + entriesSent > 0344        ? ` (sent ${entriesSent}, received ${entriesReceived})`345        : ""),346  );347  void Promise.resolve()348    .then(() => {349      if (vault.isDecoyMode()) return;350      return setLastSyncResult({351        outcome: result.outcome,352        detail: result.detail,353        at: now,354        entriesReceived,355        entriesSent,356      });357    })358    .catch(() => undefined);359}360361/** What the eligibility filter actually saw. Names, not just counts. */362function inventory(devices: PairedDevice[]): string {363  return devices.map(describeHost).join("; ");364}365366function describeHost(d: PairedDevice): string {367  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;368}369370/**371 * MV3 kills the service worker constantly, so devtools on the SW is the only372 * post-hoc record of a sync attempt. Prefixed so it can be filtered.373 */374function log(message: string): void {375  console.info(`[Peach sync] ${message}`);376}377378function setState(patch: Partial<SyncState>): void {379  const state = getSyncTriggerState();380  state.lastState = { ...state.lastState, ...patch };381  broadcast(state.lastState);382}383384function broadcast(state: SyncState): void {385  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;386  try {387    const maybeThenable = chrome.runtime.sendMessage({388      type: "SYNC_STATE_CHANGED",389      payload: state,390    });391    if (392      maybeThenable &&393      typeof (maybeThenable as Promise<unknown>).catch === "function"394    ) {395      (maybeThenable as Promise<unknown>).catch(() => {396        /* popup not listening — fine */397      });398    }399  } catch {400    /* messaging unavailable */401  }402}