1672cc400bce

rename the pairing surface to say what it is: code-pairing modules, PairDevice view, PairingBirds, PAIR_WITH_CODE/CODE_PAIR_* messages (v4.0.31)

time
2026-07-24 23:47 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
code pairing rename
parent
ab3f2d5e6a51
20 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing-bg.ts+135 −0src/background/code-pairing.test.ts+384 −0src/background/code-pairing.ts+157 −0src/background/desktop-pairing-bg.ts+0 −144src/background/desktop-pairing.test.ts+0 −392src/background/desktop-pairing.ts+0 −167src/background/messages.ts+9 −9src/popup/App.tsx+3 −3src/popup/components/ChirpPairingFlow.tsx+0 −161src/popup/components/PairingBirds.tsx+161 −0src/popup/views/ChirpSync.desktop.test.tsx+0 −276src/popup/views/ChirpSync.tsx+0 −342src/popup/views/PairDevice.test.tsx+276 −0src/popup/views/PairDevice.tsx+341 −0src/popup/views/Settings.tsx+2 −2src/shared/chirp-handshake.ts+1 −1src/shared/types.ts+6 −7
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.30",4+  "version": "4.0.31",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.30",4+  "version": "4.0.31",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": "4.0.30",3+  "version": "4.0.31",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/code-pairing-bg.ts+135 −0
@@ -0,0 +1,135 @@1+// Background wiring for code pairing.2+//3+// Keeps `code-pairing.ts` a PURE, dependency-injected client (so its unit tests4+// run without chrome / vault / WebSocket). This module supplies the production5+// `CodePairingDeps` and the single-flight SAS/confirm round-trip that bridges6+// the blocking `confirm(sas)` callback to the popup via runtime messages:7+//8+//   PAIR_WITH_CODE    → startCodePairing()            (popup → bg, messages.ts)9+//   CODE_PAIR_SAS     ← push when onSasReady fires    (bg → popup)10+//   CODE_PAIR_CONFIRM → resolveCodePairingConfirm()   (popup → bg, messages.ts)11+//   CODE_PAIR_RESULT  ← push on complete/error        (bg → popup)12+13+import {14+  pairWithCode,15+  type CodePairingDeps,16+  type CodePairingCallbacks,17+} from "./code-pairing";18+import { openPairingSocket } from "../shared/pairing-dial";19+import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";20+import { buildLocalIdentity } from "./chirp-identity";21+import { computeVaultId } from "../shared/sync-identity";22+import { isUnlocked, getRootKeyHex } from "./vault";23+import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";24+import { discoverPeachSync } from "./desktop-pairing-mdns";25+import { getLocalSubnet } from "../shared/local-network";26+import type { LocalIdentity } from "../shared/chirp-handshake";27+28+function hexToBytesLocal(hex: string): Uint8Array {29+  const out = new Uint8Array(hex.length / 2);30+  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);31+  return out;32+}33+34+/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */35+function push(type: string, payload: unknown): void {36+  try {37+    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;38+    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {39+      (maybe as Promise<unknown>).catch(() => undefined);40+    }41+  } catch {42+    // Popup closed / no receiver — fine.43+  }44+}45+46+/** Production dependency set — real probe, WebSocket, identity, and storage. */47+export function productionCodePairingDeps(): CodePairingDeps {48+  return {49+    async findHost(port) {50+      // Localhost first: peach-sync runs on the same machine as the extension.51+      // This works in every browser without mDNS or WebRTC, and is the fastest52+      // path. The daemon binds 0.0.0.0 so 127.0.0.1 is always reachable.53+      if (await wsPortOpen("127.0.0.1", port, 800)) return "127.0.0.1";54+      // mDNS next — instant when chrome.mdns is available (Chrome/Chromium).55+      const discovered = await discoverPeachSync();56+      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;57+      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).58+      const subnet = await getLocalSubnet();59+      if (!subnet) return null;60+      return probeSubnetForPort(subnet, port);61+    },62+    openSocket: (ip, port) => openPairingSocket(ip, port),63+    async getLocalIdentity(): Promise<LocalIdentity> {64+      if (!isUnlocked()) throw new Error("vault_locked");65+      const masterKeyHex = getRootKeyHex();66+      if (!masterKeyHex) throw new Error("vault_locked");67+      // SEV-2-B: derive from the stable ROOT key so pairing survives a68+      // master-password change. Zero the copy when done.69+      const masterKey = hexToBytesLocal(masterKeyHex);70+      try {71+        const base = await buildLocalIdentity(masterKey);72+        const vaultId = await computeVaultId(masterKey);73+        return { ...base, vaultId };74+      } finally {75+        masterKey.fill(0);76+      }77+    },78+    persistDevice: (d) => upsertPairedDevice(d),79+    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),80+  };81+}82+83+// ── Single in-flight pairing per worker ──────────────────────────────────────84+// One pairing at a time. The blocking confirm() resolver is parked here while85+// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.86+let pendingConfirm: ((confirmed: boolean) => void) | null = null;87+let inFlight = false;88+89+function clearPairingState(): void {90+  pendingConfirm = null;91+  inFlight = false;92+}93+94+export function isCodePairingInFlight(): boolean {95+  return inFlight;96+}97+98+/**99+ * Kick off a code pairing. Returns immediately; the SAS and the terminal100+ * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT101+ * pushes. `deps` is injectable for tests.102+ */103+export function startCodePairing(104+  code: string,105+  deps: CodePairingDeps = productionCodePairingDeps(),106+): void {107+  inFlight = true;108+  const cb: CodePairingCallbacks = {109+    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),110+    onComplete: (device) => {111+      push("CODE_PAIR_RESULT", { status: "complete", device });112+      clearPairingState();113+    },114+    onError: (error) => {115+      push("CODE_PAIR_RESULT", { status: "error", error });116+      clearPairingState();117+    },118+  };119+  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.120+  const confirm = (_sas: string): Promise<boolean> =>121+    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });122+123+  void pairWithCode(code, cb, deps, confirm).finally(() => {124+    // onComplete/onError already cleared state; this is the backstop for an125+    // unexpected throw that skipped the callbacks.126+    if (inFlight) clearPairingState();127+  });128+}129+130+/** Resolve the parked confirm() with the user's decision from the popup. */131+export function resolveCodePairingConfirm(confirmed: boolean): void {132+  const resolve = pendingConfirm;133+  pendingConfirm = null;134+  if (resolve) resolve(confirmed);135+}

view file

src/background/code-pairing.test.ts+384 −0
@@ -0,0 +1,384 @@1+// Code pairing — the PAIR_IDENTITY half, over both code forms.2+//3+// Driven entirely through the dependency-injection seam (CodePairingDeps and a4+// fake PairingSocket), so no real WebSocket or Noise crypto runs — except the5+// SAS KAT, which calls the REAL `derivePairingSAS` to pin cross-implementation6+// agreement with the peer.7+8+import { beforeEach, describe, expect, it, vi } from "vitest";9+10+import {11+  pairWithCode,12+  type CodePairingDeps,13+  type CodePairingCallbacks,14+} from "./code-pairing";15+import { PAIRING_PORT, type PairingSocket } from "../shared/pairing-dial";16+17+// REAL SAS implementation — reused, not re-derived (ground truth §5).18+import { derivePairingSAS } from "../shared/chirp-handshake";19+import { encodePairingCode } from "../shared/pairing-code";20+import type { LocalIdentity } from "../shared/chirp-handshake";21+import type { PairedDevice } from "../shared/sync-types";22+23+// ── helpers ────────────────────────────────────────────────────────────────24+25+const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));26+const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));27+28+const HELPER_EPHEMERAL_HEX = "a".repeat(64);29+const HELPER_LONGTERM_HEX = "b".repeat(64);30+31+const OUR_IDENTITY: LocalIdentity = {32+  deviceId: "ext-local-001",33+  deviceName: "Firefox on MacBook",34+  deviceType: "extension",35+  longTermPublicKeyHex: "c".repeat(64),36+  signingPublicKeyHex: "d".repeat(64),37+  vaultId: "e".repeat(64),38+};39+40+/**41+ * Scripted fake socket. Captures everything sent, and replays a queued list42+ * of inbound frames. A frame of `null` means "the socket closes here with no43+ * data" — the peach-sync wrong-code behaviour (no error frame).44+ */45+function makeFakeSocket(inbound: Array<Uint8Array | null>) {46+  const sent: Uint8Array[] = [];47+  let closed = false;48+  let lastWasClose = false;49+  const queue = [...inbound];50+  const sock: PairingSocket = {51+    send(bytes) { sent.push(bytes); },52+    async receive() {53+      const next = queue.shift();54+      if (next === undefined || next === null) {55+        closed = true;56+        lastWasClose = true;57+        throw new Error("socket closed");58+      }59+      lastWasClose = false;60+      return next;61+    },62+    close() { closed = true; },63+    closedBeforeFrame() { return lastWasClose; },64+  };65+  return { sock, sent, isClosed: () => closed };66+}67+68+/**69+ * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we70+ * can drive the PAIR_IDENTITY exchange without real crypto. We stub71+ * `noiseNKInitiator` via vi.mock below; this is the shape it returns.72+ */73+const FAKE_HANDSHAKE_HASH = Uint8Array.from([74+  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),75+]);76+77+// Mock the NK initiator: skip the two handshake messages, hand back a session78+// whose encrypt/decrypt are identity transforms and whose handshakeHash is the79+// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).80+vi.mock("../shared/noise-js", async (orig) => {81+  const actual = await orig<typeof import("../shared/noise-js")>();82+  return {83+    ...actual,84+    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({85+      encrypt: (pt: Uint8Array) => pt,86+      decrypt: (ct: Uint8Array) => ct,87+      handshakeHash: FAKE_HANDSHAKE_HASH,88+    })),89+  };90+});91+92+function makeDeps(overrides: Partial<CodePairingDeps> = {}): {93+  deps: CodePairingDeps;94+  persisted: PairedDevice[];95+  sockets: ReturnType<typeof makeFakeSocket>[];96+} {97+  const persisted: PairedDevice[] = [];98+  const sockets: ReturnType<typeof makeFakeSocket>[] = [];99+  const deps: CodePairingDeps = {100+    findHost: vi.fn(async () => "192.168.1.50"),101+    openSocket: vi.fn(async () => {102+      // default: ACK then a PAIR_IDENTITY response103+      const s = makeFakeSocket([104+        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),105+        enc({106+          type: "PAIR_IDENTITY",107+          device_id: "desktop-001",108+          device_name: "June's iMac",109+          device_type: "desktop",110+          long_term_public_key_hex: HELPER_LONGTERM_HEX,111+          ws_port: 8765,112+        }),113+      ]);114+      sockets.push(s);115+      return s.sock;116+    }),117+    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),118+    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),119+    ...overrides,120+  };121+  return { deps, persisted, sockets };122+}123+124+function makeCallbacks(): {125+  cb: CodePairingCallbacks;126+  sas: { value?: string };127+  errors: string[];128+  completed: PairedDevice[];129+} {130+  const sas: { value?: string } = {};131+  const errors: string[] = [];132+  const completed: PairedDevice[] = [];133+  const cb: CodePairingCallbacks = {134+    onSasReady: (s) => { sas.value = s; },135+    onError: (e) => { errors.push(e); },136+    onComplete: (d) => { completed.push(d); },137+  };138+  return { cb, sas, errors, completed };139+}140+141+beforeEach(() => {142+  vi.clearAllMocks();143+});144+145+// ── specs ────────────────────────────────────────────────────────────────146+147+describe("pairWithCode", () => {148+  it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {149+    const { deps, sockets } = makeDeps();150+    const { cb } = makeCallbacks();151+152+    await pairWithCode("123", cb, deps, async () => true);153+154+    expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", PAIRING_PORT);155+    const firstFrame = dec(sockets[0].sent[0]);156+    expect(firstFrame.type).toBe("PEACH_HELLO");157+    // "123" must be normalised to exactly 6 ASCII digits, zero-padded.158+    expect(firstFrame.code).toBe("000123");159+    expect(firstFrame.code).toMatch(/^\d{6}$/);160+  });161+162+  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {163+    // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY164+    // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the165+    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces166+    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)167+    const closeImmediately = makeFakeSocket([null]); // close, no data168+    const { deps } = makeDeps({169+      openSocket: vi.fn(async () => closeImmediately.sock),170+    });171+    const { cb, errors, completed } = makeCallbacks();172+173+    await pairWithCode("654321", cb, deps, async () => true);174+175+    expect(errors).toEqual(["invalid_code"]);176+    expect(completed).toHaveLength(0);177+    expect(deps.persistDevice).not.toHaveBeenCalled();178+  });179+180+  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {181+    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake182+    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;183+    // 263895245 mod 1_000_000 = 895245.184+    const hash = Uint8Array.from([185+      0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),186+    ]);187+    expect(derivePairingSAS(hash)).toBe("895245");188+  });189+190+  it("surfaces that same SAS through the pairing flow", async () => {191+    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.192+    const { deps } = makeDeps();193+    const { cb, sas } = makeCallbacks();194+195+    await pairWithCode("000123", cb, deps, async () => true);196+197+    expect(sas.value).toBe("895245");198+  });199+200+  it("persists the device with deviceType 'desktop' on SAS confirm", async () => {201+    const { deps, persisted } = makeDeps();202+    const { cb, completed, errors } = makeCallbacks();203+204+    await pairWithCode("000123", cb, deps, async () => true);205+206+    expect(errors).toHaveLength(0);207+    expect(persisted).toHaveLength(1);208+    expect(persisted[0].deviceType).toBe("desktop");209+    expect(persisted[0].deviceId).toBe("desktop-001");210+    expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);211+    expect(persisted[0].trustSource).toBe("direct");212+    expect(persisted[0].wsPort).toBe(8765);213+    expect(completed).toHaveLength(1);214+  });215+216+  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {217+    const { deps, persisted } = makeDeps();218+    const { cb, errors } = makeCallbacks();219+220+    await pairWithCode("000123", cb, deps, async () => false);221+222+    expect(errors).toEqual(["sas_mismatch"]);223+    expect(persisted).toHaveLength(0);224+    expect(deps.persistDevice).not.toHaveBeenCalled();225+  });226+227+  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {228+    const { deps, sockets } = makeDeps();229+    const { cb } = makeCallbacks();230+231+    await pairWithCode("000123", cb, deps, async () => true);232+233+    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY234+    // (encrypt is identity in the mock, so it decodes directly).235+    const ourPairIdentity = dec(sockets[0].sent[1]);236+    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");237+    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);238+  });239+240+  it("reports no_host_found when the probe returns null", async () => {241+    const { deps } = makeDeps({ findHost: vi.fn(async () => null) });242+    const { cb, errors } = makeCallbacks();243+244+    await pairWithCode("000123", cb, deps, async () => true);245+246+    expect(errors).toEqual(["no_host_found"]);247+    expect(deps.openSocket).not.toHaveBeenCalled();248+  });249+250+  it("reports vault_locked when local identity is unavailable", async () => {251+    const { deps } = makeDeps({252+      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),253+    });254+    const { cb, errors } = makeCallbacks();255+256+    await pairWithCode("000123", cb, deps, async () => true);257+258+    expect(errors).toEqual(["vault_locked"]);259+    expect(deps.findHost).not.toHaveBeenCalled();260+  });261+});262+263+// ── pairing-code path (the phone is the listener) ──────────────────────────264+265+const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);266+const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);267+const PHONE_LONGTERM_HEX = "f".repeat(64);268+269+/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */270+function makePhoneDeps(deviceType: string | undefined = "android") {271+  const identity: Record<string, unknown> = {272+    type: "PAIR_IDENTITY",273+    device_id: "android-001",274+    device_name: "Pixel 8",275+    long_term_public_key_hex: PHONE_LONGTERM_HEX,276+    ws_port: 8765,277+  };278+  identity.device_type = deviceType;279+  return makeDeps({280+    openSocket: vi.fn(async () => makeFakeSocket([281+      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),282+      enc(identity),283+    ]).sock),284+  });285+}286+287+describe("pairWithCode — pairing code", () => {288+  it("dials the address in the code instead of probing for a host", async () => {289+    const { deps } = makePhoneDeps();290+    const { cb, errors } = makeCallbacks();291+292+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);293+294+    expect(errors).toHaveLength(0);295+    expect(deps.findHost).not.toHaveBeenCalled();296+    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);297+  });298+299+  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {300+    const sockets: ReturnType<typeof makeFakeSocket>[] = [];301+    const { deps } = makeDeps({302+      openSocket: vi.fn(async () => {303+        const s = makeFakeSocket([304+          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),305+          enc({306+            type: "PAIR_IDENTITY",307+            device_id: "android-001",308+            device_name: "Pixel 8",309+            device_type: "android",310+            long_term_public_key_hex: PHONE_LONGTERM_HEX,311+            ws_port: 8765,312+          }),313+        ]);314+        sockets.push(s);315+        return s.sock;316+      }),317+    });318+    const { cb } = makeCallbacks();319+320+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);321+322+    const hello = dec(sockets[0].sent[0]);323+    expect(hello.type).toBe("PEACH_HELLO");324+    expect(hello.token).toBe("deadbeef");325+    expect(hello.code).toBeUndefined();326+  });327+328+  it("persists the phone with the device type it announced", async () => {329+    const { deps, persisted } = makePhoneDeps();330+    const { cb, completed } = makeCallbacks();331+332+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);333+334+    expect(persisted).toHaveLength(1);335+    expect(persisted[0].deviceType).toBe("android");336+    expect(persisted[0].deviceId).toBe("android-001");337+    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);338+    expect(persisted[0].trustSource).toBe("direct");339+    expect(persisted[0].wsPort).toBe(8765);340+    expect(completed).toHaveLength(1);341+  });342+343+  it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {344+    const { deps, persisted } = makePhoneDeps("");345+    const { cb } = makeCallbacks();346+347+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);348+349+    expect(persisted[0].deviceType).toBe("android");350+  });351+352+  it("caches the coded IP for the next sync round", async () => {353+    const cacheLastKnownIp = vi.fn(async () => undefined);354+    const { deps } = makePhoneDeps();355+    deps.cacheLastKnownIp = cacheLastKnownIp;356+    const { cb } = makeCallbacks();357+358+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);359+360+    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");361+  });362+363+  it("reports no_host_found when the coded endpoint refuses the connection", async () => {364+    const { deps } = makeDeps({365+      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),366+    });367+    const { cb, errors } = makeCallbacks();368+369+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);370+371+    expect(errors).toEqual(["no_host_found"]);372+    expect(deps.persistDevice).not.toHaveBeenCalled();373+  });374+375+  it("still aborts on SAS rejection — the token is not the security boundary", async () => {376+    const { deps, persisted } = makePhoneDeps();377+    const { cb, errors } = makeCallbacks();378+379+    await pairWithCode(PHONE_CODE, cb, deps, async () => false);380+381+    expect(errors).toEqual(["sas_mismatch"]);382+    expect(persisted).toHaveLength(0);383+  });384+});

