8ad0d17ee9b4

feat: add sync-trigger.ts — central sync dispatcher with syncInFlight guard, markLastSyncedAt wiring, state broadcast, dependency-injected runner (v3.6.81)

time
2026-05-21 15:49 UTC · 5 months ago
machine
koh2
parent
cf02cd217e1f
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-trigger.test.ts+151 −0src/background/sync-trigger.ts+140 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.6.80",5+  "version": "3.6.81",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.6.80",4+  "version": "3.6.81",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.80",3+  "version": "3.6.81",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-trigger.test.ts+151 −0
@@ -0,0 +1,151 @@1+import { describe, expect, it, beforeEach, vi } from "vitest";2+import type { PairedDevice } from "../shared/sync-types";3+import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices";4+import {5+  triggerSyncIfReachable,6+  setSyncRunner,7+  _resetSyncTriggerForTest,8+  getSyncState,9+} from "./sync-trigger";10+11+function makeStorageStub() {12+  const sync = new Map<string, unknown>();13+  const local = new Map<string, unknown>();14+  function area(store: Map<string, unknown>) {15+    return {16+      async get(key?: string | string[] | null) {17+        if (key === null || key === undefined) {18+          const all: Record<string, unknown> = {};19+          for (const [k, v] of store) all[k] = v;20+          return all;21+        }22+        const keys = Array.isArray(key) ? key : [key];23+        const out: Record<string, unknown> = {};24+        for (const k of keys) if (store.has(k)) out[k] = store.get(k);25+        return out;26+      },27+      async set(values: Record<string, unknown>) {28+        for (const [k, v] of Object.entries(values)) store.set(k, v);29+      },30+      async remove(key: string | string[]) {31+        const keys = Array.isArray(key) ? key : [key];32+        for (const k of keys) store.delete(k);33+      },34+    };35+  }36+  (globalThis as unknown as { chrome: unknown }).chrome = {37+    storage: { sync: area(sync), local: area(local) },38+    runtime: { sendMessage: () => Promise.resolve() },39+  };40+}41+42+const ANDROID: PairedDevice = {43+  deviceId: "AAA-android",44+  deviceName: "Pixel",45+  deviceType: "android",46+  longTermPublicKeyHex: "a".repeat(64),47+  pairedAt: 1700000000000,48+};49+50+const IOS: PairedDevice = {51+  deviceId: "BBB-ios",52+  deviceName: "iPhone",53+  deviceType: "ios",54+  longTermPublicKeyHex: "b".repeat(64),55+  pairedAt: 1700000000000,56+};57+58+const EXT: PairedDevice = {59+  deviceId: "CCC-ext",60+  deviceName: "Other Chrome",61+  deviceType: "extension",62+  longTermPublicKeyHex: "c".repeat(64),63+  pairedAt: 1700000000000,64+};65+66+describe("triggerSyncIfReachable", () => {67+  beforeEach(() => {68+    makeStorageStub();69+    _resetSyncTriggerForTest();70+  });71+72+  it("does not call the runner when no mobile hosts are paired", async () => {73+    await upsertPairedDevice(EXT);74+    const runner = vi.fn(async () => ({ entriesReceived: 0, entriesSent: 0 }));75+    setSyncRunner(runner);76+    await triggerSyncIfReachable({ reason: "manual" });77+    expect(runner).not.toHaveBeenCalled();78+  });79+80+  it("calls the runner once per mobile host on a single trigger", async () => {81+    await upsertPairedDevice(ANDROID);82+    await upsertPairedDevice(IOS);83+    await upsertPairedDevice(EXT);84+    const seen: string[] = [];85+    setSyncRunner(async (device) => {86+      seen.push(device.deviceId);87+      return { entriesReceived: 1, entriesSent: 0 };88+    });89+    await triggerSyncIfReachable({ reason: "manual" });90+    expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);91+  });92+93+  it("marks lastSyncedAt on each host that completes successfully", async () => {94+    await upsertPairedDevice(ANDROID);95+    await upsertPairedDevice(IOS);96+    setSyncRunner(async () => ({ entriesReceived: 0, entriesSent: 0 }));97+    await triggerSyncIfReachable({ reason: "manual" });98+    const a = await findPairedDeviceById(ANDROID.deviceId);99+    const b = await findPairedDeviceById(IOS.deviceId);100+    expect(typeof a?.lastSyncedAt).toBe("number");101+    expect(typeof b?.lastSyncedAt).toBe("number");102+  });103+104+  it("does not start a second sync if one is already in flight (non-force)", async () => {105+    await upsertPairedDevice(ANDROID);106+    const runner = vi.fn(async () => {107+      await new Promise<void>((r) => setTimeout(r, 50));108+      return { entriesReceived: 0, entriesSent: 0 };109+    });110+    setSyncRunner(runner);111+    const a = triggerSyncIfReachable({ reason: "vault_change" });112+    const b = triggerSyncIfReachable({ reason: "vault_change" });113+    await Promise.all([a, b]);114+    expect(runner).toHaveBeenCalledTimes(1);115+  });116+117+  it("force:true bypasses the syncInFlight guard for manual triggers", async () => {118+    await upsertPairedDevice(ANDROID);119+    const runner = vi.fn(async () => {120+      await new Promise<void>((r) => setTimeout(r, 30));121+      return { entriesReceived: 0, entriesSent: 0 };122+    });123+    setSyncRunner(runner);124+    const a = triggerSyncIfReachable({ reason: "vault_change" });125+    const b = triggerSyncIfReachable({ reason: "manual", force: true });126+    await Promise.all([a, b]);127+    expect(runner).toHaveBeenCalledTimes(2);128+  });129+130+  it("transitions to error state when no host succeeds", async () => {131+    await upsertPairedDevice(ANDROID);132+    setSyncRunner(async () => {133+      throw new Error("unreachable");134+    });135+    await triggerSyncIfReachable({ reason: "manual" });136+    expect(getSyncState().status).toBe("error");137+    expect(getSyncState().error).toBe("unreachable");138+  });139+140+  it("returns to idle state on success with aggregated entry counts", async () => {141+    await upsertPairedDevice(ANDROID);142+    await upsertPairedDevice(IOS);143+    setSyncRunner(async () => ({ entriesReceived: 3, entriesSent: 2 }));144+    await triggerSyncIfReachable({ reason: "manual" });145+    const s = getSyncState();146+    expect(s.status).toBe("idle");147+    expect(s.entriesReceived).toBe(6);148+    expect(s.entriesSent).toBe(4);149+    expect(typeof s.lastSyncAt).toBe("number");150+  });151+});

