sync-trigger.ts

10.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 {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";2324export type SyncTriggerReason = "vault_change" | "foreground" | "manual";2526export type SyncRunStatus = "idle" | "syncing" | "error";2728export interface SyncState {29  status: SyncRunStatus;30  reason?: SyncTriggerReason;31  /** Last SUCCESSFUL sync. `finishedAt` is the last attempt of any kind. */32  lastSyncAt?: number;33  entriesReceived?: number;34  entriesSent?: number;35  error?: string;36  outcome?: SyncOutcome;37  /** Raw internal code, or the device inventory behind `no_eligible_host`. */38  detail?: string;39  finishedAt?: number;40}4142export type SyncRunResult =43  | { ok: true; entriesReceived: number; entriesSent: number }44  | { ok: false; error: string };4546export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4748interface PeachSyncTriggerState {49  syncInFlight: boolean;50  lastState: SyncState;51  runner: RunSyncWithDevice | null;52}5354function getSyncTriggerState(): PeachSyncTriggerState {55  const g = globalThis as typeof globalThis & {56    __peachSyncTriggerState?: PeachSyncTriggerState;57  };58  if (!g.__peachSyncTriggerState) {59    g.__peachSyncTriggerState = {60      syncInFlight: false,61      lastState: { status: "idle" },62      runner: null,63    };64  }65  return g.__peachSyncTriggerState;66}6768export function setSyncRunner(fn: RunSyncWithDevice | null): void {69  getSyncTriggerState().runner = fn;70}7172export function _resetSyncTriggerForTest(): void {73  const state = getSyncTriggerState();74  state.syncInFlight = false;75  state.lastState = { status: "idle" };76  state.runner = null;77}7879export function getSyncState(): SyncState {80  return getSyncTriggerState().lastState;81}8283/**84 * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service85 * worker dies and restarts on the slightest provocation; without this the86 * popup's "Last synced N minutes ago" indicator resets to "never" every87 * time the SW respawns. Called once at startup from `entry.ts`.88 */89export async function hydratePersistedSyncState(): Promise<void> {90  const state = getSyncTriggerState();91  if (state.lastState.lastSyncAt === undefined) {92    const persisted = await getLastSyncAt();93    if (typeof persisted === "number") {94      state.lastState = { ...state.lastState, lastSyncAt: persisted };95    }96  }97  // The outcome of the last attempt survives too, otherwise a failure that98  // happened before the SW died is unreportable — which is the case a real99  // device most needs explained.100  if (state.lastState.outcome === undefined) {101    const last = await getLastSyncResult();102    if (last) {103      state.lastState = {104        ...state.lastState,105        outcome: last.outcome,106        detail: last.detail,107        finishedAt: last.at,108        entriesReceived: last.entriesReceived,109        entriesSent: last.entriesSent,110      };111    }112  }113}114115export async function triggerSyncIfReachable(opts: {116  reason: SyncTriggerReason;117  force?: boolean;118}): Promise<void> {119  const state = getSyncTriggerState();120  // Claim the guard synchronously before any await, otherwise two121  // concurrent triggers could both pass the gate before either reached122  // the registry lookup.123  if (state.syncInFlight && !opts.force) {124    // Deliberately does NOT overwrite lastState — the in-flight run owns it.125    log(`trigger (${opts.reason}) ignored, a sync is already in flight`);126    return;127  }128  state.syncInFlight = true;129130  try {131    const devices = await listPairedDevices();132    // Only native peers can accept a connection, so only they are sync hosts;133    // another extension is reachable solely via a relay, which is the relaying134    // device's job, not ours.135    //136    // "vouched" belongs here alongside "direct": a vouched device is one the137    // user has already confirmed out of the pending queue (MV-7), and the whole138    // point of mesh introduction is that it becomes reachable without its own139    // pairing code. Excluding it left confirmed devices permanently unsynced.140    const hosts = devices.filter(141      (d) =>142        (d.deviceType === "android" || d.deviceType === "ios") &&143        (d.trustSource === "direct" || d.trustSource === "vouched"),144    );145    if (devices.length === 0) {146      finish({ outcome: "no_paired_devices" });147      return;148    }149    if (hosts.length === 0) {150      // The device inventory is the diagnostic: it says exactly which151      // deviceType/trustSource combination the eligibility filter rejected.152      finish({ outcome: "no_eligible_host", detail: inventory(devices) });153      return;154    }155    if (!state.runner) {156      finish({ outcome: "runner_unavailable" });157      return;158    }159160    setState({161      status: "syncing",162      reason: opts.reason,163      error: undefined,164      outcome: undefined,165      detail: undefined,166    });167    log(168      `starting (${opts.reason}) against ${hosts.length} host(s): ${inventory(hosts)}`,169    );170    await runSyncRounds(hosts);171  } finally {172    state.syncInFlight = false;173  }174}175176async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {177  const state = getSyncTriggerState();178  if (!state.runner) {179    finish({ outcome: "runner_unavailable" });180    return;181  }182183  let entriesReceived = 0;184  let entriesSent = 0;185  let anySucceeded = false;186  let firstError: string | undefined;187188  for (const host of hosts) {189    try {190      const result = await state.runner(host);191      if (!result.ok) {192        log(`host ${describeHost(host)} refused: ${result.error}`);193        if (firstError === undefined) firstError = result.error;194        continue;195      }196      entriesReceived += result.entriesReceived;197      entriesSent += result.entriesSent;198      await markLastSyncedAt(host.deviceId, Date.now());199      anySucceeded = true;200    } catch (err) {201      const message = (err as Error)?.message ?? String(err);202      log(`host ${describeHost(host)} threw: ${message}`);203      if (firstError === undefined) firstError = message;204    }205  }206207  if (anySucceeded) {208    const now = Date.now();209    finish({210      outcome: entriesReceived + entriesSent > 0 ? "completed_changes" : "completed_no_changes",211      // A partial success still names the host that failed — otherwise "synced"212      // hides the peer that never got the change.213      detail: firstError,214      entriesReceived,215      entriesSent,216    });217    // Persist so the popup's "last synced" indicator survives a SW restart.218    // Best-effort — storage failures must not surface as sync failures.219    void setLastSyncAt(now).catch(() => undefined);220  } else {221    const error = firstError ?? "no_host_reachable";222    finish({ outcome: classifySyncError(error), detail: error });223  }224}225226/**227 * Runner errors are opaque strings by contract (`SyncRunResult.error` and any228 * throw). Mapping them here is the only place the popup's plain-English copy229 * can key off something stable.230 */231function classifySyncError(error: string): SyncOutcome {232  if (error.includes("VAULT_LOCKED")) return "vault_locked";233  if (error.includes("sync_disabled")) return "sync_disabled";234  if (error.includes("private_relay_unset")) return "private_relay_unset";235  if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";236  if (error.includes("sync_timeout")) return "sync_timeout";237  if (error.startsWith("relay_sync_failed")) {238    return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)239      ? "handshake_failed"240      : "relay_unreachable";241  }242  return "run_failed";243}244245interface SyncFinish {246  outcome: SyncOutcome;247  detail?: string;248  entriesReceived?: number;249  entriesSent?: number;250}251252/**253 * The single exit for every terminating path. Nothing may `return` out of a254 * sync run without going through here — that silence is the bug this whole255 * module was reworked to remove.256 */257function finish(result: SyncFinish): void {258  const now = Date.now();259  const failed = isSyncFailure(result.outcome);260  const entriesReceived = result.entriesReceived ?? 0;261  const entriesSent = result.entriesSent ?? 0;262  setState({263    status: failed ? "error" : "idle",264    outcome: result.outcome,265    detail: result.detail,266    error: failed ? result.detail ?? result.outcome : undefined,267    finishedAt: now,268    entriesReceived,269    entriesSent,270    ...(result.outcome === "completed_changes" || result.outcome === "completed_no_changes"271      ? { lastSyncAt: now }272      : {}),273  });274  log(275    `${result.outcome}${result.detail ? ` — ${result.detail}` : ""}` +276      (entriesReceived + entriesSent > 0277        ? ` (sent ${entriesSent}, received ${entriesReceived})`278        : ""),279  );280  void setLastSyncResult({281    outcome: result.outcome,282    detail: result.detail,283    at: now,284    entriesReceived,285    entriesSent,286  }).catch(() => undefined);287}288289/** What the eligibility filter actually saw. Names, not just counts. */290function inventory(devices: PairedDevice[]): string {291  return devices.map(describeHost).join("; ");292}293294function describeHost(d: PairedDevice): string {295  return `${d.deviceName ?? d.deviceId} [type=${d.deviceType ?? "unset"}, trust=${d.trustSource ?? "unset"}]`;296}297298/**299 * MV3 kills the service worker constantly, so devtools on the SW is the only300 * post-hoc record of a sync attempt. Prefixed so it can be filtered.301 */302function log(message: string): void {303  console.info(`[Peach sync] ${message}`);304}305306function setState(patch: Partial<SyncState>): void {307  const state = getSyncTriggerState();308  state.lastState = { ...state.lastState, ...patch };309  broadcast(state.lastState);310}311312function broadcast(state: SyncState): void {313  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;314  try {315    const maybeThenable = chrome.runtime.sendMessage({316      type: "SYNC_STATE_CHANGED",317      payload: state,318    });319    if (320      maybeThenable &&321      typeof (maybeThenable as Promise<unknown>).catch === "function"322    ) {323      (maybeThenable as Promise<unknown>).catch(() => {324        /* popup not listening — fine */325      });326    }327  } catch {328    /* messaging unavailable */329  }330}