view file

src/background/code-pairing.ts+157 −0
@@ -0,0 +1,157 @@1+// Device pairing over a code — the PAIR_IDENTITY half.2+//3+// The dialling half (decode the code, connect, plaintext hello, Noise NK) lives4+// in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks5+// ADOPT_* frames over the same channel instead. This module is only what6+// happens once that channel is open:7+//8+//   1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)9+//   2. derive the SAS, surface it for user confirmation10+//   3. on confirm -> persist the PairedDevice; on reject -> abort without11+//      persisting12+//13+// Two code forms reach here. A pairing code (`A74H-48J3-KI32-OEP4`) is the14+// primary path: the phone listens and the code carries its address. A legacy15+// 6-digit helper code has no address, so the host is probed for.16+17+import { dialPairingChannel, type PairingDialDeps } from "../shared/pairing-dial";18+import type { LocalIdentity } from "../shared/chirp-handshake";19+import { parsePairingCode } from "../shared/pairing-code";20+import type { DeviceType, PairedDevice } from "../shared/sync-types";21+22+/** Default sync port a peer announces; used when ws_port is absent. */23+const DEFAULT_SYNC_PORT = 8765;24+25+export type CodePairingError =26+  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)27+  | "no_host_found"     // nothing answered at the endpoint28+  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed29+  | "sas_mismatch"      // user rejected the SAS30+  | "vault_locked";     // no master key available31+32+export interface CodePairingDeps extends PairingDialDeps {33+  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */34+  getLocalIdentity(): Promise<LocalIdentity>;35+  /** Persist on user confirm. */36+  persistDevice(device: PairedDevice): Promise<void>;37+  /** Optional fast-path cache for the next sync round. */38+  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;39+}40+41+export interface CodePairingCallbacks {42+  /** Show the 6-digit SAS; the popup must compare it against the peer's. */43+  onSasReady(sas: string): void;44+  /** Pairing succeeded and the device was persisted. */45+  onComplete(device: PairedDevice): void;46+  /** Pairing failed at some stage. */47+  onError(error: CodePairingError): void;48+}49+50+interface PairingFrame {51+  type: "PAIR_IDENTITY";52+  device_id: string;53+  device_name: string;54+  device_type: DeviceType;55+  long_term_public_key_hex: string;56+  signing_public_key_hex?: string;57+  ws_port?: number;58+  vault_id?: string; // Change 1 — additive, optional59+}60+61+function encodeJson(obj: unknown): Uint8Array {62+  return new TextEncoder().encode(JSON.stringify(obj));63+}64+65+function decodePairIdentity(bytes: Uint8Array): PairingFrame {66+  const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;67+  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");68+  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");69+  if (typeof p.device_name !== "string") throw new Error("device_name missing");70+  if (typeof p.device_type !== "string") throw new Error("device_type missing");71+  if (typeof p.long_term_public_key_hex !== "string" ||72+      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {73+    throw new Error("long_term_public_key_hex is not 32-byte hex");74+  }75+  return p as unknown as PairingFrame;76+}77+78+/**79+ * Drive a complete code pairing.80+ *81+ * `confirm` is a function the caller resolves AFTER the user has compared the82+ * SAS shown here against the SAS the peer displays. Resolving false (or83+ * rejecting) aborts before any device is persisted — this is the out-of-band84+ * MITM defence (see the SECURITY note in chirp-handshake.ts), and it, not the85+ * pairing token, is what actually authenticates the peer.86+ */87+export async function pairWithCode(88+  code: string,89+  cb: CodePairingCallbacks,90+  deps: CodePairingDeps,91+  confirm: (sas: string) => Promise<boolean>,92+): Promise<void> {93+  let identity: LocalIdentity;94+  try {95+    identity = await deps.getLocalIdentity();96+  } catch {97+    cb.onError("vault_locked");98+    return;99+  }100+101+  const dial = await dialPairingChannel(code, deps);102+  if (!dial.ok) {103+    cb.onError(dial.error);104+    return;105+  }106+  const { channel, sas, host, socket } = dial.dialled;107+108+  try {109+    // Initiator (browser) sends its identity FIRST, then reads the peer's.110+    const ourFrame: PairingFrame = {111+      type: "PAIR_IDENTITY",112+      device_id: identity.deviceId,113+      device_name: identity.deviceName,114+      device_type: identity.deviceType,115+      long_term_public_key_hex: identity.longTermPublicKeyHex,116+      signing_public_key_hex: identity.signingPublicKeyHex,117+      ws_port: identity.wsPort,118+      vault_id: identity.vaultId, // Change 1119+    };120+    await channel.send(encodeJson(ourFrame));121+122+    const theirFrame = decodePairIdentity(await channel.receive());123+124+    cb.onSasReady(sas);125+    const confirmed = await confirm(sas);126+    if (!confirmed) {127+      cb.onError("sas_mismatch");128+      return;129+    }130+131+    // ws_port comes from the peer frame, defaulting to 8765 (both peers132+    // announce it, but guard it). The device-type fallback follows the code133+    // form so a phone never lands in the registry labelled "desktop".134+    const device: PairedDevice = {135+      deviceId: theirFrame.device_id,136+      deviceName: theirFrame.device_name,137+      deviceType:138+        (theirFrame.device_type as DeviceType) ||139+        (parsePairingCode(code) ? "android" : "desktop"),140+      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,141+      signingPublicKeyHex: theirFrame.signing_public_key_hex,142+      trustSource: "direct",143+      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,144+      pairedAt: Date.now(),145+    };146+    await deps.persistDevice(device);147+    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {148+      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);149+    }150+    cb.onComplete(device);151+  } catch (err) {152+    if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);153+    cb.onError("handshake_failed");154+  } finally {155+    socket.close();156+  }157+}

view file

src/background/desktop-pairing-bg.ts+0 −144
@@ -1,144 +0,0 @@1-// Background wiring for desktop helper code pairing (Change 3 §Wiring).2-//3-// Keeps `desktop-pairing.ts` a PURE, dependency-injected client (so its unit4-// tests run without chrome / vault / WebSocket). This module supplies the5-// production `DesktopPairingDeps`, the real `WebSocket` adapter, and the6-// single-flight SAS/confirm round-trip that bridges the blocking7-// `confirm(sas)` callback to the popup via runtime messages:8-//9-//   PAIR_WITH_DESKTOP  → startDesktopPairing()         (popup → bg, messages.ts)10-//   DESKTOP_PAIR_SAS   ← push when onSasReady fires     (bg → popup)11-//   DESKTOP_PAIR_CONFIRM → resolveDesktopPairingConfirm (popup → bg, messages.ts)12-//   DESKTOP_PAIR_RESULT ← push on complete/error        (bg → popup)13-14-import {15-  pairWithDesktopHelper,16-  type DesktopPairingDeps,17-  type PairWithDesktopCallbacks,18-} from "./desktop-pairing";19-import { openPairingSocket } from "../shared/pairing-dial";20-import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";21-import { buildLocalIdentity } from "./chirp-identity";22-import { computeVaultId } from "../shared/sync-identity";23-import { isUnlocked, getRootKeyHex } from "./vault";24-import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";25-import { discoverPeachSync } from "./desktop-pairing-mdns";26-import { getLocalSubnet } from "../shared/local-network";27-import type { LocalIdentity } from "../shared/chirp-handshake";28-29-function hexToBytesLocal(hex: string): Uint8Array {30-  const out = new Uint8Array(hex.length / 2);31-  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);32-  return out;33-}34-35-/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */36-function push(type: string, payload: unknown): void {37-  try {38-    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;39-    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {40-      (maybe as Promise<unknown>).catch(() => undefined);41-    }42-  } catch {43-    // Popup closed / no receiver — fine.44-  }45-}46-47-/**48- * The WebSocket adapter moved to `shared/pairing-dial.ts` alongside the seam it49- * implements, so vault adoption (which runs in the popup, before a vault50- * exists) dials through the same code. Re-exported under its original name51- * because that is what existing callers import.52- */53-export { openPairingSocket as openBinaryWebSocket };54-55-/** Production dependency set — real probe, WebSocket, identity, and storage. */56-export function productionDesktopPairingDeps(): DesktopPairingDeps {57-  return {58-    async findHost(port) {59-      // Localhost first: peach-sync runs on the same machine as the extension.60-      // This works in every browser without mDNS or WebRTC, and is the fastest61-      // path. The daemon binds 0.0.0.0 so 127.0.0.1 is always reachable.62-      if (await wsPortOpen("127.0.0.1", port, 800)) return "127.0.0.1";63-      // mDNS next — instant when chrome.mdns is available (Chrome/Chromium).64-      const discovered = await discoverPeachSync();65-      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;66-      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).67-      const subnet = await getLocalSubnet();68-      if (!subnet) return null;69-      return probeSubnetForPort(subnet, port);70-    },71-    openSocket: (ip, port) => openPairingSocket(ip, port),72-    async getLocalIdentity(): Promise<LocalIdentity> {73-      if (!isUnlocked()) throw new Error("vault_locked");74-      const masterKeyHex = getRootKeyHex();75-      if (!masterKeyHex) throw new Error("vault_locked");76-      // SEV-2-B: derive from the stable ROOT key so pairing survives a77-      // master-password change. Zero the copy when done.78-      const masterKey = hexToBytesLocal(masterKeyHex);79-      try {80-        const base = await buildLocalIdentity(masterKey);81-        const vaultId = await computeVaultId(masterKey);82-        return { ...base, vaultId };83-      } finally {84-        masterKey.fill(0);85-      }86-    },87-    persistDevice: (d) => upsertPairedDevice(d),88-    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),89-  };90-}91-92-// ── Single in-flight pairing per worker ──────────────────────────────────────93-// One pairing at a time. The blocking confirm() resolver is parked here while94-// the popup shows the SAS; DESKTOP_PAIR_CONFIRM resolves it.95-let pendingConfirm: ((confirmed: boolean) => void) | null = null;96-let inFlight = false;97-98-function clearPairingState(): void {99-  pendingConfirm = null;100-  inFlight = false;101-}102-103-export function isDesktopPairingInFlight(): boolean {104-  return inFlight;105-}106-107-/**108- * Kick off a desktop pairing. Returns immediately; the SAS and the terminal109- * result are delivered to the popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT110- * pushes. `deps` is injectable for tests.111- */112-export function startDesktopPairing(113-  code: string,114-  deps: DesktopPairingDeps = productionDesktopPairingDeps(),115-): void {116-  inFlight = true;117-  const cb: PairWithDesktopCallbacks = {118-    onSasReady: (sas) => push("DESKTOP_PAIR_SAS", { sas }),119-    onComplete: (device) => {120-      push("DESKTOP_PAIR_RESULT", { status: "complete", device });121-      clearPairingState();122-    },123-    onError: (error) => {124-      push("DESKTOP_PAIR_RESULT", { status: "error", error });125-      clearPairingState();126-    },127-  };128-  // The confirm callback blocks on the popup's DESKTOP_PAIR_CONFIRM reply.129-  const confirm = (_sas: string): Promise<boolean> =>130-    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });131-132-  void pairWithDesktopHelper(code, cb, deps, confirm).finally(() => {133-    // onComplete/onError already cleared state; this is the backstop for an134-    // unexpected throw that skipped the callbacks.135-    if (inFlight) clearPairingState();136-  });137-}138-139-/** Resolve the parked confirm() with the user's decision from the popup. */140-export function resolveDesktopPairingConfirm(confirmed: boolean): void {141-  const resolve = pendingConfirm;142-  pendingConfirm = null;143-  if (resolve) resolve(confirmed);144-}

