sync-trigger.ts

13.6 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}117118export function recordInboundSyncCompletion(119  entriesReceived: number,120  entriesSent: number,121): void {122  finish({123    outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",124    entriesReceived,125    entriesSent,126  });127}128129/**130 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service131 * worker dies and restarts on the slightest provocation; without this the132 * popup's "Last synced N minutes ago" indicator resets to "never" every133 * time the SW respawns. Called once at startup from `entry.ts`.134 */135export async function hydratePersistedSyncState(): Promise<void> {136  const state = getSyncTriggerState();137  if (state.lastState.lastSyncAt === undefined) {138    const persisted = await getLastSyncAt();139    if (typeof persisted === "number") {140      state.lastState = { ...state.lastState, lastSyncAt: persisted };141    }142  }143  // The outcome of the last attempt survives too, otherwise a failure that144  // happened before the SW died is unreportable — which is the case a real145  // device most needs explained.146  if (state.lastState.outcome === undefined) {147    const last = await getLastSyncResult();148    if (last) {149      state.lastState = {150        ...state.lastState,151        outcome: last.outcome,152        detail: last.detail,153        finishedAt: last.at,154        entriesReceived: last.entriesReceived,155        entriesSent: last.entriesSent,156      };157    }158  }159}160export async function triggerSyncIfReachable(opts: {161  reason: SyncTriggerReason;162  force?: boolean;163}): Promise<void> {164  if (vault.isDecoyMode()) return;165  const state = getSyncTriggerState();166  // Claim the guard synchronously before any await, otherwise two167  // concurrent triggers could both pass the gate before either reached168  // the registry lookup.169  if (state.syncInFlight && !opts.force) {170    // Deliberately does NOT overwrite lastState — the in-flight run owns it.171    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);172    return;173  }174  state.syncInFlight = true;175176  try {177    // §11: a device that owes a secure reset, or whose generation cannot be178    // read, opens no session at all. Checked before the device list so the179    // refusal is reported as itself rather than as "no paired devices".180    const decision = await assertSyncAllowed(browserGenerationIo);181    if (decision !== "allowed") {182      finish({ outcome: decision });183      return;184    }185    let devices: PairedDevice[];186    try {187      devices = await listPairedDevices();188    } catch (err) {189      if (err instanceof TrustDataCorruptedError) {190        finish({ outcome: "trust_data_corrupted", detail: err.message });191        return;192      }193      throw err;194    }195    const routeCapable = devices.filter(196      (d) =>197        (198          d.deviceType === "extension" ||199          d.deviceType === "android" ||200          d.deviceType === "ios"201        ) &&202        (d.trustSource === "direct" || d.trustSource === "vouched"),203    );204    // Production resolves the symmetric key-order role here: lower static key205    // dials, higher static key is kept online by mesh-relay-listener. Tests206    // without the production selector retain the pre-mesh native-host default.207    const hosts = state.selectDialTargets208      ? await state.selectDialTargets(routeCapable)209      : routeCapable.filter(210          (d) => d.deviceType === "android" || d.deviceType === "ios",211        );212    if (devices.length === 0) {213      finish({ outcome: "no_paired_devices" });214      return;215    }216    if (hosts.length === 0) {217      if (routeCapable.length > 0 && state.selectDialTargets) {218        finish({ outcome: "waiting_for_peer", detail: inventory(routeCapable) });219        return;220      }221      // The device inventory is the diagnostic: it says exactly which222      // deviceType/trustSource combination the eligibility filter rejected.223      finish({ outcome: "no_eligible_host", detail: inventory(devices) });224      return;225    }226    if (!state.runner) {227      finish({ outcome: "runner_unavailable" });228      return;229    }230231    setState({232      status: "syncing",233      reason: opts.reason,234      error: undefined,235      outcome: undefined,236      detail: undefined,237    });238    log(239      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,240    );241    await runSyncRounds(hosts);242  } finally {243    state.syncInFlight = false;244  }245}246247async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {248  const state = getSyncTriggerState();249  if (!state.runner) {250    finish({ outcome: "runner_unavailable" });251    return;252  }253254  let entriesReceived = 0;255  let entriesSent = 0;256  let anySucceeded = false;257  let firstError: string | undefined;258259  for (const host of hosts) {260    try {261      const result = await state.runner(host);262      if (!result.ok) {263        log(`host ${describeHost(host)} refused: ${result.error}`);264        if (firstError === undefined) firstError = result.error;265        continue;266      }267      entriesReceived += result.entriesReceived;268      entriesSent += result.entriesSent;269      if (vault.isDecoyMode()) return;270      await markLastSyncedAt(host.deviceId, Date.now());271      if (vault.isDecoyMode()) return;272      anySucceeded = true;273    } catch (err) {274      const message = (err as Error)?.message ?? String(err);275      log(`host ${describeHost(host)} threw: ${message}`);276      if (firstError === undefined) firstError = message;277    }278  }279280  if (vault.isDecoyMode()) return;281  if (anySucceeded) {282    finish({283      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",284      // A partial success still names the host that failed — otherwise "synced"285      // hides the peer that never got the change.286      detail: firstError,287      entriesReceived,288      entriesSent,289    });290  } else {291    const error = firstError ?? "no_host_reachable";292    finish({ outcome: classifySyncError(error), detail: error });293  }294}295296/**297 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any298 * throw). Mapping them here is the only place the popup's plain-English copy299 * can key off something stable.300 */301function classifySyncError(error: string): SyncOutcome {302  if (error.includes("VAULT_LOCKED")) return "vault_locked";303  if (error.includes("sync_disabled")) return "sync_disabled";304  if (error.includes("private_relay_unset")) return "private_relay_unset";305  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";306  if (error.includes("trust_data_corrupted")) return "trust_data_corrupted";307  if (error.includes("sync_timeout")) return "sync_timeout";308  if (error.includes("PEER_PROTOCOL_OUTDATED")) return "peer_protocol_outdated";309  // A transport that hung up mid-protocol. The alarm retries, and the peer may310  // well have applied what we already sent, so this is network-class (yellow)311  // rather than "the app is broken" (red). A close during the *dial* is excluded312  // here and falls to the branch below, where `relay_unreachable` is the more313  // specific diagnosis.314  if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {315    return "connection_lost";316  }317  if (error.startsWith("relay_sync_failed")) {318    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)319      ? "handshake_failed"320      : "relay_unreachable";321  }322  return "run_failed";323}324325interface SyncFinish {326  outcome: SyncOutcome;327  detail?: string;328  entriesReceived?: number;329  entriesSent?: number;330}331332/**333 * The single exit for every terminating path. Nothing may `return` out of a334 * sync run without going through here — that silence is the bug this whole335 * module was reworked to remove.336 */337function finish(result: SyncFinish): void {338  const now = Date.now();339  const failed = isSyncFailure(result.outcome);340  const entriesReceived = result.entriesReceived ?? 0;341  const entriesSent = result.entriesSent ?? 0;342  setState({343    status: failed ? "error" : "idle",344    outcome: result.outcome,345    detail: result.detail,346    error: failed ? result.detail ?? result.outcome : undefined,347    finishedAt: now,348    entriesReceived,349    entriesSent,350    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"351      ? { lastSyncAt: now }352      : {}),353  });354  log(355    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +356      (entriesReceived + entriesSent > 0357        ? ` (sent ${entriesSent}, received ${entriesReceived})`358        : ""),359  );360  void Promise.resolve()361    .then(() => {362      if (vault.isDecoyMode()) return;363      const persisted = setLastSyncResult({364        outcome: result.outcome,365        detail: result.detail,366        at: now,367        entriesReceived,368        entriesSent,369      });370      if (result.outcome === "completed_changes" || result.outcome === "completed_no_changes") {371        return Promise.all([persisted, setLastSyncAt(now)]).then(() => undefined);372      }373      return persisted;374    })375    .catch(() => undefined);376}377378/** What the eligibility filter actually saw. Names, not just counts. */379function inventory(devices: PairedDevice[]): string {380  return devices.map(describeHost).join("; ");381}382383function describeHost(d: PairedDevice): string {384  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;385}386387/**388 * MV3 kills the service worker constantly, so devtools on the SW is the only389 * post-hoc record of a sync attempt. Prefixed so it can be filtered.390 */391function log(message: string): void {392  console.info(`[Peach sync] ${message}`);393}394395function setState(patch: Partial<SyncState>): void {396  const state = getSyncTriggerState();397  state.lastState = { ...state.lastState, ...patch };398  broadcast(state.lastState);399}400401function broadcast(state: SyncState): void {402  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;403  try {404    const maybeThenable = chrome.runtime.sendMessage({405      type: "SYNC_STATE_CHANGED",406      payload: state,407    });408    if (409      maybeThenable &&410      typeof (maybeThenable as Promise<unknown>).catch === "function"411    ) {412      (maybeThenable as Promise<unknown>).catch(() => {413        /* popup not listening — fine */414      });415    }416  } catch {417    /* messaging unavailable */418  }419}