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
7 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 −2
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.87",5+  "version": "3.6.88",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.87",4+  "version": "3.6.88",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.87",3+  "version": "3.6.88",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

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+});

view file

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+}

view file

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();

view file

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",

view file