view file at the parent

src/background/desktop-pairing.test.ts+0 −392
@@ -1,392 +0,0 @@1-// Tests for Change 3 — desktop helper code pairing.2-//3-// DRAFT / apply-ready. These specs import from the proposed module4-// `src/background/desktop-pairing.ts` (Change 3). Path assumes this file is5-// relocated to `src/background/desktop-pairing.test.ts` on apply; from the6-// draft location the imports below would resolve as written if vitest's7-// rootDir maps `src/` — adjust the two `../../` prefixes when you move it.8-//9-// They use the dependency-injection seam (DesktopPairingDeps / a fake10-// DesktopPairingSocket) so no real WebSocket or Noise crypto runs — except11-// the SAS KAT, which calls the REAL `derivePairingSAS` from the shipped12-// chirp-handshake module to pin cross-impl agreement with peach-sync.13-14-import { beforeEach, describe, expect, it, vi } from "vitest";15-16-// Change 3 module (proposed):17-import {18-  pairWithDesktopHelper,19-  DESKTOP_PAIRING_PORT,20-  type DesktopPairingDeps,21-  type DesktopPairingSocket,22-  type PairWithDesktopCallbacks,23-} from "./desktop-pairing";24-25-// REAL SAS implementation — reused, not re-derived (ground truth §5).26-import { derivePairingSAS } from "../shared/chirp-handshake";27-import { encodePairingCode } from "../shared/pairing-code";28-import type { LocalIdentity } from "../shared/chirp-handshake";29-import type { PairedDevice } from "../shared/sync-types";30-31-// ── helpers ────────────────────────────────────────────────────────────────32-33-const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));34-const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));35-36-const HELPER_EPHEMERAL_HEX = "a".repeat(64);37-const HELPER_LONGTERM_HEX = "b".repeat(64);38-39-const OUR_IDENTITY: LocalIdentity = {40-  deviceId: "ext-local-001",41-  deviceName: "Firefox on MacBook",42-  deviceType: "extension",43-  longTermPublicKeyHex: "c".repeat(64),44-  signingPublicKeyHex: "d".repeat(64),45-  vaultId: "e".repeat(64),46-};47-48-/**49- * Scripted fake socket. Captures everything sent, and replays a queued list50- * of inbound frames. A frame of `null` means "the socket closes here with no51- * data" — the peach-sync wrong-code behaviour (no error frame).52- */53-function makeFakeSocket(inbound: Array<Uint8Array | null>) {54-  const sent: Uint8Array[] = [];55-  let closed = false;56-  let lastWasClose = false;57-  const queue = [...inbound];58-  const sock: DesktopPairingSocket = {59-    send(bytes) { sent.push(bytes); },60-    async receive() {61-      const next = queue.shift();62-      if (next === undefined || next === null) {63-        closed = true;64-        lastWasClose = true;65-        throw new Error("socket closed");66-      }67-      lastWasClose = false;68-      return next;69-    },70-    close() { closed = true; },71-    closedBeforeFrame() { return lastWasClose; },72-  };73-  return { sock, sent, isClosed: () => closed };74-}75-76-/**77- * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we78- * can drive the PAIR_IDENTITY exchange without real crypto. We stub79- * `noiseNKInitiator` via vi.mock below; this is the shape it returns.80- */81-const FAKE_HANDSHAKE_HASH = Uint8Array.from([82-  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),83-]);84-85-// Mock the NK initiator: skip the two handshake messages, hand back a session86-// whose encrypt/decrypt are identity transforms and whose handshakeHash is the87-// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).88-vi.mock("../shared/noise-js", async (orig) => {89-  const actual = await orig<typeof import("../shared/noise-js")>();90-  return {91-    ...actual,92-    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({93-      encrypt: (pt: Uint8Array) => pt,94-      decrypt: (ct: Uint8Array) => ct,95-      handshakeHash: FAKE_HANDSHAKE_HASH,96-    })),97-  };98-});99-100-function makeDeps(overrides: Partial<DesktopPairingDeps> = {}): {101-  deps: DesktopPairingDeps;102-  persisted: PairedDevice[];103-  sockets: ReturnType<typeof makeFakeSocket>[];104-} {105-  const persisted: PairedDevice[] = [];106-  const sockets: ReturnType<typeof makeFakeSocket>[] = [];107-  const deps: DesktopPairingDeps = {108-    findHost: vi.fn(async () => "192.168.1.50"),109-    openSocket: vi.fn(async () => {110-      // default: ACK then a PAIR_IDENTITY response111-      const s = makeFakeSocket([112-        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),113-        enc({114-          type: "PAIR_IDENTITY",115-          device_id: "desktop-001",116-          device_name: "June's iMac",117-          device_type: "desktop",118-          long_term_public_key_hex: HELPER_LONGTERM_HEX,119-          ws_port: 8765,120-        }),121-      ]);122-      sockets.push(s);123-      return s.sock;124-    }),125-    getLocalIdentity: vi.fn(async () => OUR_IDENTITY),126-    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),127-    ...overrides,128-  };129-  return { deps, persisted, sockets };130-}131-132-function makeCallbacks(): {133-  cb: PairWithDesktopCallbacks;134-  sas: { value?: string };135-  errors: string[];136-  completed: PairedDevice[];137-} {138-  const sas: { value?: string } = {};139-  const errors: string[] = [];140-  const completed: PairedDevice[] = [];141-  const cb: PairWithDesktopCallbacks = {142-    onSasReady: (s) => { sas.value = s; },143-    onError: (e) => { errors.push(e); },144-    onComplete: (d) => { completed.push(d); },145-  };146-  return { cb, sas, errors, completed };147-}148-149-beforeEach(() => {150-  vi.clearAllMocks();151-});152-153-// ── specs ────────────────────────────────────────────────────────────────154-155-describe("pairWithDesktopHelper", () => {156-  it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {157-    const { deps, sockets } = makeDeps();158-    const { cb } = makeCallbacks();159-160-    await pairWithDesktopHelper("123", cb, deps, async () => true);161-162-    expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", DESKTOP_PAIRING_PORT);163-    const firstFrame = dec(sockets[0].sent[0]);164-    expect(firstFrame.type).toBe("PEACH_HELLO");165-    // "123" must be normalised to exactly 6 ASCII digits, zero-padded.166-    expect(firstFrame.code).toBe("000123");167-    expect(firstFrame.code).toMatch(/^\d{6}$/);168-  });169-170-  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {171-    // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY172-    // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the173-    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces174-    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)175-    const closeImmediately = makeFakeSocket([null]); // close, no data176-    const { deps } = makeDeps({177-      openSocket: vi.fn(async () => closeImmediately.sock),178-    });179-    const { cb, errors, completed } = makeCallbacks();180-181-    await pairWithDesktopHelper("654321", cb, deps, async () => true);182-183-    expect(errors).toEqual(["invalid_code"]);184-    expect(completed).toHaveLength(0);185-    expect(deps.persistDevice).not.toHaveBeenCalled();186-  });187-188-  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 895245", () => {189-    // Cross-impl KAT pinned in peach-sync (pairing.zig:275-279): the handshake190-    // hash beginning 0f ba b8 cd yields SAS "895245". 0x0fbab8cd = 263895245;191-    // 263895245 mod 1_000_000 = 895245.192-    const hash = Uint8Array.from([193-      0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),194-    ]);195-    expect(derivePairingSAS(hash)).toBe("895245");196-  });197-198-  it("surfaces that same SAS through the pairing flow", async () => {199-    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 895245.200-    const { deps } = makeDeps();201-    const { cb, sas } = makeCallbacks();202-203-    await pairWithDesktopHelper("000123", cb, deps, async () => true);204-205-    expect(sas.value).toBe("895245");206-  });207-208-  it("persists the device with deviceType 'desktop' on SAS confirm", async () => {209-    const { deps, persisted } = makeDeps();210-    const { cb, completed, errors } = makeCallbacks();211-212-    await pairWithDesktopHelper("000123", cb, deps, async () => true);213-214-    expect(errors).toHaveLength(0);215-    expect(persisted).toHaveLength(1);216-    expect(persisted[0].deviceType).toBe("desktop");217-    expect(persisted[0].deviceId).toBe("desktop-001");218-    expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);219-    expect(persisted[0].trustSource).toBe("direct");220-    expect(persisted[0].wsPort).toBe(8765);221-    expect(completed).toHaveLength(1);222-  });223-224-  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {225-    const { deps, persisted } = makeDeps();226-    const { cb, errors } = makeCallbacks();227-228-    await pairWithDesktopHelper("000123", cb, deps, async () => false);229-230-    expect(errors).toEqual(["sas_mismatch"]);231-    expect(persisted).toHaveLength(0);232-    expect(deps.persistDevice).not.toHaveBeenCalled();233-  });234-235-  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {236-    const { deps, sockets } = makeDeps();237-    const { cb } = makeCallbacks();238-239-    await pairWithDesktopHelper("000123", cb, deps, async () => true);240-241-    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY242-    // (encrypt is identity in the mock, so it decodes directly).243-    const ourPairIdentity = dec(sockets[0].sent[1]);244-    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");245-    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);246-  });247-248-  it("reports no_host_found when the probe returns null", async () => {249-    const { deps } = makeDeps({ findHost: vi.fn(async () => null) });250-    const { cb, errors } = makeCallbacks();251-252-    await pairWithDesktopHelper("000123", cb, deps, async () => true);253-254-    expect(errors).toEqual(["no_host_found"]);255-    expect(deps.openSocket).not.toHaveBeenCalled();256-  });257-258-  it("reports vault_locked when local identity is unavailable", async () => {259-    const { deps } = makeDeps({260-      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),261-    });262-    const { cb, errors } = makeCallbacks();263-264-    await pairWithDesktopHelper("000123", cb, deps, async () => true);265-266-    expect(errors).toEqual(["vault_locked"]);267-    expect(deps.findHost).not.toHaveBeenCalled();268-  });269-});270-271-// ── pairing-code path (the phone is the listener) ──────────────────────────272-273-const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);274-const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);275-const PHONE_LONGTERM_HEX = "f".repeat(64);276-277-/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */278-function makePhoneDeps(deviceType: string | undefined = "android") {279-  const identity: Record<string, unknown> = {280-    type: "PAIR_IDENTITY",281-    device_id: "android-001",282-    device_name: "Pixel 8",283-    long_term_public_key_hex: PHONE_LONGTERM_HEX,284-    ws_port: 8765,285-  };286-  identity.device_type = deviceType;287-  return makeDeps({288-    openSocket: vi.fn(async () => makeFakeSocket([289-      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),290-      enc(identity),291-    ]).sock),292-  });293-}294-295-describe("pairWithDesktopHelper — pairing code", () => {296-  it("dials the address in the code instead of probing for a host", async () => {297-    const { deps } = makePhoneDeps();298-    const { cb, errors } = makeCallbacks();299-300-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);301-302-    expect(errors).toHaveLength(0);303-    expect(deps.findHost).not.toHaveBeenCalled();304-    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);305-  });306-307-  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {308-    const sockets: ReturnType<typeof makeFakeSocket>[] = [];309-    const { deps } = makeDeps({310-      openSocket: vi.fn(async () => {311-        const s = makeFakeSocket([312-          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),313-          enc({314-            type: "PAIR_IDENTITY",315-            device_id: "android-001",316-            device_name: "Pixel 8",317-            device_type: "android",318-            long_term_public_key_hex: PHONE_LONGTERM_HEX,319-            ws_port: 8765,320-          }),321-        ]);322-        sockets.push(s);323-        return s.sock;324-      }),325-    });326-    const { cb } = makeCallbacks();327-328-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);329-330-    const hello = dec(sockets[0].sent[0]);331-    expect(hello.type).toBe("PEACH_HELLO");332-    expect(hello.token).toBe("deadbeef");333-    expect(hello.code).toBeUndefined();334-  });335-336-  it("persists the phone with the device type it announced", async () => {337-    const { deps, persisted } = makePhoneDeps();338-    const { cb, completed } = makeCallbacks();339-340-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);341-342-    expect(persisted).toHaveLength(1);343-    expect(persisted[0].deviceType).toBe("android");344-    expect(persisted[0].deviceId).toBe("android-001");345-    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);346-    expect(persisted[0].trustSource).toBe("direct");347-    expect(persisted[0].wsPort).toBe(8765);348-    expect(completed).toHaveLength(1);349-  });350-351-  it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {352-    const { deps, persisted } = makePhoneDeps("");353-    const { cb } = makeCallbacks();354-355-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);356-357-    expect(persisted[0].deviceType).toBe("android");358-  });359-360-  it("caches the coded IP for the next sync round", async () => {361-    const cacheLastKnownIp = vi.fn(async () => undefined);362-    const { deps } = makePhoneDeps();363-    deps.cacheLastKnownIp = cacheLastKnownIp;364-    const { cb } = makeCallbacks();365-366-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);367-368-    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");369-  });370-371-  it("reports no_host_found when the coded endpoint refuses the connection", async () => {372-    const { deps } = makeDeps({373-      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),374-    });375-    const { cb, errors } = makeCallbacks();376-377-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);378-379-    expect(errors).toEqual(["no_host_found"]);380-    expect(deps.persistDevice).not.toHaveBeenCalled();381-  });382-383-  it("still aborts on SAS rejection — the token is not the security boundary", async () => {384-    const { deps, persisted } = makePhoneDeps();385-    const { cb, errors } = makeCallbacks();386-387-    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => false);388-389-    expect(errors).toEqual(["sas_mismatch"]);390-    expect(persisted).toHaveLength(0);391-  });392-});

view file at the parent

