cfc7e8821df9
feat: productionSyncRunner + startup registration — composes adapter+transport+coordinator with cached-IP fast path and /24 subnet fallback; setSyncRunner wired in entry.ts start(); hydratePersistedSyncState rehydrates lastSyncAt across SW restarts; 13 runner+persistence tests, full suite 2948/2948 (v3.6.88)
- time
- 2026-05-21 16:36 UTC · 5 months ago
- machine
- koh2
- parent
- ef5de9313daf
↓ peach-browser-cfc7e882.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/chirp-sync-runner.test.ts+404 −0src/background/chirp-sync-runner.ts+178 −0src/background/entry.ts+7 −0src/background/sync-trigger.ts+25 −2manifest.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.87",5+ "version": "3.6.88",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.87",4+ "version": "3.6.88",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.87",3+ "version": "3.6.88",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/chirp-sync-runner.test.ts+404 −0
@@ -0,0 +1,404 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import type { PairedDevice, ChirpSyncMessage } from "../shared/sync-types";3+import type {4+ ChirpSyncSession,5+ SyncEndpoint,6+} from "../shared/chirp-sync-transport";7+import type { ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";8+import type { SyncRunnerDeps } from "./chirp-sync-runner";9+10+type StorageShape = Record<string, unknown>;11+12+function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {13+ const area = (store: StorageShape) => ({14+ get: vi.fn(async (key?: string | string[]) => {15+ if (!key) return store;16+ if (typeof key === "string") return { [key]: store[key] };17+ return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});18+ }),19+ set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),20+ remove: vi.fn(async (keys: string | string[]) => {21+ const list = Array.isArray(keys) ? keys : [keys];22+ for (const k of list) delete store[k];23+ }),24+ });25+ return {26+ storage: { local: area(local), sync: area(sync) },27+ alarms: {28+ clear: vi.fn(),29+ create: vi.fn(),30+ onAlarm: { addListener: vi.fn() },31+ },32+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },33+ runtime: { sendMessage: vi.fn(async () => undefined) },34+ } as unknown as typeof chrome;35+}36+37+const REMOTE_PUBKEY_HEX = "ab".repeat(32);38+39+function makeDevice(overrides: Partial<PairedDevice> = {}): PairedDevice {40+ return {41+ deviceId: "pixel-xyz",42+ deviceName: "Pixel",43+ deviceType: "android",44+ longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45+ wsPort: 8765,46+ pairedAt: 1700000000000,47+ ...overrides,48+ };49+}50+51+interface FakePeer {52+ send(msg: ChirpSyncMessage): Promise<void>;53+ receive(): Promise<ChirpSyncMessage>;54+}55+56+/**57+ * In-memory ChirpSyncSession ↔ FakePeer pair. The session is what the58+ * runner passes to runChirpSyncInitiator; the peer is the other end of the59+ * wire, controlled by the test body. Mirrors the pattern in60+ * chirp-sync-coordinator.test.ts.61+ */62+function inMemoryPair(): { session: ChirpSyncSession; peer: FakePeer; closed: { v: boolean } } {63+ const inboundToSession: Uint8Array[] = [];64+ const outboundFromSession: Uint8Array[] = [];65+ const sessionReaders: Array<(v: Uint8Array) => void> = [];66+ const peerReaders: Array<(v: Uint8Array) => void> = [];67+ const closed = { v: false };68+69+ const session: ChirpSyncSession = {70+ send(bytes) {71+ const r = peerReaders.shift();72+ if (r) r(bytes); else outboundFromSession.push(bytes);73+ },74+ receive(): Promise<Uint8Array> {75+ const v = inboundToSession.shift();76+ if (v) return Promise.resolve(v);77+ return new Promise<Uint8Array>((res) => sessionReaders.push(res));78+ },79+ async close() { closed.v = true; },80+ remoteStaticPublicKey: new Uint8Array(32),81+ };82+ const peer: FakePeer = {83+ async send(msg) {84+ const bytes = new TextEncoder().encode(JSON.stringify(msg));85+ const r = sessionReaders.shift();86+ if (r) r(bytes); else inboundToSession.push(bytes);87+ },88+ async receive() {89+ const v = outboundFromSession.shift();90+ const bytes = v ?? await new Promise<Uint8Array>((res) => peerReaders.push(res));91+ return JSON.parse(new TextDecoder().decode(bytes)) as ChirpSyncMessage;92+ },93+ };94+ return { session, peer, closed };95+}96+97+/**98+ * Drive a happy-path SYNC_DONE handshake on the peer side, matching the99+ * coordinator's HELLO → MANIFEST → REQUEST → ENTRIES → TOMBSTONES → DONE100+ * sequence. `peerEntries` lets the peer send a SYNC_ENTRIES payload of any101+ * size for the entriesReceived assertion; `wantFromUs` requests N entries102+ * from our side (drives entriesSent).103+ */104+async function runPeer(105+ peer: FakePeer,106+ opts: {107+ vaultId?: string;108+ peerEntriesB64?: Record<string, string>;109+ wantFromUsIds?: string[];110+ } = {},111+): Promise<void> {112+ const hello = await peer.receive();113+ if (hello.type !== "SYNC_HELLO") throw new Error("expected HELLO from initiator");114+ await peer.send({115+ type: "SYNC_HELLO",116+ vaultId: opts.vaultId ?? hello.vaultId,117+ deviceId: "peer-device",118+ });119+ const manifest = await peer.receive();120+ if (manifest.type !== "SYNC_MANIFEST") throw new Error("expected MANIFEST");121+122+ // Peer manifest declares the entries it will ship in SYNC_ENTRIES.123+ const peerEntryManifest: Record<string, number> = {};124+ for (const id of Object.keys(opts.peerEntriesB64 ?? {})) {125+ peerEntryManifest[id] = Date.now();126+ }127+ await peer.send({128+ type: "SYNC_MANIFEST",129+ entries: peerEntryManifest,130+ tombstones: {},131+ });132+133+ const ourRequest = await peer.receive();134+ if (ourRequest.type !== "SYNC_REQUEST") throw new Error("expected REQUEST from initiator");135+136+ // Peer asks for the ids it wants from us.137+ await peer.send({ type: "SYNC_REQUEST", entryIds: opts.wantFromUsIds ?? [] });138+139+ const ourEntries = await peer.receive();140+ if (ourEntries.type !== "SYNC_ENTRIES") throw new Error("expected ENTRIES from initiator");141+142+ // Only ship SYNC_ENTRIES when the initiator actually requested any. The143+ // coordinator only awaits this frame when wantFromPeer.length > 0.144+ if (Object.keys(peerEntryManifest).length > 0) {145+ await peer.send({ type: "SYNC_ENTRIES", entries: opts.peerEntriesB64 ?? {} });146+ }147+148+ const ourTombstones = await peer.receive();149+ if (ourTombstones.type !== "SYNC_TOMBSTONES") throw new Error("expected TOMBSTONES");150+ await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });151+152+ const done = await peer.receive();153+ if (done.type !== "SYNC_DONE") throw new Error("expected DONE");154+ await peer.send({ type: "SYNC_DONE" });155+}156+157+function emptyAdapter(): ChirpSyncVaultAdapter {158+ return {159+ async computeVaultId() { return "vault-abc"; },160+ async getOurDeviceId() { return "device-init"; },161+ async buildEntryManifest() { return {}; },162+ async buildTombstoneManifest() { return {}; },163+ async getEncryptedEntries() { return {}; },164+ async applyEntries() { /* no-op */ },165+ async applyTombstones() { /* no-op */ },166+ };167+}168+169+describe("productionSyncRunner", () => {170+ let local: StorageShape;171+ let sync: StorageShape;172+173+ beforeEach(() => {174+ vi.resetModules();175+ local = {};176+ sync = {};177+ (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);178+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));179+ });180+181+ async function bootVault(): Promise<typeof import("./vault")> {182+ const vault = await import("./vault");183+ await vault.createVault("correct horse battery staple");184+ return vault;185+ }186+187+ it("throws VaultLockedError when the vault is locked", async () => {188+ const vault = await bootVault();189+ vault.lock();190+ const { runWithDeps } = await import("./chirp-sync-runner");191+ const deps: SyncRunnerDeps = {192+ openSession: vi.fn(),193+ probeSubnet: vi.fn(),194+ getSubnet: vi.fn(async () => "192.168.1"),195+ createAdapter: emptyAdapter,196+ };197+ await expect(runWithDeps(makeDevice(), deps)).rejects.toMatchObject({198+ name: "VaultLockedError",199+ });200+ expect(deps.openSession).not.toHaveBeenCalled();201+ });202+203+ it("throws when the device has no longTermPublicKeyHex", async () => {204+ await bootVault();205+ const { runWithDeps } = await import("./chirp-sync-runner");206+ const deps: SyncRunnerDeps = {207+ openSession: vi.fn(),208+ probeSubnet: vi.fn(),209+ getSubnet: vi.fn(),210+ createAdapter: emptyAdapter,211+ };212+ await expect(213+ runWithDeps(makeDevice({ longTermPublicKeyHex: undefined }), deps),214+ ).rejects.toThrow("paired_device_missing_pubkey");215+ });216+217+ it("uses the cached last-known IP first and skips the subnet probe", async () => {218+ await bootVault();219+ const { setLastKnownIp } = await import("./paired-devices");220+ await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");221+222+ const { session, peer } = inMemoryPair();223+ const openSession = vi.fn(async (_e: SyncEndpoint) => session);224+ const probeSubnet = vi.fn();225+ const deps: SyncRunnerDeps = {226+ openSession,227+ probeSubnet,228+ getSubnet: vi.fn(async () => "192.168.1"),229+ createAdapter: emptyAdapter,230+ };231+232+ const { runWithDeps } = await import("./chirp-sync-runner");233+ const runPromise = runWithDeps(makeDevice(), deps);234+ await runPeer(peer);235+ const result = await runPromise;236+ expect(result).toEqual({ entriesReceived: 0, entriesSent: 0 });237+ expect(openSession).toHaveBeenCalledTimes(1);238+ expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });239+ expect(probeSubnet).not.toHaveBeenCalled();240+ });241+242+ it("falls back to subnet probe when cached IP fails to connect", async () => {243+ await bootVault();244+ const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");245+ await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");246+247+ const { session, peer } = inMemoryPair();248+ const openSession = vi.fn()249+ .mockRejectedValueOnce(new Error("ECONNREFUSED"))250+ .mockResolvedValueOnce(session);251+ const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));252+ const deps: SyncRunnerDeps = {253+ openSession,254+ probeSubnet,255+ getSubnet: vi.fn(async () => "192.168.1"),256+ createAdapter: emptyAdapter,257+ };258+259+ const { runWithDeps } = await import("./chirp-sync-runner");260+ const runPromise = runWithDeps(makeDevice(), deps);261+ await runPeer(peer);262+ await runPromise;263+ expect(openSession).toHaveBeenCalledTimes(2);264+ expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });265+ expect(probeSubnet).toHaveBeenCalledTimes(1);266+ expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");267+ });268+269+ it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {270+ await bootVault();271+ const { setLastKnownIp } = await import("./paired-devices");272+ await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");273+274+ const deps: SyncRunnerDeps = {275+ openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),276+ probeSubnet: vi.fn(async () => null),277+ getSubnet: vi.fn(async () => "192.168.1"),278+ createAdapter: emptyAdapter,279+ };280+ const { runWithDeps } = await import("./chirp-sync-runner");281+ await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");282+ });283+284+ it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {285+ await bootVault();286+ const deps: SyncRunnerDeps = {287+ openSession: vi.fn(),288+ probeSubnet: vi.fn(),289+ getSubnet: vi.fn(async () => null),290+ createAdapter: emptyAdapter,291+ };292+ const { runWithDeps } = await import("./chirp-sync-runner");293+ await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");294+ });295+296+ it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {297+ await bootVault();298+ const { session, peer } = inMemoryPair();299+ const openSession = vi.fn(async () => session);300+ const deps: SyncRunnerDeps = {301+ openSession,302+ probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),303+ getSubnet: vi.fn(async () => "192.168.1"),304+ createAdapter: emptyAdapter,305+ };306+ const { runWithDeps } = await import("./chirp-sync-runner");307+ const runPromise = runWithDeps(makeDevice(), deps);308+ await runPeer(peer, {309+ peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },310+ });311+ const result = await runPromise;312+ expect(result.entriesReceived).toBe(3);313+ expect(result.entriesSent).toBe(0);314+ });315+316+ it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {317+ await bootVault();318+ const { session, peer } = inMemoryPair();319+ const adapter: ChirpSyncVaultAdapter = {320+ ...emptyAdapter(),321+ async getEncryptedEntries(ids) {322+ const out: Record<string, string> = {};323+ for (const id of ids) out[id] = "ENVELOPE_" + id;324+ return out;325+ },326+ };327+ const deps: SyncRunnerDeps = {328+ openSession: vi.fn(async () => session),329+ probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),330+ getSubnet: vi.fn(async () => "192.168.1"),331+ createAdapter: () => adapter,332+ };333+ const { runWithDeps } = await import("./chirp-sync-runner");334+ const runPromise = runWithDeps(makeDevice(), deps);335+ await runPeer(peer, { wantFromUsIds: ["a", "b"] });336+ const result = await runPromise;337+ expect(result.entriesSent).toBe(2);338+ });339+340+ it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {341+ await bootVault();342+ const { session, peer } = inMemoryPair();343+ const deps: SyncRunnerDeps = {344+ openSession: vi.fn(async () => session),345+ probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),346+ getSubnet: vi.fn(async () => "192.168.1"),347+ createAdapter: emptyAdapter,348+ };349+ const { runWithDeps } = await import("./chirp-sync-runner");350+ const runPromise = runWithDeps(makeDevice(), deps);351+ // Peer responds with a vault_id that does NOT match ours, forcing the352+ // coordinator to send SYNC_ERROR and return status:"vault_mismatch".353+ (async () => {354+ const hello = await peer.receive();355+ if (hello.type === "SYNC_HELLO") {356+ await peer.send({357+ type: "SYNC_HELLO",358+ vaultId: "wrong-vault-id",359+ deviceId: "peer-device",360+ });361+ }362+ })().catch(() => undefined);363+ await expect(runPromise).rejects.toThrow("vault_id_mismatch");364+ });365+});366+367+describe("sync-trigger persistence", () => {368+ let local: StorageShape;369+ let sync: StorageShape;370+371+ beforeEach(() => {372+ vi.resetModules();373+ local = {};374+ sync = {};375+ (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);376+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));377+ });378+379+ it("persists lastSyncAt to chrome.storage.local on a successful sync", async () => {380+ const trigger = await import("./sync-trigger");381+ const paired = await import("./paired-devices");382+ trigger._resetSyncTriggerForTest();383+ await paired.upsertPairedDevice({384+ deviceId: "P",385+ deviceName: "Pixel",386+ deviceType: "android",387+ longTermPublicKeyHex: "a".repeat(64),388+ pairedAt: 1700000000000,389+ });390+ trigger.setSyncRunner(async () => ({ entriesReceived: 1, entriesSent: 0 }));391+ await trigger.triggerSyncIfReachable({ reason: "manual" });392+ expect(typeof local.peach_last_sync_at).toBe("number");393+ expect(local.peach_last_sync_at).toBeGreaterThan(0);394+ });395+396+ it("hydratePersistedSyncState fills lastSyncAt from storage when in-memory state is empty", async () => {397+ local.peach_last_sync_at = 1_700_005_000_000;398+ const trigger = await import("./sync-trigger");399+ trigger._resetSyncTriggerForTest();400+ expect(trigger.getSyncState().lastSyncAt).toBeUndefined();401+ await trigger.hydratePersistedSyncState();402+ expect(trigger.getSyncState().lastSyncAt).toBe(1_700_005_000_000);403+ });404+});src/background/chirp-sync-runner.ts+178 −0
@@ -0,0 +1,178 @@1+// Production sync runner — composes the live vault adapter, WebSocket2+// transport, and coordinator into the function the dispatcher injects via3+// `setSyncRunner`. The runner is responsible for opening one Noise XX4+// session against a single paired device, driving the sync protocol, and5+// surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in6+// sync-trigger.ts iterates over hosts and aggregates results.7+//8+// Connection strategy:9+// 1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path.10+// 2. Fall back to `probeSubnetForPeer` over the local /24. The subnet11+// probe is itself rate-limited (60s per-pubkey cooldown) inside the12+// transport, so we don't add another layer here.13+// 3. On a successful session, persist the IP we connected on so the next14+// sync round skips the probe.15+//16+// We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange17+// does that (returns `status: "vault_mismatch"` if the peer holds a18+// different vault). The runner just translates the coordinator's outcome19+// into the throw-or-return contract the dispatcher expects.20+21+import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";22+import { createChirpSyncVaultAdapter, VaultLockedError } from "./chirp-sync-vault-adapter";23+import {24+ getLastKnownIp,25+ setLastKnownIp,26+} from "./paired-devices";27+import {28+ openChirpSyncSession,29+ probeSubnetForPeer,30+ type ChirpSyncSession,31+ type SyncEndpoint,32+} from "../shared/chirp-sync-transport";33+import { deriveSyncIdentityKeypair, hexToPubkey } from "../shared/sync-identity";34+import { getLocalSubnet } from "../shared/local-network";35+import { getOrCreateDeviceId } from "./chirp-identity";36+import { getDerivedKeyHex, isUnlocked } from "./vault";37+import type { PairedDevice } from "../shared/sync-types";38+import type { SyncRunResult } from "./sync-trigger";39+40+const DEFAULT_PORT = 8765;41+42+/**43+ * Test/injection seam. Production wires in the real `openChirpSyncSession`;44+ * unit tests inject a fake to avoid real WebSocket + Noise XX.45+ */46+export interface SyncRunnerDeps {47+ openSession: (48+ endpoint: SyncEndpoint,49+ ourPriv: Uint8Array,50+ ourPub: Uint8Array,51+ expectedRemoteHex: string,52+ ) => Promise<ChirpSyncSession>;53+ probeSubnet: (54+ subnet: string,55+ port: number,56+ ourPriv: Uint8Array,57+ ourPub: Uint8Array,58+ expectedRemoteHex: string,59+ ) => Promise<SyncEndpoint | null>;60+ getSubnet: () => Promise<string | null>;61+ createAdapter: () => ChirpSyncVaultAdapter;62+}63+64+const productionDeps: SyncRunnerDeps = {65+ openSession: (endpoint, priv, pub, expected) =>66+ openChirpSyncSession(endpoint, priv, pub, expected),67+ probeSubnet: (subnet, port, priv, pub, expected) =>68+ probeSubnetForPeer(subnet, port, priv, pub, expected),69+ getSubnet: getLocalSubnet,70+ createAdapter: createChirpSyncVaultAdapter,71+};72+73+export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {74+ return runWithDeps(device, productionDeps);75+}76+77+/**78+ * Inner runner with explicit deps — exported so tests can inject fakes.79+ */80+export async function runWithDeps(81+ device: PairedDevice,82+ deps: SyncRunnerDeps,83+): Promise<SyncRunResult> {84+ if (!isUnlocked()) throw new VaultLockedError();85+ if (!device.longTermPublicKeyHex) {86+ throw new Error("paired_device_missing_pubkey");87+ }88+ const expectedRemoteHex = device.longTermPublicKeyHex;89+ const port = device.wsPort ?? DEFAULT_PORT;90+91+ const masterKeyHex = getDerivedKeyHex();92+ if (!masterKeyHex) throw new VaultLockedError();93+ const masterKey = hexToBytesLocal(masterKeyHex);94+ const ourDeviceId = await getOrCreateDeviceId();95+ const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);96+97+ // Sanity — the remote pubkey must be 32-byte hex so noise can compare it.98+ void hexToPubkey(expectedRemoteHex);99+100+ const adapter = deps.createAdapter();101+ const session = await connect(device, expectedRemoteHex, port, ourKeypair, deps);102+103+ try {104+ const outcome = await runChirpSyncInitiator(session, adapter);105+ if (outcome.status === "completed") {106+ return {107+ entriesReceived: outcome.entriesReceived,108+ entriesSent: outcome.entriesSent,109+ };110+ }111+ if (outcome.status === "vault_mismatch") {112+ throw new Error("vault_id_mismatch");113+ }114+ if (outcome.status === "timeout") {115+ throw new Error("sync_timeout");116+ }117+ throw new Error(outcome.error ?? "sync_errored");118+ } finally {119+ // The coordinator already closes the session inside its own `finally`;120+ // calling close again is harmless because `ChirpSyncSession.close()` is121+ // idempotent (ws.close() is a no-op once closed).122+ await session.close().catch(() => undefined);123+ }124+}125+126+async function connect(127+ _device: PairedDevice,128+ expectedRemoteHex: string,129+ port: number,130+ ourKeypair: { secretKey: Uint8Array; publicKey: Uint8Array },131+ deps: SyncRunnerDeps,132+): Promise<ChirpSyncSession> {133+ const cached = await getLastKnownIp(expectedRemoteHex);134+ if (cached) {135+ try {136+ const session = await deps.openSession(137+ { ip: cached, port },138+ ourKeypair.secretKey,139+ ourKeypair.publicKey,140+ expectedRemoteHex,141+ );142+ return session;143+ } catch {144+ // Cached IP stale — fall through to the subnet probe.145+ }146+ }147+148+ const subnet = await deps.getSubnet();149+ if (!subnet) {150+ throw new Error(cached ? "host_unreachable" : "subnet_unknown");151+ }152+153+ const found = await deps.probeSubnet(154+ subnet,155+ port,156+ ourKeypair.secretKey,157+ ourKeypair.publicKey,158+ expectedRemoteHex,159+ );160+ if (!found) throw new Error("host_unreachable");161+162+ const session = await deps.openSession(163+ found,164+ ourKeypair.secretKey,165+ ourKeypair.publicKey,166+ expectedRemoteHex,167+ );168+ await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);169+ return session;170+}171+172+function hexToBytesLocal(hex: string): Uint8Array {173+ const out = new Uint8Array(hex.length / 2);174+ for (let i = 0; i < out.length; i++) {175+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);176+ }177+ return out;178+}src/background/entry.ts+7 −0
@@ -6,6 +6,8 @@66 import { ensureSettingsLoaded } from "./vault";77 import { isRecoveryConfirmPending } from "./recovery-pending";88 import { denyAllPendingPasskeyRequests } from "./passkey";9+import { hydratePersistedSyncState, setSyncRunner } from "./sync-trigger";10+import { productionSyncRunner } from "./chirp-sync-runner";911 1012 // Keep alive for MV3 service worker1113 chrome.runtime.onInstalled.addListener(() => {});@@ -36,6 +38,11 @@3638 void setupAutoLock();3739 setupCommands();3840 void setupPasskeyProxy();41+ // Wire the chirp-sync production runner into the dispatcher. Without this42+ // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the43+ // pairing/transport/coordinator stack ships dormant until this assignment.44+ setSyncRunner(productionSyncRunner);45+ void hydratePersistedSyncState();3946 }4047 4148 start();src/background/sync-trigger.ts+25 −2
@@ -10,7 +10,12 @@1010 // can be unit-tested without WebSocket/Noise transport. Production wires1111 // in `runChirpSyncWithDevice` from `./sync-runner` at module load.1212 13-import { listPairedDevices, markLastSyncedAt } from "./paired-devices";13+import {14+ getLastSyncAt,15+ listPairedDevices,16+ markLastSyncedAt,17+ setLastSyncAt,18+} from "./paired-devices";1419 import type { PairedDevice } from "../shared/sync-types";1520 1621 export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";@@ -51,6 +56,20 @@5156 return lastState;5257 }5358 59+/**60+ * Re-hydrate `lastSyncAt` from `chrome.storage.local`. The MV3 service61+ * worker dies and restarts on the slightest provocation; without this the62+ * popup's "Last synced N minutes ago" indicator resets to "never" every63+ * time the SW respawns. Called once at startup from `entry.ts`.64+ */65+export async function hydratePersistedSyncState(): Promise<void> {66+ if (lastState.lastSyncAt !== undefined) return;67+ const persisted = await getLastSyncAt();68+ if (typeof persisted === "number") {69+ lastState = { ...lastState, lastSyncAt: persisted };70+ }71+}72+5473 export async function triggerSyncIfReachable(opts: {5574 reason: SyncTriggerReason;5675 force?: boolean;@@ -99,13 +118,17 @@99118 }100119 101120 if (anySucceeded) {121+ const now = Date.now();102122 setState({103123 status: "idle",104- lastSyncAt: Date.now(),124+ lastSyncAt: now,105125 entriesReceived,106126 entriesSent,107127 error: undefined,108128 });129+ // Persist so the popup's "last synced" indicator survives a SW restart.130+ // Best-effort — a storage failure must not surface as a sync failure.131+ void setLastSyncAt(now).catch(() => undefined);109132 } else {110133 setState({111134 status: "error",