view file

src/background/sync-trigger.ts+140 −0
@@ -0,0 +1,140 @@1+// sync-trigger.ts — central sync dispatcher.2+//3+// SEV-1-A fix: the chirp-sync coordinator + transport were complete but4+// never invoked from production code paths. This module is the single5+// entry point for the four trigger conditions (vault change, popup6+// foreground, manual "Sync Now", and mDNS peer discovery) and is the7+// only place that calls `runChirpSyncInitiator` 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 `runChirpSyncWithDevice` from `./sync-runner` at module load.12+13+import { listPairedDevices, markLastSyncedAt } from "./paired-devices";14+import type { PairedDevice } from "../shared/sync-types";15+16+export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";17+18+export type SyncRunStatus = "idle" | "syncing" | "error";19+20+export interface SyncState {21+  status: SyncRunStatus;22+  reason?: SyncTriggerReason;23+  lastSyncAt?: number;24+  entriesReceived?: number;25+  entriesSent?: number;26+  error?: string;27+}28+29+export interface SyncRunResult {30+  entriesReceived: number;31+  entriesSent: number;32+}33+34+export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;35+36+let syncInFlight = false;37+let lastState: SyncState = { status: "idle" };38+let runner: RunSyncWithDevice | null = null;39+40+export function setSyncRunner(fn: RunSyncWithDevice | null): void {41+  runner = fn;42+}43+44+export function _resetSyncTriggerForTest(): void {45+  syncInFlight = false;46+  lastState = { status: "idle" };47+  runner = null;48+}49+50+export function getSyncState(): SyncState {51+  return lastState;52+}53+54+export async function triggerSyncIfReachable(opts: {55+  reason: SyncTriggerReason;56+  force?: boolean;57+}): Promise<void> {58+  // Claim the guard synchronously before any await, otherwise two59+  // concurrent triggers could both pass the gate before either reached60+  // the registry lookup.61+  if (syncInFlight && !opts.force) return;62+  syncInFlight = true;63+64+  try {65+    const devices = await listPairedDevices();66+    const hosts = devices.filter(67+      (d) => d.deviceType === "android" || d.deviceType === "ios",68+    );69+    if (hosts.length === 0) return;70+    if (!runner) return;71+72+    setState({ status: "syncing", reason: opts.reason, error: undefined });73+    await runSyncRounds(hosts);74+  } finally {75+    syncInFlight = false;76+  }77+}78+79+async function runSyncRounds(hosts: PairedDevice[]): Promise<void> {80+  if (!runner) return;81+82+  let entriesReceived = 0;83+  let entriesSent = 0;84+  let anySucceeded = false;85+  let firstError: string | undefined;86+87+  for (const host of hosts) {88+    try {89+      const result = await runner(host);90+      entriesReceived += result.entriesReceived;91+      entriesSent += result.entriesSent;92+      await markLastSyncedAt(host.deviceId, Date.now());93+      anySucceeded = true;94+    } catch (err) {95+      if (firstError === undefined) {96+        firstError = (err as Error)?.message ?? String(err);97+      }98+    }99+  }100+101+  if (anySucceeded) {102+    setState({103+      status: "idle",104+      lastSyncAt: Date.now(),105+      entriesReceived,106+      entriesSent,107+      error: undefined,108+    });109+  } else {110+    setState({111+      status: "error",112+      error: firstError ?? "no_host_reachable",113+    });114+  }115+}116+117+function setState(patch: Partial<SyncState>): void {118+  lastState = { ...lastState, ...patch };119+  broadcast(lastState);120+}121+122+function broadcast(state: SyncState): void {123+  if (typeof chrome === "undefined" || !chrome.runtime?.sendMessage) return;124+  try {125+    const maybeThenable = chrome.runtime.sendMessage({126+      type: "SYNC_STATE_CHANGED",127+      payload: state,128+    });129+    if (130+      maybeThenable &&131+      typeof (maybeThenable as Promise<unknown>).catch === "function"132+    ) {133+      (maybeThenable as Promise<unknown>).catch(() => {134+        /* popup not listening — fine */135+      });136+    }137+  } catch {138+    /* messaging unavailable */139+  }140+}

view file