src/background/desktop-pairing.ts+0 −167
@@ -1,167 +0,0 @@1-// Device pairing over a code — the PAIR_IDENTITY half.2-//3-// The dialling half (decode the code, connect, plaintext hello, Noise NK) lives4-// in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks5-// ADOPT_* frames over the same channel instead. This module is only what6-// happens once that channel is open:7-//8-//   1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)9-//   2. derive the SAS, surface it for user confirmation10-//   3. on confirm -> persist the PairedDevice; on reject -> abort without11-//      persisting12-//13-// Two code forms reach here. A pairing code (`A74H-48J3-KI32-OEP4`) is the14-// primary path: the phone listens and the code carries its address. A legacy15-// 6-digit helper code has no address, so the host is probed for.16-17-import {18-  dialPairingChannel,19-  PAIRING_PORT,20-  type PairingDialDeps,21-  type PairingSocket,22-} from "../shared/pairing-dial";23-import type { LocalIdentity } from "../shared/chirp-handshake";24-import { parsePairingCode } from "../shared/pairing-code";25-import type { DeviceType, PairedDevice } from "../shared/sync-types";26-27-/** The pairing-only port a legacy helper code implies. */28-export const DESKTOP_PAIRING_PORT = PAIRING_PORT;29-/** Default sync port a peer announces; used when ws_port is absent. */30-const DEFAULT_SYNC_PORT = 8765;31-32-export type DesktopPairingError =33-  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)34-  | "no_host_found"     // nothing answered at the endpoint35-  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed36-  | "sas_mismatch"      // user rejected the SAS37-  | "vault_locked";     // no master key available38-39-/** Re-exported so existing callers and specs keep one name for the seam. */40-export type DesktopPairingSocket = PairingSocket;41-42-export interface DesktopPairingDeps extends PairingDialDeps {43-  /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */44-  getLocalIdentity(): Promise<LocalIdentity>;45-  /** Persist on user confirm. */46-  persistDevice(device: PairedDevice): Promise<void>;47-  /** Optional fast-path cache for the next sync round. */48-  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;49-}50-51-export interface PairWithDesktopCallbacks {52-  /** Show the 6-digit SAS; the popup must compare it against the peer's. */53-  onSasReady(sas: string): void;54-  /** Pairing succeeded and the device was persisted. */55-  onComplete(device: PairedDevice): void;56-  /** Pairing failed at some stage. */57-  onError(error: DesktopPairingError): void;58-}59-60-interface PairingFrame {61-  type: "PAIR_IDENTITY";62-  device_id: string;63-  device_name: string;64-  device_type: DeviceType;65-  long_term_public_key_hex: string;66-  signing_public_key_hex?: string;67-  ws_port?: number;68-  vault_id?: string; // Change 1 — additive, optional69-}70-71-function encodeJson(obj: unknown): Uint8Array {72-  return new TextEncoder().encode(JSON.stringify(obj));73-}74-75-function decodePairIdentity(bytes: Uint8Array): PairingFrame {76-  const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;77-  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");78-  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");79-  if (typeof p.device_name !== "string") throw new Error("device_name missing");80-  if (typeof p.device_type !== "string") throw new Error("device_type missing");81-  if (typeof p.long_term_public_key_hex !== "string" ||82-      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {83-    throw new Error("long_term_public_key_hex is not 32-byte hex");84-  }85-  return p as unknown as PairingFrame;86-}87-88-/**89- * Drive a complete code pairing.90- *91- * `confirm` is a function the caller resolves AFTER the user has compared the92- * SAS shown here against the SAS the peer displays. Resolving false (or93- * rejecting) aborts before any device is persisted — this is the out-of-band94- * MITM defence (see the SECURITY note in chirp-handshake.ts), and it, not the95- * pairing token, is what actually authenticates the peer.96- */97-export async function pairWithDesktopHelper(98-  code: string,99-  cb: PairWithDesktopCallbacks,100-  deps: DesktopPairingDeps,101-  confirm: (sas: string) => Promise<boolean>,102-): Promise<void> {103-  let identity: LocalIdentity;104-  try {105-    identity = await deps.getLocalIdentity();106-  } catch {107-    cb.onError("vault_locked");108-    return;109-  }110-111-  const dial = await dialPairingChannel(code, deps);112-  if (!dial.ok) {113-    cb.onError(dial.error);114-    return;115-  }116-  const { channel, sas, host, socket } = dial.dialled;117-118-  try {119-    // Initiator (browser) sends its identity FIRST, then reads the peer's.120-    const ourFrame: PairingFrame = {121-      type: "PAIR_IDENTITY",122-      device_id: identity.deviceId,123-      device_name: identity.deviceName,124-      device_type: identity.deviceType,125-      long_term_public_key_hex: identity.longTermPublicKeyHex,126-      signing_public_key_hex: identity.signingPublicKeyHex,127-      ws_port: identity.wsPort,128-      vault_id: identity.vaultId, // Change 1129-    };130-    await channel.send(encodeJson(ourFrame));131-132-    const theirFrame = decodePairIdentity(await channel.receive());133-134-    cb.onSasReady(sas);135-    const confirmed = await confirm(sas);136-    if (!confirmed) {137-      cb.onError("sas_mismatch");138-      return;139-    }140-141-    // ws_port comes from the peer frame, defaulting to 8765 (both peers142-    // announce it, but guard it). The device-type fallback follows the code143-    // form so a phone never lands in the registry labelled "desktop".144-    const device: PairedDevice = {145-      deviceId: theirFrame.device_id,146-      deviceName: theirFrame.device_name,147-      deviceType:148-        (theirFrame.device_type as DeviceType) ||149-        (parsePairingCode(code) ? "android" : "desktop"),150-      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,151-      signingPublicKeyHex: theirFrame.signing_public_key_hex,152-      trustSource: "direct",153-      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,154-      pairedAt: Date.now(),155-    };156-    await deps.persistDevice(device);157-    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {158-      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);159-    }160-    cb.onComplete(device);161-  } catch (err) {162-    if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);163-    cb.onError("handshake_failed");164-  } finally {165-    socket.close();166-  }167-}

view file at the parent

src/background/messages.ts+9 −9
@@ -1431,25 +1431,25 @@14311431       // ── Desktop helper code pairing (peach-sync) ──────────────────────────14321432       // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK14331433       // pairing runs in the background; the SAS and the terminal result reach the1434-      // popup as DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes.1435-      case "PAIR_WITH_DESKTOP": {1434+      // popup as CODE_PAIR_SAS / CODE_PAIR_RESULT pushes.1435+      case "PAIR_WITH_CODE": {14361436         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };14371437         const { code } = (msg.payload ?? {}) as { code?: string };14381438         if (typeof code !== "string" || !/\d/.test(code)) {14391439           return { ok: false, error: "invalid_payload" };14401440         }1441-        const { startDesktopPairing, isDesktopPairingInFlight } = await import(1442-          "./desktop-pairing-bg"1441+        const { startCodePairing, isCodePairingInFlight } = await import(1442+          "./code-pairing-bg"14431443         );1444-        if (isDesktopPairingInFlight()) return { ok: false, error: "pairing_in_progress" };1445-        startDesktopPairing(code);1444+        if (isCodePairingInFlight()) return { ok: false, error: "pairing_in_progress" };1445+        startCodePairing(code);14461446         return { ok: true };14471447       }14481448 1449-      case "DESKTOP_PAIR_CONFIRM": {1449+      case "CODE_PAIR_CONFIRM": {14501450         const { confirmed } = (msg.payload ?? {}) as { confirmed?: boolean };1451-        const { resolveDesktopPairingConfirm } = await import("./desktop-pairing-bg");1452-        resolveDesktopPairingConfirm(confirmed === true);1451+        const { resolveCodePairingConfirm } = await import("./code-pairing-bg");1452+        resolveCodePairingConfirm(confirmed === true);14531453         return { ok: true };14541454       }14551455 

view file

src/popup/App.tsx+3 −3
@@ -36,7 +36,7 @@3636 const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));3737 const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3838 const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));39-const ChirpSyncView   = lazy(() => import("./views/ChirpSync").then(m => ({ default: m.ChirpSyncView })));39+const PairDeviceView   = lazy(() => import("./views/PairDevice").then(m => ({ default: m.PairDeviceView })));4040 const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));4141 const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));4242 const AdoptVaultView = lazy(() => import("./views/AdoptVaultView").then(m => ({ default: m.AdoptVaultView })));@@ -199,7 +199,7 @@199199   const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);200200   // When the sync card's "Chirp"/"Code" buttons open the sync view, deep-link201201   // straight into that pairing path instead of the rest screen.202-  const [syncPairIntent, setSyncPairIntent] = createSignal<"desktop" | null>(null);202+  const [syncPairIntent, setSyncPairIntent] = createSignal<"code" | null>(null);203203   const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");204204   const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);205205   const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");@@ -1666,7 +1666,7 @@16661666             </Show>16671667             <Show when={view() === "chirp-sync"}>16681668               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1669-                <ChirpSyncView1669+                <PairDeviceView16701670                   isPro={proStatus()}16711671                   initialPair={syncPairIntent()}16721672                   onProChanged={() => { void refreshProStatus(); }}

view file

src/popup/components/ChirpPairingFlow.tsx+0 −161
@@ -1,161 +0,0 @@1-// Pairing birds — the two illustrated characters used on the pairing screens.2-//3-// These are all that remains of the audio-chirp pairing animation this file is4-// named after: the flow itself was removed with Chirp, but the birds outlived5-// it and still animate above the code-entry, connecting, SAS and paired6-// screens (ChirpSync.tsx's `desktopBirdStage`).7-8-import { Component } from "solid-js";9-10-const FINCH = {11-  hi: "#f4a6b6", mid: "#dc8294", lo: "#a85870", shade: "#7a3e54",12-  cream: "#f5d8c8", beak: "#f5c764", beakShade: "#c8932e", legs: "#7a3e54",13-};14-const SWALLOW = {15-  hi: "#f4c8a0", mid: "#dc9968", lo: "#a86b3e", shade: "#7a4a26",16-  cream: "#f7e0c8", beak: "#f5c764", beakShade: "#b87a22", legs: "#5e3a1f",17-};18-19-export const DesktopBird: Component<{ pose: string }> = (props) => {20-  const p = FINCH;21-  return (22-    <div class={`peach-chirp-bird peach-chirp-bird-desktop peach-chirp-bird-pose-${props.pose}`}>23-      <svg viewBox="0 0 160 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax meet">24-        <defs>25-          <radialGradient id="peach-chirp-finch-body" cx="0.42" cy="0.32" r="0.72">26-            <stop offset="0%" stop-color={p.hi} />27-            <stop offset="55%" stop-color={p.mid} />28-            <stop offset="100%" stop-color={p.lo} />29-          </radialGradient>30-          <radialGradient id="peach-chirp-finch-head" cx="0.5" cy="0.4" r="0.7">31-            <stop offset="0%" stop-color={p.hi} />32-            <stop offset="100%" stop-color={p.mid} />33-          </radialGradient>34-          <linearGradient id="peach-chirp-finch-belly" x1="0" y1="0" x2="0" y2="1">35-            <stop offset="0%" stop-color={p.cream} stop-opacity="0" />36-            <stop offset="50%" stop-color={p.cream} stop-opacity="0.85" />37-            <stop offset="100%" stop-color={p.cream} stop-opacity="0.5" />38-          </linearGradient>39-        </defs>40-        <ellipse class="peach-chirp-bird-shadow" cx="78" cy="138" rx="40" ry="3.5" fill="rgba(0,0,0,0.45)" />41-        <g class="peach-chirp-bird-aura" opacity="0">42-          <ellipse cx="76" cy="74" rx="60" ry="46" fill={p.hi} opacity="0.18" style={{ filter: "blur(8px)" }} />43-        </g>44-        <g class="peach-chirp-bird-tail">45-          <path d="M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z" fill={p.lo} />46-          <path d="M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z" fill={p.shade} opacity="0.85" />47-          <path d="M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z" fill={p.lo} opacity="0.9" />48-        </g>49-        <g class="peach-chirp-bird-feet" stroke={p.legs} stroke-width="1.6" stroke-linecap="round" fill="none">50-          <line x1="62" y1="118" x2="60" y2="134" />51-          <path d="M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138" stroke-width="1.2" />52-          <line x1="84" y1="118" x2="86" y2="134" />53-          <path d="M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138" stroke-width="1.2" />54-        </g>55-        <g class="peach-chirp-bird-body">56-          <path d="M 30 88 C 24 64, 40 38, 72 36 C 105 34, 124 56, 122 84 C 120 110, 100 122, 70 120 C 44 118, 32 108, 30 88 Z" fill="url(#peach-chirp-finch-body)" />57-          <path d="M 40 82 C 42 100, 60 112, 80 110 C 100 108, 110 100, 112 86 C 100 102, 80 106, 60 102 C 50 100, 44 92, 40 82 Z" fill="url(#peach-chirp-finch-belly)" />58-          <g class="peach-chirp-bird-wing">59-            <path d="M 56 60 C 68 56, 88 60, 102 68 C 108 76, 106 86, 96 90 C 80 92, 64 86, 54 78 C 50 70, 50 64, 56 60 Z" fill={p.lo} />60-            <g stroke={p.shade} stroke-width="0.7" stroke-linecap="round" fill="none" opacity="0.55">61-              <path d="M 64 64 Q 72 70, 78 80" />62-              <path d="M 74 62 Q 82 70, 88 82" />63-              <path d="M 84 64 Q 90 72, 94 84" />64-            </g>65-            <path d="M 56 60 C 68 56, 88 60, 102 68" stroke={p.shade} stroke-width="1" fill="none" opacity="0.4" />66-          </g>67-        </g>68-        <g class="peach-chirp-bird-head-group" style={{ "transform-origin": "95px 52px" }}>69-          <g class="peach-chirp-bird-tuft">70-            <path d="M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z" fill={p.lo} />71-            <path d="M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z" fill={p.shade} opacity="0.95" />72-            <path d="M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z" fill={p.lo} opacity="0.85" />73-          </g>74-          <circle cx="98" cy="48" r="26" fill="url(#peach-chirp-finch-head)" />75-          <path d="M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z" fill={p.shade} opacity="0.35" />76-          <g class="peach-chirp-bird-eye-group">77-            <circle cx="108" cy="42" r="4.5" fill="#1f0a10" />78-            <circle cx="109.4" cy="40.5" r="1.4" fill="rgba(255,240,225,0.95)" />79-            <circle cx="106.5" cy="44" r="0.5" fill="rgba(255,240,225,0.6)" />80-          </g>81-          <g class="peach-chirp-bird-beak" style={{ "transform-origin": "120px 52px" }}>82-            <path d="M 120 48 L 138 50 L 122 56 Z" fill={p.beak} />83-            <path d="M 120 48 L 138 50 L 128 52 Z" fill={p.beakShade} opacity="0.55" />84-            <path d="M 122 52 L 134 51" stroke={p.beakShade} stroke-width="0.6" opacity="0.6" />85-          </g>86-        </g>87-      </svg>88-    </div>89-  );90-};91-92-export const MobileBird: Component<{ pose: string }> = (props) => {93-  const p = SWALLOW;94-  return (95-    <div class={`peach-chirp-bird peach-chirp-bird-mobile peach-chirp-bird-pose-${props.pose}`}>96-      <svg viewBox="0 0 160 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax meet" style={{ transform: "scaleX(-1)" }}>97-        <defs>98-          <radialGradient id="peach-chirp-swallow-body" cx="0.55" cy="0.35" r="0.75">99-            <stop offset="0%" stop-color={p.hi} />100-            <stop offset="50%" stop-color={p.mid} />101-            <stop offset="100%" stop-color={p.lo} />102-          </radialGradient>103-          <radialGradient id="peach-chirp-swallow-head" cx="0.45" cy="0.4" r="0.7">104-            <stop offset="0%" stop-color={p.hi} />105-            <stop offset="100%" stop-color={p.mid} />106-          </radialGradient>107-          <linearGradient id="peach-chirp-swallow-throat" x1="0" y1="0" x2="0" y2="1">108-            <stop offset="0%" stop-color={p.cream} stop-opacity="0.95" />109-            <stop offset="100%" stop-color={p.cream} stop-opacity="0.2" />110-          </linearGradient>111-        </defs>112-        <ellipse class="peach-chirp-bird-shadow" cx="86" cy="140" rx="34" ry="3" fill="rgba(0,0,0,0.45)" />113-        <g class="peach-chirp-bird-aura" opacity="0">114-          <ellipse cx="80" cy="78" rx="60" ry="42" fill={p.hi} opacity="0.18" style={{ filter: "blur(8px)" }} />115-        </g>116-        <g class="peach-chirp-bird-tail">117-          <path d="M 38 80 Q 8 70, 0 60 Q 8 78, 28 88 Q 14 90, 6 96 Q 16 94, 32 92 L 38 84 Z" fill={p.lo} />118-          <path d="M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z" fill={p.shade} opacity="0.7" />119-        </g>120-        <g class="peach-chirp-bird-feet" stroke={p.legs} stroke-width="1.5" stroke-linecap="round" fill="none">121-          <line x1="76" y1="118" x2="74" y2="136" />122-          <path d="M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140" stroke-width="1.1" />123-          <line x1="96" y1="118" x2="98" y2="136" />124-          <path d="M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140" stroke-width="1.1" />125-        </g>126-        <g class="peach-chirp-bird-body">127-          <path d="M 36 82 C 32 56, 56 42, 88 44 C 116 46, 134 60, 134 80 C 134 102, 116 118, 90 118 C 60 118, 40 104, 36 82 Z" fill="url(#peach-chirp-swallow-body)" />128-          <path d="M 50 64 C 60 52, 80 46, 100 48 C 116 50, 126 60, 128 70 C 120 60, 100 56, 80 60 C 64 64, 56 68, 50 64 Z" fill={p.shade} opacity="0.5" />129-          <path d="M 64 82 C 66 100, 86 114, 102 112 C 116 110, 124 100, 126 88 C 116 102, 100 108, 84 104 C 74 102, 68 94, 64 82 Z" fill="url(#peach-chirp-swallow-throat)" />130-          <g class="peach-chirp-bird-wing">131-            <path d="M 50 70 C 64 64, 84 66, 100 72 C 112 78, 112 90, 100 96 C 84 100, 64 96, 52 88 C 46 80, 46 74, 50 70 Z" fill={p.lo} />132-            <g fill={p.shade} opacity="0.85">133-              <path d="M 96 88 L 110 92 L 100 96 Z" />134-              <path d="M 88 92 L 102 98 L 92 100 Z" />135-              <path d="M 80 94 L 92 100 L 84 102 Z" />136-            </g>137-            <g stroke={p.shade} stroke-width="0.6" stroke-linecap="round" fill="none" opacity="0.5">138-              <path d="M 58 76 Q 70 82, 80 90" />139-              <path d="M 68 72 Q 80 80, 90 88" />140-              <path d="M 80 70 Q 90 78, 100 86" />141-            </g>142-            <path d="M 50 70 C 64 64, 84 66, 100 72" stroke={p.shade} stroke-width="1" fill="none" opacity="0.55" />143-          </g>144-        </g>145-        <g class="peach-chirp-bird-head-group" style={{ "transform-origin": "116px 64px" }}>146-          <circle cx="118" cy="64" r="20" fill="url(#peach-chirp-swallow-head)" />147-          <path d="M 100 60 Q 108 46, 124 46 Q 134 50, 136 60 Q 124 54, 108 60 Z" fill={p.shade} opacity="0.55" />148-          <g class="peach-chirp-bird-eye-group">149-            <circle cx="126" cy="60" r="4" fill="#1f0a10" />150-            <circle cx="127.2" cy="58.8" r="1.2" fill="rgba(255,240,225,0.95)" />151-          </g>152-          <g class="peach-chirp-bird-beak" style={{ "transform-origin": "136px 64px" }}>153-            <path d="M 136 60 L 154 62 L 138 66 Z" fill={p.beak} />154-            <path d="M 136 60 L 154 62 L 145 63 Z" fill={p.beakShade} opacity="0.5" />155-            <path d="M 138 63 L 152 63" stroke={p.beakShade} stroke-width="0.5" opacity="0.6" />156-          </g>157-        </g>158-      </svg>159-    </div>160-  );161-};

