d04f5b124fc2

feat: sync coordinator includes desktop peers in sync session attempts (v4.0.2)

time
2026-06-17 13:18 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
desktop helper pairing integration
parent
64236f0049f1
2 files src/background/sync-trigger.desktop.test.ts+127 −0src/background/sync-trigger.ts+3 −1
src/background/sync-trigger.desktop.test.ts+127 −0
@@ -0,0 +1,127 @@1+// Change 6 — desktop peers participate in sync sessions.2+//3+// Relocated from drafts/DESKTOP-PAIRING/06-sync-coordinator-desktop.md.4+// Spec 1 asserts the host filter (sync-trigger.ts:104) includes "desktop".5+// Spec 2 asserts the browser's SYNC_HELLO carries a vaultId equal to the6+// desktop helper's stored vault_id (== computeVaultId of the same master key),7+// which is what peach-sync compares byte-for-byte (sync_coordinator.zig:141).8+9+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";10+11+import {12+  triggerSyncIfReachable,13+  setSyncRunner,14+  _resetSyncTriggerForTest,15+} from "./sync-trigger";16+import { upsertPairedDevice } from "./paired-devices";17+import { computeVaultId } from "../shared/sync-identity";18+import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";19+import type { ChirpSyncSession } from "../shared/chirp-sync-transport";20+import type { ChirpSyncMessage } from "../shared/sync-types";21+import type { PairedDevice } from "../shared/sync-types";22+23+// Minimal chrome.storage.local fake — paired-devices and sync-trigger both read24+// through it. Re-initialised each beforeEach so state never leaks across specs.25+function makeStorageStub(): void {26+  const store: Record<string, unknown> = {};27+  (globalThis as unknown as { chrome: unknown }).chrome = {28+    storage: {29+      local: {30+        get: async (keys?: string | string[]) => {31+          if (!keys) return { ...store };32+          const list = Array.isArray(keys) ? keys : [keys];33+          const out: Record<string, unknown> = {};34+          for (const k of list) if (k in store) out[k] = store[k];35+          return out;36+        },37+        set: async (obj: Record<string, unknown>) => { Object.assign(store, obj); },38+        remove: async (keys: string | string[]) => {39+          for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];40+        },41+      },42+    },43+    runtime: { lastError: undefined },44+  };45+}46+47+const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));48+49+const DESKTOP: PairedDevice = {50+  deviceId: "desktop-001",51+  deviceName: "June's iMac",52+  deviceType: "desktop",53+  longTermPublicKeyHex: "a".repeat(64),54+  trustSource: "direct",55+  wsPort: 8765,56+  pairedAt: 1700000000000,57+};58+59+beforeEach(() => {60+  makeStorageStub();61+  _resetSyncTriggerForTest();62+});63+64+afterEach(() => {65+  vi.restoreAllMocks();66+});67+68+describe("Change 6 — desktop peers in sync", () => {69+  it("desktop peer is included in sync session attempts", async () => {70+    await upsertPairedDevice(DESKTOP);71+    const seen: string[] = [];72+    setSyncRunner(async (device) => {73+      seen.push(device.deviceId);74+      return { ok: true, entriesReceived: 0, entriesSent: 0 };75+    });76+77+    await triggerSyncIfReachable({ reason: "manual" });78+79+    // Before Change 6, the filter (sync-trigger.ts:104) only matched80+    // android|ios, so the runner was never called for a desktop host.81+    expect(seen).toEqual([DESKTOP.deviceId]);82+  });83+84+  it("SYNC_HELLO vaultId matches the desktop helper's stored vault_id", async () => {85+    // The helper stores exactly the vault_id the browser sent at pairing86+    // (peach-sync pairing.zig:212), which is computeVaultId(masterKey). At87+    // sync time the browser sends SYNC_HELLO { vaultId: computeVaultId(...) }.88+    // peach-sync compares the two byte-for-byte (sync_coordinator.zig:141).89+    const ourVaultId = await computeVaultId(MASTER);90+91+    // Capture frames the coordinator sends; reply just enough to reach the92+    // equal-version SYNC_DONE shortcut so the round completes cleanly.93+    const sent: ChirpSyncMessage[] = [];94+    const inbound: ChirpSyncMessage[] = [95+      { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: "desktop-001", version: 7, deviceTime: Date.now() },96+      { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 },97+    ];98+    let rx = 0;99+    const session: ChirpSyncSession = {100+      send: (bytes: Uint8Array) => { sent.push(JSON.parse(new TextDecoder().decode(bytes))); },101+      receive: async () => new TextEncoder().encode(JSON.stringify(inbound[rx++])),102+      close: async () => {},103+    } as unknown as ChirpSyncSession;104+105+    const adapter: ChirpSyncVaultAdapter = {106+      computeVaultId: async () => ourVaultId,107+      getOurDeviceId: async () => "ext-local",108+      getLocalVersion: async () => 7, // equal version → SYNC_DONE shortcut109+      buildEntryManifest: async () => ({}),110+      buildTombstoneManifest: async () => ({}),111+      getEncryptedEntries: async () => ({}),112+      applyEntries: async () => {},113+      applyTombstones: async () => {},114+    };115+116+    const outcome = await runChirpSyncInitiator(session, adapter);117+118+    const hello = sent.find((m) => m.type === "SYNC_HELLO") as119+      | { type: "SYNC_HELLO"; vaultId: string }120+      | undefined;121+    expect(hello).toBeTruthy();122+    // This is the exact value peach-sync compares against opts.vault_id.123+    expect(hello!.vaultId).toBe(ourVaultId);124+    expect(hello!.vaultId).toMatch(/^[0-9a-f]{64}$/);125+    expect(outcome.status).toBe("completed");126+  });127+});

view file

src/background/sync-trigger.ts+3 −1
@@ -103,7 +103,9 @@103103     const devices = await listPairedDevices();104104     const hosts = devices.filter(105105       (d) =>106-        (d.deviceType === "android" || d.deviceType === "ios") &&106+        (d.deviceType === "android" ||107+          d.deviceType === "ios" ||108+          d.deviceType === "desktop") &&107109         d.trustSource === "direct",108110     );109111     if (hosts.length === 0) return;

view file