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
↓ peach-browser-1672cc40.tar.gzatlas at this save
files changed20 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 −7manifest.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": {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",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",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+}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+});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+}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-}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-});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-}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 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(); }}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-};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+};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-});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-};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+});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+};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">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 thissrc/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"