view file at the parent

src/popup/components/PairingBirds.tsx+161 −0
@@ -0,0 +1,161 @@1+// Pairing birds — the two illustrated characters used on the pairing screens.2+//3+// These are all that remains of the audio-chirp pairing animation this file is4+// named after: the flow itself was removed with Chirp, but the birds outlived5+// it and still animate above the code-entry, connecting, SAS and paired6+// screens (ChirpSync.tsx's `desktopBirdStage`).7+8+import { Component } from "solid-js";9+10+const FINCH = {11+  hi: "#f4a6b6", mid: "#dc8294", lo: "#a85870", shade: "#7a3e54",12+  cream: "#f5d8c8", beak: "#f5c764", beakShade: "#c8932e", legs: "#7a3e54",13+};14+const SWALLOW = {15+  hi: "#f4c8a0", mid: "#dc9968", lo: "#a86b3e", shade: "#7a4a26",16+  cream: "#f7e0c8", beak: "#f5c764", beakShade: "#b87a22", legs: "#5e3a1f",17+};18+19+export const DesktopBird: Component<{ pose: string }> = (props) => {20+  const p = FINCH;21+  return (22+    <div class={`peach-chirp-bird peach-chirp-bird-desktop peach-chirp-bird-pose-${props.pose}`}>23+      <svg viewBox="0 0 160 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax meet">24+        <defs>25+          <radialGradient id="peach-chirp-finch-body" cx="0.42" cy="0.32" r="0.72">26+            <stop offset="0%" stop-color={p.hi} />27+            <stop offset="55%" stop-color={p.mid} />28+            <stop offset="100%" stop-color={p.lo} />29+          </radialGradient>30+          <radialGradient id="peach-chirp-finch-head" cx="0.5" cy="0.4" r="0.7">31+            <stop offset="0%" stop-color={p.hi} />32+            <stop offset="100%" stop-color={p.mid} />33+          </radialGradient>34+          <linearGradient id="peach-chirp-finch-belly" x1="0" y1="0" x2="0" y2="1">35+            <stop offset="0%" stop-color={p.cream} stop-opacity="0" />36+            <stop offset="50%" stop-color={p.cream} stop-opacity="0.85" />37+            <stop offset="100%" stop-color={p.cream} stop-opacity="0.5" />38+          </linearGradient>39+        </defs>40+        <ellipse class="peach-chirp-bird-shadow" cx="78" cy="138" rx="40" ry="3.5" fill="rgba(0,0,0,0.45)" />41+        <g class="peach-chirp-bird-aura" opacity="0">42+          <ellipse cx="76" cy="74" rx="60" ry="46" fill={p.hi} opacity="0.18" style={{ filter: "blur(8px)" }} />43+        </g>44+        <g class="peach-chirp-bird-tail">45+          <path d="M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z" fill={p.lo} />46+          <path d="M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z" fill={p.shade} opacity="0.85" />47+          <path d="M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z" fill={p.lo} opacity="0.9" />48+        </g>49+        <g class="peach-chirp-bird-feet" stroke={p.legs} stroke-width="1.6" stroke-linecap="round" fill="none">50+          <line x1="62" y1="118" x2="60" y2="134" />51+          <path d="M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138" stroke-width="1.2" />52+          <line x1="84" y1="118" x2="86" y2="134" />53+          <path d="M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138" stroke-width="1.2" />54+        </g>55+        <g class="peach-chirp-bird-body">56+          <path d="M 30 88 C 24 64, 40 38, 72 36 C 105 34, 124 56, 122 84 C 120 110, 100 122, 70 120 C 44 118, 32 108, 30 88 Z" fill="url(#peach-chirp-finch-body)" />57+          <path d="M 40 82 C 42 100, 60 112, 80 110 C 100 108, 110 100, 112 86 C 100 102, 80 106, 60 102 C 50 100, 44 92, 40 82 Z" fill="url(#peach-chirp-finch-belly)" />58+          <g class="peach-chirp-bird-wing">59+            <path d="M 56 60 C 68 56, 88 60, 102 68 C 108 76, 106 86, 96 90 C 80 92, 64 86, 54 78 C 50 70, 50 64, 56 60 Z" fill={p.lo} />60+            <g stroke={p.shade} stroke-width="0.7" stroke-linecap="round" fill="none" opacity="0.55">61+              <path d="M 64 64 Q 72 70, 78 80" />62+              <path d="M 74 62 Q 82 70, 88 82" />63+              <path d="M 84 64 Q 90 72, 94 84" />64+            </g>65+            <path d="M 56 60 C 68 56, 88 60, 102 68" stroke={p.shade} stroke-width="1" fill="none" opacity="0.4" />66+          </g>67+        </g>68+        <g class="peach-chirp-bird-head-group" style={{ "transform-origin": "95px 52px" }}>69+          <g class="peach-chirp-bird-tuft">70+            <path d="M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z" fill={p.lo} />71+            <path d="M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z" fill={p.shade} opacity="0.95" />72+            <path d="M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z" fill={p.lo} opacity="0.85" />73+          </g>74+          <circle cx="98" cy="48" r="26" fill="url(#peach-chirp-finch-head)" />75+          <path d="M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z" fill={p.shade} opacity="0.35" />76+          <g class="peach-chirp-bird-eye-group">77+            <circle cx="108" cy="42" r="4.5" fill="#1f0a10" />78+            <circle cx="109.4" cy="40.5" r="1.4" fill="rgba(255,240,225,0.95)" />79+            <circle cx="106.5" cy="44" r="0.5" fill="rgba(255,240,225,0.6)" />80+          </g>81+          <g class="peach-chirp-bird-beak" style={{ "transform-origin": "120px 52px" }}>82+            <path d="M 120 48 L 138 50 L 122 56 Z" fill={p.beak} />83+            <path d="M 120 48 L 138 50 L 128 52 Z" fill={p.beakShade} opacity="0.55" />84+            <path d="M 122 52 L 134 51" stroke={p.beakShade} stroke-width="0.6" opacity="0.6" />85+          </g>86+        </g>87+      </svg>88+    </div>89+  );90+};91+92+export const MobileBird: Component<{ pose: string }> = (props) => {93+  const p = SWALLOW;94+  return (95+    <div class={`peach-chirp-bird peach-chirp-bird-mobile peach-chirp-bird-pose-${props.pose}`}>96+      <svg viewBox="0 0 160 150" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMax meet" style={{ transform: "scaleX(-1)" }}>97+        <defs>98+          <radialGradient id="peach-chirp-swallow-body" cx="0.55" cy="0.35" r="0.75">99+            <stop offset="0%" stop-color={p.hi} />100+            <stop offset="50%" stop-color={p.mid} />101+            <stop offset="100%" stop-color={p.lo} />102+          </radialGradient>103+          <radialGradient id="peach-chirp-swallow-head" cx="0.45" cy="0.4" r="0.7">104+            <stop offset="0%" stop-color={p.hi} />105+            <stop offset="100%" stop-color={p.mid} />106+          </radialGradient>107+          <linearGradient id="peach-chirp-swallow-throat" x1="0" y1="0" x2="0" y2="1">108+            <stop offset="0%" stop-color={p.cream} stop-opacity="0.95" />109+            <stop offset="100%" stop-color={p.cream} stop-opacity="0.2" />110+          </linearGradient>111+        </defs>112+        <ellipse class="peach-chirp-bird-shadow" cx="86" cy="140" rx="34" ry="3" fill="rgba(0,0,0,0.45)" />113+        <g class="peach-chirp-bird-aura" opacity="0">114+          <ellipse cx="80" cy="78" rx="60" ry="42" fill={p.hi} opacity="0.18" style={{ filter: "blur(8px)" }} />115+        </g>116+        <g class="peach-chirp-bird-tail">117+          <path d="M 38 80 Q 8 70, 0 60 Q 8 78, 28 88 Q 14 90, 6 96 Q 16 94, 32 92 L 38 84 Z" fill={p.lo} />118+          <path d="M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z" fill={p.shade} opacity="0.7" />119+        </g>120+        <g class="peach-chirp-bird-feet" stroke={p.legs} stroke-width="1.5" stroke-linecap="round" fill="none">121+          <line x1="76" y1="118" x2="74" y2="136" />122+          <path d="M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140" stroke-width="1.1" />123+          <line x1="96" y1="118" x2="98" y2="136" />124+          <path d="M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140" stroke-width="1.1" />125+        </g>126+        <g class="peach-chirp-bird-body">127+          <path d="M 36 82 C 32 56, 56 42, 88 44 C 116 46, 134 60, 134 80 C 134 102, 116 118, 90 118 C 60 118, 40 104, 36 82 Z" fill="url(#peach-chirp-swallow-body)" />128+          <path d="M 50 64 C 60 52, 80 46, 100 48 C 116 50, 126 60, 128 70 C 120 60, 100 56, 80 60 C 64 64, 56 68, 50 64 Z" fill={p.shade} opacity="0.5" />129+          <path d="M 64 82 C 66 100, 86 114, 102 112 C 116 110, 124 100, 126 88 C 116 102, 100 108, 84 104 C 74 102, 68 94, 64 82 Z" fill="url(#peach-chirp-swallow-throat)" />130+          <g class="peach-chirp-bird-wing">131+            <path d="M 50 70 C 64 64, 84 66, 100 72 C 112 78, 112 90, 100 96 C 84 100, 64 96, 52 88 C 46 80, 46 74, 50 70 Z" fill={p.lo} />132+            <g fill={p.shade} opacity="0.85">133+              <path d="M 96 88 L 110 92 L 100 96 Z" />134+              <path d="M 88 92 L 102 98 L 92 100 Z" />135+              <path d="M 80 94 L 92 100 L 84 102 Z" />136+            </g>137+            <g stroke={p.shade} stroke-width="0.6" stroke-linecap="round" fill="none" opacity="0.5">138+              <path d="M 58 76 Q 70 82, 80 90" />139+              <path d="M 68 72 Q 80 80, 90 88" />140+              <path d="M 80 70 Q 90 78, 100 86" />141+            </g>142+            <path d="M 50 70 C 64 64, 84 66, 100 72" stroke={p.shade} stroke-width="1" fill="none" opacity="0.55" />143+          </g>144+        </g>145+        <g class="peach-chirp-bird-head-group" style={{ "transform-origin": "116px 64px" }}>146+          <circle cx="118" cy="64" r="20" fill="url(#peach-chirp-swallow-head)" />147+          <path d="M 100 60 Q 108 46, 124 46 Q 134 50, 136 60 Q 124 54, 108 60 Z" fill={p.shade} opacity="0.55" />148+          <g class="peach-chirp-bird-eye-group">149+            <circle cx="126" cy="60" r="4" fill="#1f0a10" />150+            <circle cx="127.2" cy="58.8" r="1.2" fill="rgba(255,240,225,0.95)" />151+          </g>152+          <g class="peach-chirp-bird-beak" style={{ "transform-origin": "136px 64px" }}>153+            <path d="M 136 60 L 154 62 L 138 66 Z" fill={p.beak} />154+            <path d="M 136 60 L 154 62 L 145 63 Z" fill={p.beakShade} opacity="0.5" />155+            <path d="M 138 63 L 152 63" stroke={p.beakShade} stroke-width="0.5" opacity="0.6" />156+          </g>157+        </g>158+      </svg>159+    </div>160+  );161+};

