e94f79e21806
route the popup pairing surface at the phone: accept a full pairing code in the code field, one Settings entry point, phone-oriented copy (v4.0.25)
- time
- 2026-07-24 21:45 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing code transport
- parent
- a4a291aa27a1
↓ peach-browser-e94f79e2.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+5 −2src/popup/views/ChirpSync.desktop.test.tsx+41 −17src/popup/views/ChirpSync.tsx+41 −27src/popup/views/Settings.tsx+5 −19manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "4.0.24",5+ "version": "4.0.25",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.24",4+ "version": "4.0.25",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.24",3+ "version": "4.0.25",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/styles/popup.css+5 −2
@@ -1039,10 +1039,13 @@10391039 padding: 4px 18px 16px; text-align: center; align-items: center;10401040 }10411041 .peach-desktop-enter .peach-label { align-self: flex-start; }1042+/* Sized for a full 19-character pairing code (A74H-48J3-KI32-OEP4) in a1043+ 320px popup — a 6-digit legacy helper code still reads fine at this size. */10421044 .peach-code-input {1043- text-align: center; letter-spacing: 0.42em; padding-left: 0.42em;1045+ text-align: center; letter-spacing: 0.12em; padding-left: 0.12em;1046+ font-size: 15px;10441047 }1045-.peach-code-input::placeholder { letter-spacing: 0.42em; }1048+.peach-code-input::placeholder { letter-spacing: 0.12em; }10461049 10471050 /* Sync card "pair a device" options — bordered, raised tappable rows that read10481051 as buttons on any card hue (theme-neutral light overlay, not the peach accent). */src/popup/views/ChirpSync.desktop.test.tsx+41 −17
@@ -1,12 +1,13 @@11 // @vitest-environment jsdom22 //3-// Desktop helper code pairing (Change 4), exercised through the integrated4-// ChirpSyncView state machine. Per the brief's "UI Design Correction", desktop5-// pairing is a MODE inside ChirpSync (birds always animate), not a separate6-// component — so these specs navigate: rest → method-select → desktop enter →7-// connecting → SAS → paired/error, asserting the popup↔background message8-// contract at each step.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.97 //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+//1011 // chrome messaging is mocked the way the popup uses it:1112 // - shared/bridge `sendMessage` is mocked (popup → bg)1213 // - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can@@ -51,7 +52,7 @@5152 };5253 });5354 54-describe("ChirpSync — desktop helper pairing", () => {55+describe("ChirpSync — code pairing", () => {5556 const cleanups: Array<() => void> = [];5657 afterEach(() => {5758 while (cleanups.length) cleanups.pop()!();@@ -89,20 +90,43 @@8990 expect(buttonByText(container, "Connect")).toBeTruthy();9091 });9192 92- it("renders a 6-digit numeric code input with birds present", () => {93+ it("renders a code input sized for a full pairing code, with birds present", () => {9394 const { container } = mount();9495 gotoDesktopEnter(container);9596 const input = codeInput(container);9697 expect(input).toBeTruthy();97- expect(input.getAttribute("inputmode")).toBe("numeric");98- expect(input.getAttribute("maxlength")).toBe("6");99- // birds always animate on the desktop screens (design correction)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)100102 expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();101- // non-digits are stripped and the value is capped at 6 chars102- inputText(input, "12ab3456789");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");103119 expect(input.value).toBe("123456");104120 });105121 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+106130 it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {107131 const { container } = mount();108132 gotoDesktopEnter(container);@@ -115,7 +139,7 @@115139 type: "PAIR_WITH_DESKTOP",116140 payload: { code: "428913" },117141 });118- expect(container.textContent).toContain("Searching for helper…");142+ expect(container.textContent).toContain("Connecting…");119143 });120144 121145 it("Connect stays disabled until 6 digits are entered", () => {@@ -136,7 +160,7 @@136160 137161 pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });138162 139- expect(container.textContent).toContain("Confirm this code matches on your computer:");163+ expect(container.textContent).toContain("Confirm this code matches on your phone:");140164 expect(container.textContent).toContain("895245");141165 expect(buttonByText(container, "Confirm")).toBeTruthy();142166 expect(buttonByText(container, "Cancel")).toBeTruthy();@@ -190,14 +214,14 @@190214 expect(buttonByText(container, "Try again")).toBeTruthy();191215 });192216 193- it("shows the 'no computer found' copy on no_host_found", async () => {217+ it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {194218 const { container } = mount();195219 gotoDesktopEnter(container);196220 inputText(codeInput(container), "111111");197221 buttonByText(container, "Connect")!.click();198222 await Promise.resolve();199223 pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });200- expect(container.textContent).toContain("Couldn't find your computer");224+ expect(container.textContent).toContain("Couldn't reach your phone");201225 });202226 203227 it("renders Paired! and the device name on a complete result", async () => {src/popup/views/ChirpSync.tsx+41 −27
@@ -6,13 +6,14 @@66 //77 // - Locked behind Pro (free user) — soft upsell card, "Learn more" routes88 // to the License section.9-// - Chirp — ChirpPairingFlow takes over the surface and drives the three-act10-// bird animation in lockstep with the audio protocol.11-// - Code — the peach-sync desktop-helper NK flow (enter code → SAS → paired).9+// - Code — the primary path: the user types the pairing code their phone10+// displays, the browser dials out to the address inside it, and the NK11+// handshake + SAS run from there (enter code → SAS → paired).12+// - Chirp — the superseded audio-pairing flow. No entry point reaches it any13+// more; the code below is still here pending the removal decision.1214 //13-// The chirp orchestrator lives in `popup/chirp-pair.ts`; the desktop NK pairing14-// runs in the background and reaches this view via the PAIR_*/DESKTOP_PAIR_*15-// messages.15+// The NK pairing runs in the background (desktop-pairing.ts) and reaches this16+// view via the PAIR_*/DESKTOP_PAIR_* messages.1617 1718 import {1819 Component,@@ -36,6 +37,7 @@3637 type ChirpPairingDeps,3738 type PairingRole,3839 } from "../chirp-pair";40+import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";3941 import { getLocalSubnet } from "../../shared/local-network";4042 import { isNativePlatform } from "../../shared/native";4143 import { openCapacitorPairingServer } from "../chirp-pair-capacitor";@@ -73,15 +75,16 @@7375 function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {7476 switch (error) {7577 case "invalid_code":76- // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.78+ // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —79+ // the listener closes the socket either way, with no error frame.7780 return {7881 headline: "Code not found or expired",79- sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",82+ sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",8083 };8184 case "no_host_found":8285 return {83- headline: "Couldn't find your computer",84- sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",86+ headline: "Couldn't reach your phone",87+ sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",8588 };8689 case "sas_mismatch":8790 return {@@ -133,7 +136,11 @@133136 const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);134137 const [desktopBusy, setDesktopBusy] = createSignal(false);135138 136- const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());139+ // Two accepted forms: the pairing code a phone displays, and the legacy140+ // 6-digit desktop-helper code. `parsePairingCode` returning null just means141+ // "not that form", so the check falls through to the digit shape.142+ const desktopCodeReady = () =>143+ parsePairingCode(desktopCode()) !== null || /^\d{6}$/.test(desktopCode());137144 138145 function startDesktopFlow(): void {139146 setDesktopCode("");@@ -151,7 +158,11 @@151158 }152159 153160 function onDesktopCodeInput(value: string): void {154- setDesktopCode(value.replace(/\D/g, "").slice(0, 6));161+ const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);162+ // Group once the input outgrows a 6-digit helper code, so a pairing code163+ // regains its dashes as it is typed without mangling the short form. Length164+ // rather than character class, so an all-digit pairing code still groups.165+ setDesktopCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);155166 }156167 157168 async function handleDesktopConnect(): Promise<void> {@@ -402,9 +413,9 @@402413 403414 function desktopLiveText(): string {404415 switch (desktopPhase()) {405- case "enter": return "Enter the pairing code from your computer";406- case "connecting": return "Searching for the helper";407- case "sas": return "Confirm the code matches on your computer";416+ case "enter": return "Enter the pairing code from your phone";417+ case "connecting": return "Connecting to your phone";418+ case "sas": return "Confirm the code matches on your phone";408419 case "paired": return "Paired";409420 case "error": return desktopErrorCopy(desktopError()).headline;410421 }@@ -412,7 +423,7 @@412423 413424 // Desktop helper code pairing — birds animate on every screen below.414425 const desktopView = () => (415- <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">426+ <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a device with a code">416427 <header class="peach-chirp-head">417428 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>418429 <div class="peach-chirp-title">Enter a code</div>@@ -423,20 +434,22 @@423434 424435 <Show when={desktopPhase() === "enter"}>425436 <div class="peach-desktop-enter">426- <div class="peach-chirp-headline">Enter the code from your computer</div>437+ <div class="peach-chirp-headline">Enter the code from your phone</div>427438 <div class="peach-chirp-sub">428- Open the Peach helper on your computer and type the 6-digit code it shows.439+ Open Peach on your phone, tap “Pair a device”, and type the code it shows.440+ Both devices need to be on the same Wi-Fi.429441 </div>430442 <label class="peach-label" for="desktop-pair-code">pairing code</label>431443 <input432444 id="desktop-pair-code"433445 class="peach-underline-input peach-unlock-input peach-code-input"434446 type="text"435- inputmode="numeric"447+ autocapitalize="characters"436448 autocomplete="one-time-code"437- maxlength={6}438- placeholder="000000"439- aria-label="6-digit pairing code"449+ spellcheck={false}450+ maxlength={32}451+ placeholder="A74H-48J3-KI32-OEP4"452+ aria-label="Pairing code"440453 value={desktopCode()}441454 ref={(el) => setTimeout(() => el.focus(), 80)}442455 onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}@@ -457,14 +470,14 @@457470 458471 <Show when={desktopPhase() === "connecting"}>459472 <div class="peach-desktop-searching">460- <div class="peach-chirp-headline">Searching for helper…</div>461- <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>473+ <div class="peach-chirp-headline">Connecting…</div>474+ <div class="peach-chirp-sub">Reaching your phone over the local network.</div>462475 </div>463476 </Show>464477 465478 <Show when={desktopPhase() === "sas"}>466479 <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">467- <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>480+ <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>468481 <div469482 class="peach-desktop-sas"470483 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;"@@ -472,7 +485,8 @@472485 {desktopSas()}473486 </div>474487 <div class="peach-chirp-sub" style="max-width:240px;">475- Only continue if this 6-digit code is identical on both screens.488+ Only continue if this 6-digit code is identical on both screens. This is489+ what proves you're talking to your own device.476490 </div>477491 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">478492 <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>@@ -489,7 +503,7 @@489503 <div class="peach-desktop-paired">490504 <div class="peach-chirp-headline">Paired!</div>491505 <div class="peach-chirp-sub">492- {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}506+ {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your phone"}493507 </div>494508 <div class="peach-chirp-action">495509 <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>src/popup/views/Settings.tsx+5 −19
@@ -1540,9 +1540,12 @@15401540 15411541 {/* Pick a pairing method and begin — no separate hub screen. */}15421542 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1543+ {/* One path in: the phone listens and shows a code, the browser dials1544+ it. A browser extension can never accept a connection, so there is1545+ no flow where this device is the one being joined. */}15431546 <button15441547 class="peach-sync-pair-option"1545- onClick={() => props.onOpenSyncPairing?.("chirp")}1548+ onClick={() => props.onOpenSyncPairing?.("desktop")}15461549 >15471550 <span class="peach-sync-pair-option-icon" aria-hidden="true">15481551 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">@@ -1552,24 +1555,7 @@15521555 </span>15531556 <span class="peach-sync-pair-option-main">15541557 <span class="peach-sync-pair-option-title">Phone or tablet</span>1555- <span class="peach-sync-pair-option-sub">Pair a nearby device in the same room — it plays a quiet chirp</span>1556- </span>1557- <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1558- </button>1559- <button1560- class="peach-sync-pair-option"1561- onClick={() => props.onOpenSyncPairing?.("desktop")}1562- >1563- <span class="peach-sync-pair-option-icon" aria-hidden="true">1564- <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1565- <rect x="2.5" y="4" width="15" height="9.5" rx="1.4" />1566- <path d="M7 17 L13 17" />1567- <path d="M10 13.5 L10 17" />1568- </svg>1569- </span>1570- <span class="peach-sync-pair-option-main">1571- <span class="peach-sync-pair-option-title">Desktop computer</span>1572- <span class="peach-sync-pair-option-sub">Needs the peach-sync helper installed — enter the code it shows</span>1558+ <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>15731559 </span>15741560 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>15751561 </button>