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
7 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 −19
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.24",5+  "version": "4.0.25",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach 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",

view file

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

view file

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). */

view file

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 () => {

view file

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>

view file

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>

view file