view file

src/popup/views/ChirpSync.desktop.test.tsx+0 −276
@@ -1,276 +0,0 @@1-// @vitest-environment jsdom2-//3-// Code pairing, exercised through the integrated ChirpSyncView state machine.4-// Code pairing is a MODE inside ChirpSync (birds always animate), not a separate5-// component — so these specs walk enter → connecting → SAS → paired/error,6-// asserting the popup↔background message contract at each step.7-//8-// The input accepts both code forms: the pairing code a phone displays9-// (pairing-code.ts) and the legacy 6-digit desktop-helper code.10-//11-// chrome messaging is mocked the way the popup uses it:12-//   - shared/bridge `sendMessage` is mocked (popup → bg)13-//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can14-//     fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg → popup)15-16-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";17-18-// --- mock the background bridge (popup → bg) -------------------------------19-const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));20-vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));21-22-// Mutated per-test to script the PAIR_WITH_DESKTOP start response.23-let startResponse: { ok: boolean; error?: string } = { ok: true };24-25-import { ChirpSyncView } from "./ChirpSync";26-import type { PairedDevice } from "../../shared/sync-types";27-import { renderView, inputText } from "./view-test-utils";28-29-// --- fake chrome.runtime push channel (bg → popup) ------------------------30-type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;31-let listeners: RuntimeListener[] = [];32-33-function pushFromBackground(type: string, payload: unknown): void {34-  for (const l of [...listeners]) l({ type, payload });35-}36-37-beforeEach(() => {38-  listeners = [];39-  startResponse = { ok: true };40-  mockSendMessage.mockReset();41-  mockSendMessage.mockImplementation(async (msg: { type?: string }) => {42-    if (msg?.type === "PAIR_WITH_DESKTOP") return startResponse;43-    return { ok: true };44-  });45-  (globalThis as unknown as { chrome: unknown }).chrome = {46-    runtime: {47-      onMessage: {48-        addListener: (fn: RuntimeListener) => { listeners.push(fn); },49-        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },50-      },51-    },52-  };53-});54-55-describe("ChirpSync — code pairing", () => {56-  const cleanups: Array<() => void> = [];57-  afterEach(() => {58-    while (cleanups.length) cleanups.pop()!();59-  });60-61-  // ChirpSync is pairing-only now; it opens straight into the desktop code flow62-  // (the device list / method choice live on the Settings sync card).63-  function mount(onBack: () => void = () => {}) {64-    const r = renderView(65-      <ChirpSyncView isPro={true} initialPair="desktop" onBack={onBack} onLearnPro={() => {}} />,66-    );67-    cleanups.push(r.dispose);68-    return r;69-  }70-71-  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {72-    return Array.from(container.querySelectorAll("button")).find(73-      (b) => (b.textContent ?? "").trim() === text,74-    ) as HTMLButtonElement | undefined;75-  }76-  function codeInput(container: HTMLElement): HTMLInputElement {77-    return container.querySelector("#desktop-pair-code") as HTMLInputElement;78-  }79-80-  // initialPair="desktop" lands directly on the code-entry screen.81-  function gotoDesktopEnter(_container: HTMLElement): void {82-    /* no-op: mount() already opens the desktop code flow */83-  }84-85-  it("opens straight into the desktop code flow (no method-select screen)", () => {86-    const { container } = mount();87-    expect(container.textContent).not.toContain("How do you want to pair?");88-    // The code input is present immediately, without any intermediate choice.89-    expect(codeInput(container)).toBeTruthy();90-    expect(buttonByText(container, "Connect")).toBeTruthy();91-  });92-93-  it("renders a code input sized for a full pairing code, with birds present", () => {94-    const { container } = mount();95-    gotoDesktopEnter(container);96-    const input = codeInput(container);97-    expect(input).toBeTruthy();98-    // Letters are part of the code now, so the field must not be numeric-only.99-    expect(input.getAttribute("inputmode")).toBeNull();100-    expect(input.getAttribute("maxlength")).toBe("32");101-    // birds always animate on the code screens (design correction)102-    expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();103-  });104-105-  it("uppercases and regroups a pairing code as it is typed", () => {106-    const { container } = mount();107-    gotoDesktopEnter(container);108-    const input = codeInput(container);109-    inputText(input, "a74h48j3ki32oep4");110-    expect(input.value).toBe("A74H-48J3-KI32-OEP4");111-    expect(buttonByText(container, "Connect")!.disabled).toBe(false);112-  });113-114-  it("leaves a short legacy code ungrouped and strips unusable characters", () => {115-    const { container } = mount();116-    gotoDesktopEnter(container);117-    const input = codeInput(container);118-    inputText(input, "12_34/56");119-    expect(input.value).toBe("123456");120-  });121-122-  it("keeps Connect disabled for input that is neither code form", () => {123-    const { container } = mount();124-    gotoDesktopEnter(container);125-    // Long enough to be grouped, but too short to carry a token.126-    inputText(codeInput(container), "A74H48J3");127-    expect(buttonByText(container, "Connect")!.disabled).toBe(true);128-  });129-130-  it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {131-    const { container } = mount();132-    gotoDesktopEnter(container);133-    inputText(codeInput(container), "428913");134-    const connect = buttonByText(container, "Connect")!;135-    expect(connect.disabled).toBe(false);136-    connect.click();137-    await Promise.resolve();138-    expect(mockSendMessage).toHaveBeenCalledWith({139-      type: "PAIR_WITH_DESKTOP",140-      payload: { code: "428913" },141-    });142-    expect(container.textContent).toContain("Connecting…");143-  });144-145-  it("Connect stays disabled until 6 digits are entered", () => {146-    const { container } = mount();147-    gotoDesktopEnter(container);148-    inputText(codeInput(container), "123");149-    expect(buttonByText(container, "Connect")!.disabled).toBe(true);150-    inputText(codeInput(container), "123456");151-    expect(buttonByText(container, "Connect")!.disabled).toBe(false);152-  });153-154-  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {155-    const { container } = mount();156-    gotoDesktopEnter(container);157-    inputText(codeInput(container), "428913");158-    buttonByText(container, "Connect")!.click();159-    await Promise.resolve();160-161-    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });162-163-    expect(container.textContent).toContain("Confirm this code matches on your phone:");164-    expect(container.textContent).toContain("895245");165-    expect(buttonByText(container, "Confirm")).toBeTruthy();166-    expect(buttonByText(container, "Cancel")).toBeTruthy();167-  });168-169-  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {170-    const { container } = mount();171-    gotoDesktopEnter(container);172-    inputText(codeInput(container), "428913");173-    buttonByText(container, "Connect")!.click();174-    await Promise.resolve();175-    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });176-177-    buttonByText(container, "Confirm")!.click();178-    await Promise.resolve();179-    expect(mockSendMessage).toHaveBeenCalledWith({180-      type: "DESKTOP_PAIR_CONFIRM",181-      payload: { confirmed: true },182-    });183-  });184-185-  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {186-    const { container } = mount();187-    gotoDesktopEnter(container);188-    inputText(codeInput(container), "428913");189-    buttonByText(container, "Connect")!.click();190-    await Promise.resolve();191-    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });192-193-    buttonByText(container, "Cancel")!.click();194-    await Promise.resolve();195-    expect(mockSendMessage).toHaveBeenCalledWith({196-      type: "DESKTOP_PAIR_CONFIRM",197-      payload: { confirmed: false },198-    });199-    expect(container.textContent).toContain("Codes don't match");200-  });201-202-  it("shows the combined 'not found or expired' copy on invalid_code", async () => {203-    const { container } = mount();204-    gotoDesktopEnter(container);205-    inputText(codeInput(container), "000000");206-    buttonByText(container, "Connect")!.click();207-    await Promise.resolve();208-209-    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth210-    // §0.3) — the background collapses both to invalid_code via a socket close.211-    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });212-213-    expect(container.textContent).toContain("Code not found or expired");214-    expect(buttonByText(container, "Try again")).toBeTruthy();215-  });216-217-  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {218-    const { container } = mount();219-    gotoDesktopEnter(container);220-    inputText(codeInput(container), "111111");221-    buttonByText(container, "Connect")!.click();222-    await Promise.resolve();223-    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });224-    expect(container.textContent).toContain("Couldn't reach your phone");225-  });226-227-  it("renders Paired! and the device name on a complete result", async () => {228-    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };229-    const { container } = mount();230-    gotoDesktopEnter(container);231-    inputText(codeInput(container), "428913");232-    buttonByText(container, "Connect")!.click();233-    await Promise.resolve();234-    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });235-    buttonByText(container, "Confirm")!.click();236-    await Promise.resolve();237-238-    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });239-    expect(container.textContent).toContain("Paired!");240-    expect(container.textContent).toContain("Connected to June's iMac");241-  });242-243-  it("auto-dismisses back to settings after the paired state settles", async () => {244-    vi.useFakeTimers();245-    try {246-      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };247-      const onBack = vi.fn();248-      const { container } = mount(onBack);249-      gotoDesktopEnter(container);250-      inputText(codeInput(container), "428913");251-      buttonByText(container, "Connect")!.click();252-      await Promise.resolve();253-      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });254-      buttonByText(container, "Confirm")!.click();255-      await Promise.resolve();256-      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });257-      expect(container.textContent).toContain("Paired!");258-      vi.advanceTimersByTime(2000);259-      // returns to the Settings sync hub, which owns the device list now260-      expect(onBack).toHaveBeenCalled();261-    } finally {262-      vi.useRealTimers();263-    }264-  });265-266-  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {267-    startResponse = { ok: false, error: "vault_locked" };268-    const { container } = mount();269-    gotoDesktopEnter(container);270-    inputText(codeInput(container), "428913");271-    buttonByText(container, "Connect")!.click();272-    await Promise.resolve();273-    await Promise.resolve();274-    expect(container.textContent).toContain("Unlock Peach first");275-  });276-});

view file at the parent

