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
↓ peach-browser-8ad0d17e.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-trigger.test.ts+151 −0src/background/sync-trigger.ts+140 −0manifest.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": {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",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": {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+});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+}