sync-trigger.ts

11.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 {14  getLastSyncAt,15  getLastSyncResult,16  listPairedDevices,17  markLastSyncedAt,18  setLastSyncAt,19  setLastSyncResult,20} from "./paired-devices";21import type { PairedDevice } from "../shared/sync-types";22import { isSyncFailure, type SyncOutcome } from "../shared/sync-outcome";2324// `alarm`         — the 10-minute chrome.alarms background tick. The only25//                    trigger that survives MV3 service-worker death.26// `startup`       — first fire after the SW wakes (install, update, or27//                    post-kill revival). Makes the popup feel instant.28// `device_list_changed` — paired/unpaired/revoked, so a fresh peer should29//                    get a dial without waiting for the next alarm.30// `vault_change`  — a committed write in vault.ts.31// `foreground`    — the popup asked for a sync with that reason.32// `manual`        — the user pressed "Sync Now".33export type SyncTriggerReason =34  | "alarm"35  | "startup"36  | "device_list_changed"37  | "vault_change"38  | "foreground"39  | "manual";4041export type SyncRunStatus = "idle" | "syncing" | "error";4243export interface SyncState {44  status: SyncRunStatus;45  reason?: SyncTriggerReason;46  /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */47  lastSyncAt?: number;48  entriesReceived?: number;49  entriesSent?: number;50  error?: string;51  outcome?: SyncOutcome;52  /** Raw internal code, or the device inventory behind `no_eligible_host`. */53  detail?: string;54  finishedAt?: number;55}5657export type SyncRunResult =58  | { ok: true; entriesReceived: number; entriesSent: number }59  | { ok: false; error: string };6061export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;6263interface PeachSyncTriggerState {64  syncInFlight: boolean;65  lastState: SyncState;66  runner: RunSyncWithDevice | null;67}6869function getSyncTriggerState(): PeachSyncTriggerState {70  const g = globalThis as typeof globalThis & {71    __peachSyncTriggerState?: PeachSyncTriggerState;72  };73  if (!g.__peachSyncTriggerState) {74    g.__peachSyncTriggerState = {75      syncInFlight: false,76      lastState: { status: "idle" },77      runner: null,78    };79  }80  return g.__peachSyncTriggerState;81}8283export function setSyncRunner(fn: RunSyncWithDevice | null): void {84  getSyncTriggerState().runner = fn;85}8687export function _resetSyncTriggerForTest(): void {88  const state = getSyncTriggerState();89  state.syncInFlight = false;90  state.lastState = { status: "idle" };91  state.runner = null;92}9394export function getSyncState(): SyncState {95  return getSyncTriggerState().lastState;96}9798/**99 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service100 * worker dies and restarts on the slightest provocation; without this the101 * popup's "Last synced N minutes ago" indicator resets to "never" every102 * time the SW respawns. Called once at startup from `entry.ts`.103 */104export async function hydratePersistedSyncState(): Promise<void> {105  const state = getSyncTriggerState();106  if (state.lastState.lastSyncAt === undefined) {107    const persisted = await getLastSyncAt();108    if (typeof persisted === "number") {109      state.lastState = { ...state.lastState, lastSyncAt: persisted };110    }111  }112  // The outcome of the last attempt survives too, otherwise a failure that113  // happened before the SW died is unreportable — which is the case a real114  // device most needs explained.115  if (state.lastState.outcome === undefined) {116    const last = await getLastSyncResult();117    if (last) {118      state.lastState = {119        ...state.lastState,120        outcome: last.outcome,121        detail: last.detail,122        finishedAt: last.at,123        entriesReceived: last.entriesReceived,124        entriesSent: last.entriesSent,125      };126    }127  }128}129130export async function triggerSyncIfReachable(opts: {131  reason: SyncTriggerReason;132  force?: boolean;133}): Promise<void> {134  const state = getSyncTriggerState();135  // Claim the guard synchronously before any await, otherwise two136  // concurrent triggers could both pass the gate before either reached137  // the registry lookup.138  if (state.syncInFlight && !opts.force) {139    // Deliberately does NOT overwrite lastState — the in-flight run owns it.140    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);141    return;142  }143  state.syncInFlight = true;144145  try {146    const devices = await listPairedDevices();147    // Only native peers can accept a connection, so only they are sync hosts;148    // another extension is reachable solely via a relay, which is the relaying149    // device's job, not ours.150    //151    // "vouched" belongs here alongside "direct": a vouched device is one the152    // user has already confirmed out of the pending queue (MV-7), and the whole153    // point of mesh introduction is that it becomes reachable without its own154    // pairing code. Excluding it left confirmed devices permanently unsynced.155    const hosts = devices.filter(156      (d) =>157        (d.deviceType === "android" || d.deviceType === "ios") &&158        (d.trustSource === "direct" || d.trustSource === "vouched"),159    );160    if (devices.length === 0) {161      finish({ outcome: "no_paired_devices" });162      return;163    }164    if (hosts.length === 0) {165      // The device inventory is the diagnostic: it says exactly which166      // deviceType/trustSource combination the eligibility filter rejected.167      finish({ outcome: "no_eligible_host", detail: inventory(devices) });168      return;169    }170    if (!state.runner) {171      finish({ outcome: "runner_unavailable" });172      return;173    }174175    setState({176      status: "syncing",177      reason: opts.reason,178      error: undefined,179      outcome: undefined,180      detail: undefined,181    });182    log(183      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,184    );185    await runSyncRounds(hosts);186  } finally {187    state.syncInFlight = false;188  }189}190191async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {192  const state = getSyncTriggerState();193  if (!state.runner) {194    finish({ outcome: "runner_unavailable" });195    return;196  }197198  let entriesReceived = 0;199  let entriesSent = 0;200  let anySucceeded = false;201  let firstError: string | undefined;202203  for (const host of hosts) {204    try {205      const result = await state.runner(host);206      if (!result.ok) {207        log(`host ${describeHost(host)} refused: ${result.error}`);208        if (firstError === undefined) firstError = result.error;209        continue;210      }211      entriesReceived += result.entriesReceived;212      entriesSent += result.entriesSent;213      await markLastSyncedAt(host.deviceId, Date.now());214      anySucceeded = true;215    } catch (err) {216      const message = (err as Error)?.message ?? String(err);217      log(`host ${describeHost(host)} threw: ${message}`);218      if (firstError === undefined) firstError = message;219    }220  }221222  if (anySucceeded) {223    const now = Date.now();224    finish({225      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",226      // A partial success still names the host that failed — otherwise "synced"227      // hides the peer that never got the change.228      detail: firstError,229      entriesReceived,230      entriesSent,231    });232    // Persist so the popup's "last synced" indicator survives a SW restart.233    // Best-effort — storage failures must not surface as sync failures.234    void setLastSyncAt(now).catch(() => undefined);235  } else {236    const error = firstError ?? "no_host_reachable";237    finish({ outcome: classifySyncError(error), detail: error });238  }239}240241/**242 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any243 * throw). Mapping them here is the only place the popup's plain-English copy244 * can key off something stable.245 */246function classifySyncError(error: string): SyncOutcome {247  if (error.includes("VAULT_LOCKED")) return "vault_locked";248  if (error.includes("sync_disabled")) return "sync_disabled";249  if (error.includes("private_relay_unset")) return "private_relay_unset";250  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";251  if (error.includes("sync_timeout")) return "sync_timeout";252  if (error.startsWith("relay_sync_failed")) {253    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)254      ? "handshake_failed"255      : "relay_unreachable";256  }257  return "run_failed";258}259260interface SyncFinish {261  outcome: SyncOutcome;262  detail?: string;263  entriesReceived?: number;264  entriesSent?: number;265}266267/**268 * The single exit for every terminating path. Nothing may `return` out of a269 * sync run without going through here — that silence is the bug this whole270 * module was reworked to remove.271 */272function finish(result: SyncFinish): void {273  const now = Date.now();274  const failed = isSyncFailure(result.outcome);275  const entriesReceived = result.entriesReceived ?? 0;276  const entriesSent = result.entriesSent ?? 0;277  setState({278    status: failed ? "error" : "idle",279    outcome: result.outcome,280    detail: result.detail,281    error: failed ? result.detail ?? result.outcome : undefined,282    finishedAt: now,283    entriesReceived,284    entriesSent,285    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"286      ? { lastSyncAt: now }287      : {}),288  });289  log(290    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +291      (entriesReceived + entriesSent > 0292        ? ` (sent ${entriesSent}, received ${entriesReceived})`293        : ""),294  );295  void setLastSyncResult({296    outcome: result.outcome,297    detail: result.detail,298    at: now,299    entriesReceived,300    entriesSent,301  }).catch(() => undefined);302}303304/** What the eligibility filter actually saw. Names, not just counts. */305function inventory(devices: PairedDevice[]): string {306  return devices.map(describeHost).join("; ");307}308309function describeHost(d: PairedDevice): string {310  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;311}312313/**314 * MV3 kills the service worker constantly, so devtools on the SW is the only315 * post-hoc record of a sync attempt. Prefixed so it can be filtered.316 */317function log(message: string): void {318  console.info(`[Peach sync] ${message}`);319}320321function setState(patch: Partial<SyncState>): void {322  const state = getSyncTriggerState();323  state.lastState = { ...state.lastState, ...patch };324  broadcast(state.lastState);325}326327function broadcast(state: SyncState): void {328  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;329  try {330    const maybeThenable = chrome.runtime.sendMessage({331      type: "SYNC_STATE_CHANGED",332      payload: state,333    });334    if (335      maybeThenable &&336      typeof (maybeThenable as Promise<unknown>).catch === "function"337    ) {338      (maybeThenable as Promise<unknown>).catch(() => {339        /* popup not listening — fine */340      });341    }342  } catch {343    /* messaging unavailable */344  }345}