src/popup/views/ChirpSync.tsx+0 −342
@@ -1,342 +0,0 @@1-// ChirpSync — the pairing surface, opened from the Settings → Sync card.2-//3-// Settings owns the sync "hub" (paired-device list, revoke, sync-now); this4-// view is purely the pairing flow, returning to Settings (`onBack`) when done5-// or cancelled: the user types the pairing code their phone displays, the6-// browser dials out to the address inside it, and the NK handshake + SAS run7-// from there (enter code → connecting → SAS → paired).8-//9-// There is one path because there can only be one: the browser cannot listen,10-// so it is always the side that dials. The audio-chirp flow this file is named11-// after has been removed.12-//13-// The pairing itself runs in the background (desktop-pairing.ts) and reaches14-// this view via the PAIR_WITH_DESKTOP / DESKTOP_PAIR_* messages.15-16-import {17-  Component,18-  Show,19-  createEffect,20-  createSignal,21-  onCleanup,22-  onMount,23-} from "solid-js";24-import { sendMessage } from "../../shared/bridge";25-import type { PairedDevice } from "../../shared/sync-types";26-import { DesktopBird, MobileBird } from "../components/ChirpPairingFlow";27-import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";28-29-export interface ChirpSyncViewProps {30-  isPro: boolean;31-  onBack(): void;32-  onLearnPro(): void;33-  /**34-   * Set when the user taps the pairing option on the Settings sync card. With35-   * no method there is nothing to do here, so the view returns to Settings.36-   * Read once on mount.37-   */38-  initialPair?: "desktop" | null;39-  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */40-  onProChanged?: () => void;41-}42-43-// Code-pairing phases. Mirrors the background DesktopPairingError union.44-type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";45-type DesktopPairingError =46-  | "invalid_code"47-  | "no_host_found"48-  | "handshake_failed"49-  | "sas_mismatch"50-  | "vault_locked";51-52-const DESKTOP_PAIRED_DISMISS_MS = 2_000;53-54-function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {55-  switch (error) {56-    case "invalid_code":57-      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —58-      // the listener closes the socket either way, with no error frame.59-      return {60-        headline: "Code not found or expired",61-        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",62-      };63-    case "no_host_found":64-      return {65-        headline: "Couldn't reach your phone",66-        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",67-      };68-    case "sas_mismatch":69-      return {70-        headline: "Codes don't match",71-        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",72-      };73-    case "vault_locked":74-      return {75-        headline: "Unlock Peach first",76-        sub: "Your vault needs to be unlocked before you can pair a device.",77-      };78-    case "handshake_failed":79-    default:80-      return {81-        headline: "Pairing failed",82-        sub: "Something went wrong while connecting. Please try again.",83-      };84-  }85-}86-87-export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {88-  // The whole NK pairing runs in the background; this view drives the UI and89-  // the out-of-band SAS confirmation round-trip via the PAIR_WITH_DESKTOP /90-  // DESKTOP_PAIR_* messages. The SAS gate is what authenticates the peer91-  // (audit HIGH — pairing initiator authentication), not the pairing token.92-  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");93-  const [desktopCode, setDesktopCode] = createSignal("");94-  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);95-  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");96-  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);97-  const [desktopBusy, setDesktopBusy] = createSignal(false);98-99-  // Two accepted forms: the pairing code a phone displays, and the legacy100-  // 6-digit desktop-helper code. `parsePairingCode` returning null just means101-  // "not that form", so the check falls through to the digit shape.102-  const desktopCodeReady = () =>103-    parsePairingCode(desktopCode()) !== null || /^\d{6}$/.test(desktopCode());104-105-  function startDesktopFlow(): void {106-    setDesktopCode("");107-    setDesktopSas(null);108-    setDesktopError("unknown");109-    setDesktopDevice(undefined);110-    setDesktopBusy(false);111-    setDesktopPhase("enter");112-  }113-114-  function onDesktopCodeInput(value: string): void {115-    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);116-    // Group once the input outgrows a 6-digit helper code, so a pairing code117-    // regains its dashes as it is typed without mangling the short form. Length118-    // rather than character class, so an all-digit pairing code still groups.119-    setDesktopCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);120-  }121-122-  async function handleDesktopConnect(): Promise<void> {123-    if (!desktopCodeReady() || desktopBusy()) return;124-    setDesktopBusy(true);125-    setDesktopPhase("connecting");126-    try {127-      const res = (await sendMessage({128-        type: "PAIR_WITH_DESKTOP",129-        payload: { code: desktopCode() },130-      })) as { ok: boolean; error?: string };131-      // A synchronous start failure (e.g. vault_locked) returns here; the SAS132-      // and the terminal result arrive later as pushes.133-      if (!res.ok) {134-        setDesktopError((res.error as DesktopPairingError) ?? "unknown");135-        setDesktopPhase("error");136-      }137-    } catch {138-      setDesktopError("unknown");139-      setDesktopPhase("error");140-    } finally {141-      setDesktopBusy(false);142-    }143-  }144-145-  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {146-    if (desktopBusy()) return;147-    setDesktopBusy(true);148-    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the149-    // final phase (paired vs sas_mismatch error).150-    if (confirmed) setDesktopPhase("connecting");151-    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }152-    try {153-      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });154-    } finally {155-      setDesktopBusy(false);156-    }157-  }158-159-  // Auto-dismiss after the paired state settles — back to the Settings hub,160-  // which reloads the paired-device list on mount.161-  createEffect(() => {162-    if (desktopPhase() !== "paired") return;163-    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);164-    onCleanup(() => clearTimeout(t));165-  });166-167-  onMount(() => {168-    // Opened straight into a pairing path from the Settings sync card. With no169-    // method there's nothing to do here — return to Settings (the hub).170-    if (props.initialPair === "desktop") startDesktopFlow();171-    else props.onBack();172-173-    // Desktop pairing pushes from the background (bg → popup). The whole NK174-    // pairing runs in the service worker; the SAS to confirm and the terminal175-    // result arrive here as runtime messages.176-    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {177-      const handler = (msg: { type?: string; payload?: unknown }) => {178-        if (msg?.type === "DESKTOP_PAIR_SAS") {179-          const p = msg.payload as { sas?: string } | undefined;180-          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }181-        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {182-          const p = msg.payload as183-            | { status: "complete"; device: PairedDevice }184-            | { status: "error"; error: DesktopPairingError }185-            | undefined;186-          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }187-          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }188-        }189-      };190-      chrome.runtime.onMessage.addListener(handler);191-      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));192-    }193-    // If the popup closes while the user is on the SAS screen, resolve the194-    // pending background confirm as false so the service worker clears195-    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".196-    onCleanup(() => {197-      if (desktopPhase() === "sas") {198-        void sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: false } });199-      }200-    });201-  });202-203-  // --- Sub-views --------------------------------------------------204-205-  const backArrow = (206-    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">207-      <path d="M10 3 L5 8 L10 13" />208-    </svg>209-  );210-211-  // The bird stage — always animating — shared by every desktop pairing screen212-  // so the bird animation is present regardless of sync method.213-  const desktopBirdStage = (active: boolean) => (214-    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">215-      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">216-        <DesktopBird pose="chirping" />217-      </div>218-      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">219-        <MobileBird pose="attentive" />220-      </div>221-    </div>222-  );223-224-  function desktopLiveText(): string {225-    switch (desktopPhase()) {226-      case "enter": return "Enter the pairing code from your phone";227-      case "connecting": return "Connecting to your phone";228-      case "sas": return "Confirm the code matches on your phone";229-      case "paired": return "Paired";230-      case "error": return desktopErrorCopy(desktopError()).headline;231-    }232-  }233-234-  // Desktop helper code pairing — birds animate on every screen below.235-  const desktopView = () => (236-    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a device with a code">237-      <header class="peach-chirp-head">238-        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>239-        <div class="peach-chirp-title">Enter a code</div>240-        <div class="peach-chirp-head-spacer" />241-      </header>242-243-      {desktopBirdStage(desktopPhase() !== "enter")}244-245-      <Show when={desktopPhase() === "enter"}>246-        <div class="peach-desktop-enter">247-          <div class="peach-chirp-headline">Enter the code from your phone</div>248-          <div class="peach-chirp-sub">249-            Open Peach on your phone, tap “Pair a device”, and type the code it shows.250-            Both devices need to be on the same Wi-Fi.251-          </div>252-          <label class="peach-label" for="desktop-pair-code">pairing code</label>253-          <input254-            id="desktop-pair-code"255-            class="peach-underline-input peach-unlock-input peach-code-input"256-            type="text"257-            autocapitalize="characters"258-            autocomplete="one-time-code"259-            spellcheck={false}260-            maxlength={32}261-            placeholder="A74H-48J3-KI32-OEP4"262-            aria-label="Pairing code"263-            value={desktopCode()}264-            ref={(el) => setTimeout(() => el.focus(), 80)}265-            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}266-            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}267-          />268-          <div class="peach-chirp-action">269-            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>270-            <button271-              class="peach-btn peach-btn-primary peach-desktop-connect"272-              disabled={!desktopCodeReady() || desktopBusy()}273-              onClick={() => void handleDesktopConnect()}274-            >275-              Connect276-            </button>277-          </div>278-        </div>279-      </Show>280-281-      <Show when={desktopPhase() === "connecting"}>282-        <div class="peach-desktop-searching">283-          <div class="peach-chirp-headline">Connecting…</div>284-          <div class="peach-chirp-sub">Reaching your phone over the local network.</div>285-        </div>286-      </Show>287-288-      <Show when={desktopPhase() === "sas"}>289-        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">290-          <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>291-          <div292-            class="peach-desktop-sas"293-            style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"294-          >295-            {desktopSas()}296-          </div>297-          <div class="peach-chirp-sub" style="max-width:240px;">298-            Only continue if this 6-digit code is identical on both screens. This is299-            what proves you're talking to your own device.300-          </div>301-          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">302-            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>303-              Confirm304-            </button>305-            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>306-              Cancel307-            </button>308-          </div>309-        </div>310-      </Show>311-312-      <Show when={desktopPhase() === "paired"}>313-        <div class="peach-desktop-paired">314-          <div class="peach-chirp-headline">Paired!</div>315-          <div class="peach-chirp-sub">316-            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your phone"}317-          </div>318-          <div class="peach-chirp-action">319-            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>320-          </div>321-        </div>322-      </Show>323-324-      <Show when={desktopPhase() === "error"}>325-        <div class="peach-desktop-error">326-          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>327-          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>328-          <div class="peach-chirp-action">329-            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>330-            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>331-          </div>332-        </div>333-      </Show>334-335-      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>336-    </div>337-  );338-339-  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can340-  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.341-  return desktopView();342-};

view file at the parent

src/popup/views/PairDevice.test.tsx+276 −0
@@ -0,0 +1,276 @@1+// @vitest-environment jsdom2+//3+// Code pairing, exercised through the integrated PairDeviceView state machine.4+// Code pairing is a MODE inside ChirpSync (birds always animate), not a separate5+// component — so these specs walk enter → connecting → SAS → paired/error,6+// asserting the popup↔background message contract at each step.7+//8+// The input accepts both code forms: the pairing code a phone displays9+// (pairing-code.ts) and the legacy 6-digit desktop-helper code.10+//11+// chrome messaging is mocked the way the popup uses it:12+//   - shared/bridge `sendMessage` is mocked (popup → bg)13+//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can14+//     fire CODE_PAIR_SAS / CODE_PAIR_RESULT pushes (bg → popup)15+16+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";17+18+// --- mock the background bridge (popup → bg) -------------------------------19+const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));20+vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));21+22+// Mutated per-test to script the PAIR_WITH_CODE start response.23+let startResponse: { ok: boolean; error?: string } = { ok: true };24+25+import { PairDeviceView } from "./PairDevice";26+import type { PairedDevice } from "../../shared/sync-types";27+import { renderView, inputText } from "./view-test-utils";28+29+// --- fake chrome.runtime push channel (bg → popup) ------------------------30+type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;31+let listeners: RuntimeListener[] = [];32+33+function pushFromBackground(type: string, payload: unknown): void {34+  for (const l of [...listeners]) l({ type, payload });35+}36+37+beforeEach(() => {38+  listeners = [];39+  startResponse = { ok: true };40+  mockSendMessage.mockReset();41+  mockSendMessage.mockImplementation(async (msg: { type?: string }) => {42+    if (msg?.type === "PAIR_WITH_CODE") return startResponse;43+    return { ok: true };44+  });45+  (globalThis as unknown as { chrome: unknown }).chrome = {46+    runtime: {47+      onMessage: {48+        addListener: (fn: RuntimeListener) => { listeners.push(fn); },49+        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },50+      },51+    },52+  };53+});54+55+describe("ChirpSync — code pairing", () => {56+  const cleanups: Array<() => void> = [];57+  afterEach(() => {58+    while (cleanups.length) cleanups.pop()!();59+  });60+61+  // ChirpSync is pairing-only now; it opens straight into the desktop code flow62+  // (the device list / method choice live on the Settings sync card).63+  function mount(onBack: () => void = () => {}) {64+    const r = renderView(65+      <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,66+    );67+    cleanups.push(r.dispose);68+    return r;69+  }70+71+  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {72+    return Array.from(container.querySelectorAll("button")).find(73+      (b) => (b.textContent ?? "").trim() === text,74+    ) as HTMLButtonElement | undefined;75+  }76+  function codeInput(container: HTMLElement): HTMLInputElement {77+    return container.querySelector("#pair-code") as HTMLInputElement;78+  }79+80+  // initialPair="code" lands directly on the code-entry screen.81+  function gotoDesktopEnter(_container: HTMLElement): void {82+    /* no-op: mount() already opens the desktop code flow */83+  }84+85+  it("opens straight into the desktop code flow (no method-select screen)", () => {86+    const { container } = mount();87+    expect(container.textContent).not.toContain("How do you want to pair?");88+    // The code input is present immediately, without any intermediate choice.89+    expect(codeInput(container)).toBeTruthy();90+    expect(buttonByText(container, "Connect")).toBeTruthy();91+  });92+93+  it("renders a code input sized for a full pairing code, with birds present", () => {94+    const { container } = mount();95+    gotoDesktopEnter(container);96+    const input = codeInput(container);97+    expect(input).toBeTruthy();98+    // Letters are part of the code now, so the field must not be numeric-only.99+    expect(input.getAttribute("inputmode")).toBeNull();100+    expect(input.getAttribute("maxlength")).toBe("32");101+    // birds always animate on the code screens (design correction)102+    expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();103+  });104+105+  it("uppercases and regroups a pairing code as it is typed", () => {106+    const { container } = mount();107+    gotoDesktopEnter(container);108+    const input = codeInput(container);109+    inputText(input, "a74h48j3ki32oep4");110+    expect(input.value).toBe("A74H-48J3-KI32-OEP4");111+    expect(buttonByText(container, "Connect")!.disabled).toBe(false);112+  });113+114+  it("leaves a short legacy code ungrouped and strips unusable characters", () => {115+    const { container } = mount();116+    gotoDesktopEnter(container);117+    const input = codeInput(container);118+    inputText(input, "12_34/56");119+    expect(input.value).toBe("123456");120+  });121+122+  it("keeps Connect disabled for input that is neither code form", () => {123+    const { container } = mount();124+    gotoDesktopEnter(container);125+    // Long enough to be grouped, but too short to carry a token.126+    inputText(codeInput(container), "A74H48J3");127+    expect(buttonByText(container, "Connect")!.disabled).toBe(true);128+  });129+130+  it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {131+    const { container } = mount();132+    gotoDesktopEnter(container);133+    inputText(codeInput(container), "428913");134+    const connect = buttonByText(container, "Connect")!;135+    expect(connect.disabled).toBe(false);136+    connect.click();137+    await Promise.resolve();138+    expect(mockSendMessage).toHaveBeenCalledWith({139+      type: "PAIR_WITH_CODE",140+      payload: { code: "428913" },141+    });142+    expect(container.textContent).toContain("Connecting…");143+  });144+145+  it("Connect stays disabled until 6 digits are entered", () => {146+    const { container } = mount();147+    gotoDesktopEnter(container);148+    inputText(codeInput(container), "123");149+    expect(buttonByText(container, "Connect")!.disabled).toBe(true);150+    inputText(codeInput(container), "123456");151+    expect(buttonByText(container, "Connect")!.disabled).toBe(false);152+  });153+154+  it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {155+    const { container } = mount();156+    gotoDesktopEnter(container);157+    inputText(codeInput(container), "428913");158+    buttonByText(container, "Connect")!.click();159+    await Promise.resolve();160+161+    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });162+163+    expect(container.textContent).toContain("Confirm this code matches on your phone:");164+    expect(container.textContent).toContain("895245");165+    expect(buttonByText(container, "Confirm")).toBeTruthy();166+    expect(buttonByText(container, "Cancel")).toBeTruthy();167+  });168+169+  it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {170+    const { container } = mount();171+    gotoDesktopEnter(container);172+    inputText(codeInput(container), "428913");173+    buttonByText(container, "Connect")!.click();174+    await Promise.resolve();175+    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });176+177+    buttonByText(container, "Confirm")!.click();178+    await Promise.resolve();179+    expect(mockSendMessage).toHaveBeenCalledWith({180+      type: "CODE_PAIR_CONFIRM",181+      payload: { confirmed: true },182+    });183+  });184+185+  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {186+    const { container } = mount();187+    gotoDesktopEnter(container);188+    inputText(codeInput(container), "428913");189+    buttonByText(container, "Connect")!.click();190+    await Promise.resolve();191+    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });192+193+    buttonByText(container, "Cancel")!.click();194+    await Promise.resolve();195+    expect(mockSendMessage).toHaveBeenCalledWith({196+      type: "CODE_PAIR_CONFIRM",197+      payload: { confirmed: false },198+    });199+    expect(container.textContent).toContain("Codes don't match");200+  });201+202+  it("shows the combined 'not found or expired' copy on invalid_code", async () => {203+    const { container } = mount();204+    gotoDesktopEnter(container);205+    inputText(codeInput(container), "000000");206+    buttonByText(container, "Connect")!.click();207+    await Promise.resolve();208+209+    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth210+    // §0.3) — the background collapses both to invalid_code via a socket close.211+    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });212+213+    expect(container.textContent).toContain("Code not found or expired");214+    expect(buttonByText(container, "Try again")).toBeTruthy();215+  });216+217+  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {218+    const { container } = mount();219+    gotoDesktopEnter(container);220+    inputText(codeInput(container), "111111");221+    buttonByText(container, "Connect")!.click();222+    await Promise.resolve();223+    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });224+    expect(container.textContent).toContain("Couldn't reach your phone");225+  });226+227+  it("renders Paired! and the device name on a complete result", async () => {228+    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };229+    const { container } = mount();230+    gotoDesktopEnter(container);231+    inputText(codeInput(container), "428913");232+    buttonByText(container, "Connect")!.click();233+    await Promise.resolve();234+    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });235+    buttonByText(container, "Confirm")!.click();236+    await Promise.resolve();237+238+    pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });239+    expect(container.textContent).toContain("Paired!");240+    expect(container.textContent).toContain("Connected to June's iMac");241+  });242+243+  it("auto-dismisses back to settings after the paired state settles", async () => {244+    vi.useFakeTimers();245+    try {246+      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };247+      const onBack = vi.fn();248+      const { container } = mount(onBack);249+      gotoDesktopEnter(container);250+      inputText(codeInput(container), "428913");251+      buttonByText(container, "Connect")!.click();252+      await Promise.resolve();253+      pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });254+      buttonByText(container, "Confirm")!.click();255+      await Promise.resolve();256+      pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });257+      expect(container.textContent).toContain("Paired!");258+      vi.advanceTimersByTime(2000);259+      // returns to the Settings sync hub, which owns the device list now260+      expect(onBack).toHaveBeenCalled();261+    } finally {262+      vi.useRealTimers();263+    }264+  });265+266+  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {267+    startResponse = { ok: false, error: "vault_locked" };268+    const { container } = mount();269+    gotoDesktopEnter(container);270+    inputText(codeInput(container), "428913");271+    buttonByText(container, "Connect")!.click();272+    await Promise.resolve();273+    await Promise.resolve();274+    expect(container.textContent).toContain("Unlock Peach first");275+  });276+});

view file

src/popup/views/PairDevice.tsx+341 −0
@@ -0,0 +1,341 @@1+// ChirpSync — the pairing surface, opened from the Settings → Sync card.2+//3+// Settings owns the sync "hub" (paired-device list, revoke, sync-now); this4+// view is purely the pairing flow, returning to Settings (`onBack`) when done5+// or cancelled: the user types the pairing code their phone displays, the6+// browser dials out to the address inside it, and the NK handshake + SAS run7+// from there (enter code → connecting → SAS → paired).8+//9+// There is one path because there can only be one: the browser cannot listen,10+// so it is always the side that dials. The audio-chirp flow this file is named11+// after has been removed.12+//13+// The pairing itself runs in the background (code-pairing.ts) and reaches14+// this view via the PAIR_WITH_CODE / DESKTOP_PAIR_* messages.15+16+import {17+  Component,18+  Show,19+  createEffect,20+  createSignal,21+  onCleanup,22+  onMount,23+} from "solid-js";24+import { sendMessage } from "../../shared/bridge";25+import type { PairedDevice } from "../../shared/sync-types";26+import { DesktopBird, MobileBird } from "../components/PairingBirds";27+import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";28+29+export interface PairDeviceViewProps {30+  isPro: boolean;31+  onBack(): void;32+  onLearnPro(): void;33+  /**34+   * Set when the user taps the pairing option on the Settings sync card. With35+   * no method there is nothing to do here, so the view returns to Settings.36+   * Read once on mount.37+   */38+  initialPair?: "code" | null;39+  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */40+  onProChanged?: () => void;41+}42+43+// Code-pairing phases. Mirrors the background CodePairingError union.44+type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";45+type CodePairingError =46+  | "invalid_code"47+  | "no_host_found"48+  | "handshake_failed"49+  | "sas_mismatch"50+  | "vault_locked";51+52+const PAIRED_DISMISS_MS = 2_000;53+54+function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {55+  switch (error) {56+    case "invalid_code":57+      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —58+      // the listener closes the socket either way, with no error frame.59+      return {60+        headline: "Code not found or expired",61+        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",62+      };63+    case "no_host_found":64+      return {65+        headline: "Couldn't reach your phone",66+        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",67+      };68+    case "sas_mismatch":69+      return {70+        headline: "Codes don't match",71+        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",72+      };73+    case "vault_locked":74+      return {75+        headline: "Unlock Peach first",76+        sub: "Your vault needs to be unlocked before you can pair a device.",77+      };78+    case "handshake_failed":79+    default:80+      return {81+        headline: "Pairing failed",82+        sub: "Something went wrong while connecting. Please try again.",83+      };84+  }85+}86+87+export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {88+  // The whole NK pairing runs in the background; this view drives the UI and89+  // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE /90+  // DESKTOP_PAIR_* messages. The SAS gate is what authenticates the peer91+  // (audit HIGH — pairing initiator authentication), not the pairing token.92+  const [phase, setPhase] = createSignal<PairPhase>("enter");93+  const [code, setCode] = createSignal("");94+  const [sas, setSas] = createSignal<string | null>(null);95+  const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");96+  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);97+  const [busy, setBusy] = createSignal(false);98+99+  // Two accepted forms: the pairing code a phone displays, and the legacy100+  // 6-digit desktop-helper code. `parsePairingCode` returning null just means101+  // "not that form", so the check falls through to the digit shape.102+  const codeReady = () =>103+    parsePairingCode(code()) !== null || /^\d{6}$/.test(code());104+105+  function startFlow(): void {106+    setCode("");107+    setSas(null);108+    setErrorKind("unknown");109+    setPairedDevice(undefined);110+    setBusy(false);111+    setPhase("enter");112+  }113+114+  function onCodeInput(value: string): void {115+    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);116+    // Group once the input outgrows a 6-digit helper code, so a pairing code117+    // regains its dashes as it is typed without mangling the short form. Length118+    // rather than character class, so an all-digit pairing code still groups.119+    setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);120+  }121+122+  async function handleConnect(): Promise<void> {123+    if (!codeReady() || busy()) return;124+    setBusy(true);125+    setPhase("connecting");126+    try {127+      const res = (await sendMessage({128+        type: "PAIR_WITH_CODE",129+        payload: { code: code() },130+      })) as { ok: boolean; error?: string };131+      // A synchronous start failure (e.g. vault_locked) returns here; the SAS132+      // and the terminal result arrive later as pushes.133+      if (!res.ok) {134+        setErrorKind((res.error as CodePairingError) ?? "unknown");135+        setPhase("error");136+      }137+    } catch {138+      setErrorKind("unknown");139+      setPhase("error");140+    } finally {141+      setBusy(false);142+    }143+  }144+145+  async function respondConfirm(confirmed: boolean): Promise<void> {146+    if (busy()) return;147+    setBusy(true);148+    // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the149+    // final phase (paired vs sas_mismatch error).150+    if (confirmed) setPhase("connecting");151+    else { setErrorKind("sas_mismatch"); setPhase("error"); }152+    try {153+      await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });154+    } finally {155+      setBusy(false);156+    }157+  }158+159+  // Auto-dismiss after the paired state settles — back to the Settings hub,160+  // which reloads the paired-device list on mount.161+  createEffect(() => {162+    if (phase() !== "paired") return;163+    const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);164+    onCleanup(() => clearTimeout(t));165+  });166+167+  onMount(() => {168+    // Opened straight into a pairing path from the Settings sync card. With no169+    // method there's nothing to do here — return to Settings (the hub).170+    if (props.initialPair === "code") startFlow();171+    else props.onBack();172+173+    // Pairing pushes from the background (bg → popup). The whole NK174+    // pairing runs in the service worker; the SAS to confirm and the terminal175+    // result arrive here as runtime messages.176+    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {177+      const handler = (msg: { type?: string; payload?: unknown }) => {178+        if (msg?.type === "CODE_PAIR_SAS") {179+          const p = msg.payload as { sas?: string } | undefined;180+          if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }181+        } else if (msg?.type === "CODE_PAIR_RESULT") {182+          const p = msg.payload as183+            | { status: "complete"; device: PairedDevice }184+            | { status: "error"; error: CodePairingError }185+            | undefined;186+          if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }187+          else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }188+        }189+      };190+      chrome.runtime.onMessage.addListener(handler);191+      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));192+    }193+    // If the popup closes while the user is on the SAS screen, resolve the194+    // pending background confirm as false so the service worker clears195+    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".196+    onCleanup(() => {197+      if (phase() === "sas") {198+        void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });199+      }200+    });201+  });202+203+  // --- Sub-views --------------------------------------------------204+205+  const backArrow = (206+    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">207+      <path d="M10 3 L5 8 L10 13" />208+    </svg>209+  );210+211+  // The bird stage — always animating — shared by every pairing screen.212+  const birdStage = (active: boolean) => (213+    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">214+      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">215+        <DesktopBird pose="chirping" />216+      </div>217+      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">218+        <MobileBird pose="attentive" />219+      </div>220+    </div>221+  );222+223+  function liveText(): string {224+    switch (phase()) {225+      case "enter": return "Enter the pairing code from your phone";226+      case "connecting": return "Connecting to your phone";227+      case "sas": return "Confirm the code matches on your phone";228+      case "paired": return "Paired";229+      case "error": return errorCopy(errorKind()).headline;230+    }231+  }232+233+  // Code pairing — birds animate on every screen below.234+  const pairingScreen = () => (235+    <div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">236+      <header class="peach-chirp-head">237+        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>238+        <div class="peach-chirp-title">Enter a code</div>239+        <div class="peach-chirp-head-spacer" />240+      </header>241+242+      {birdStage(phase() !== "enter")}243+244+      <Show when={phase() === "enter"}>245+        <div class="peach-desktop-enter">246+          <div class="peach-chirp-headline">Enter the code from your phone</div>247+          <div class="peach-chirp-sub">248+            Open Peach on your phone, tap “Pair a device”, and type the code it shows.249+            Both devices need to be on the same Wi-Fi.250+          </div>251+          <label class="peach-label" for="pair-code">pairing code</label>252+          <input253+            id="pair-code"254+            class="peach-underline-input peach-unlock-input peach-code-input"255+            type="text"256+            autocapitalize="characters"257+            autocomplete="one-time-code"258+            spellcheck={false}259+            maxlength={32}260+            placeholder="A74H-48J3-KI32-OEP4"261+            aria-label="Pairing code"262+            value={code()}263+            ref={(el) => setTimeout(() => el.focus(), 80)}264+            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}265+            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}266+          />267+          <div class="peach-chirp-action">268+            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>269+            <button270+              class="peach-btn peach-btn-primary peach-desktop-connect"271+              disabled={!codeReady() || busy()}272+              onClick={() => void handleConnect()}273+            >274+              Connect275+            </button>276+          </div>277+        </div>278+      </Show>279+280+      <Show when={phase() === "connecting"}>281+        <div class="peach-desktop-searching">282+          <div class="peach-chirp-headline">Connecting…</div>283+          <div class="peach-chirp-sub">Reaching your phone over the local network.</div>284+        </div>285+      </Show>286+287+      <Show when={phase() === "sas"}>288+        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">289+          <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>290+          <div291+            class="peach-desktop-sas"292+            style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"293+          >294+            {sas()}295+          </div>296+          <div class="peach-chirp-sub" style="max-width:240px;">297+            Only continue if this 6-digit code is identical on both screens. This is298+            what proves you're talking to your own device.299+          </div>300+          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">301+            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>302+              Confirm303+            </button>304+            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>305+              Cancel306+            </button>307+          </div>308+        </div>309+      </Show>310+311+      <Show when={phase() === "paired"}>312+        <div class="peach-desktop-paired">313+          <div class="peach-chirp-headline">Paired!</div>314+          <div class="peach-chirp-sub">315+            {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}316+          </div>317+          <div class="peach-chirp-action">318+            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>319+          </div>320+        </div>321+      </Show>322+323+      <Show when={phase() === "error"}>324+        <div class="peach-desktop-error">325+          <div class="peach-chirp-headline">{errorCopy(errorKind()).headline}</div>326+          <div class="peach-chirp-sub">{errorCopy(errorKind()).sub}</div>327+          <div class="peach-chirp-action">328+            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>329+            <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>330+          </div>331+        </div>332+      </Show>333+334+      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>335+    </div>336+  );337+338+  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can339+  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.340+  return pairingScreen();341+};

view file

src/popup/views/Settings.tsx+2 −2
@@ -61,7 +61,7 @@6161   onExport: () => void;6262   onBreachCheckDone: () => void;6363   onOpenHealth?: () => void;64-  onOpenSyncPairing?: (method?: "desktop") => void;64+  onOpenSyncPairing?: (method?: "code") => void;6565   onOpenFileImport?: () => void;6666   onOpenPanic?: () => void;6767   onOpenCodexGenerate?: () => void;@@ -1545,7 +1545,7 @@15451545               no flow where this device is the one being joined. */}15461546           <button15471547             class="peach-sync-pair-option"1548-            onClick={() => props.onOpenSyncPairing?.("desktop")}1548+            onClick={() => props.onOpenSyncPairing?.("code")}15491549           >15501550             <span class="peach-sync-pair-option-icon" aria-hidden="true">15511551               <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">

view file

src/shared/chirp-handshake.ts+1 −1
@@ -4,7 +4,7 @@44 // uses from here is `derivePairingSAS` and the `LocalIdentity` /55 // `PairingTransport` types; the live dial-and-handshake path is66 // `shared/pairing-dial.ts`, and the live PAIR_IDENTITY exchange is7-// `background/desktop-pairing.ts`.7+// `background/code-pairing.ts`.88 //99 // The `runPairing*` helpers below are exercised only by this module's own1010 // specs. Do NOT reach for the RESPONDER ones in new code: they assume this

view file

src/shared/types.ts+6 −7
@@ -303,13 +303,12 @@303303   | "CHIRP_LIST_PAIRED_DEVICES"304304   | "CHIRP_UNPAIR_DEVICE"305305   | "CHIRP_RENAME_LOCAL_DEVICE"306-  // Desktop helper code pairing (peach-sync). PAIR_WITH_DESKTOP /307-  // DESKTOP_PAIR_CONFIRM are popup→bg requests handled in the switch;308-  // DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT are bg→popup pushes.309-  | "PAIR_WITH_DESKTOP"310-  | "DESKTOP_PAIR_CONFIRM"311-  | "DESKTOP_PAIR_SAS"312-  | "DESKTOP_PAIR_RESULT"306+  // Code pairing. PAIR_WITH_CODE / CODE_PAIR_CONFIRM are popup→bg requests307+  // handled in the switch; CODE_PAIR_SAS / CODE_PAIR_RESULT are bg→popup pushes.308+  | "PAIR_WITH_CODE"309+  | "CODE_PAIR_CONFIRM"310+  | "CODE_PAIR_SAS"311+  | "CODE_PAIR_RESULT"313312   | "TRIGGER_SYNC"314313   | "GET_SYNC_STATE"315314   | "GET_PENDING_PASSKEY"

view file