b97a6418fbf5

feat: collapse desktop pairing to two direct options on the sync card — remove method-select screen, Chirp/Code buttons in Settings + rest view deep-link straight into each path; findHost verifies mDNS host before committing (falls back to subnet on stale hit); log real pairing error; bump 4.0.6→4.0.7

time
2026-06-18 20:26 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
sync pairing flow simplify
parent
9d02d22b70df
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/desktop-pairing-bg.ts+8 −4src/background/desktop-pairing-probe.ts+1 −1src/background/desktop-pairing.ts+4 −1src/popup/App.tsx+6 −2src/popup/styles/popup.css+2 −0src/popup/views/ChirpSync.desktop.test.tsx+12 −17src/popup/views/ChirpSync.tsx+53 −64src/popup/views/Settings.tsx+18 −8
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.6",5+  "version": "4.0.7",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.6",4+  "version": "4.0.7",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.6",3+  "version": "4.0.7",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/desktop-pairing-bg.ts+8 −4
@@ -21,7 +21,7 @@2121 import { buildLocalIdentity } from "./chirp-identity";2222 import { computeVaultId } from "../shared/sync-identity";2323 import { isUnlocked, getRootKeyHex } from "./vault";24-import { probeSubnetForPort } from "./desktop-pairing-probe";24+import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";2525 import { discoverPeachSync } from "./desktop-pairing-mdns";2626 import { getLocalSubnet } from "../shared/local-network";2727 import type { LocalIdentity } from "../shared/chirp-handshake";@@ -104,10 +104,14 @@104104 export function productionDesktopPairingDeps(): DesktopPairingDeps {105105   return {106106     async findHost(port) {107-      // mDNS first — instant and reliable when chrome.mdns is available.107+      // mDNS first — instant and reliable when chrome.mdns is available. But108+      // chrome.mdns can hand back stale/duplicate cache entries (e.g. a host109+      // that's since gone, or one not running `peach-sync pair`), so only commit110+      // to a discovered host that actually accepts a pairing connection right111+      // now; otherwise fall through to the probe.108112       const discovered = await discoverPeachSync();109-      if (discovered) return discovered.ip;110-      // Fallback: /24 subnet probe (Firefox, or Chrome without mDNS).113+      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;114+      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).111115       const subnet = await getLocalSubnet();112116       if (!subnet) return null;113117       return probeSubnetForPort(subnet, port);

view file

src/background/desktop-pairing-probe.ts+1 −1
@@ -44,7 +44,7 @@4444 }4545 4646 /** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */47-function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {47+export function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {4848   return new Promise((resolve) => {4949     let ws: WebSocket;5050     let settled = false;

view file

src/background/desktop-pairing.ts+4 −1
@@ -236,7 +236,10 @@236236       await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);237237     }238238     cb.onComplete(device);239-  } catch {239+  } catch (err) {240+    // The mapped error is intentionally coarse for the UI; log the real cause241+    // so a failed pair is diagnosable from the service-worker console.242+    if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);240243     cb.onError("handshake_failed");241244   } finally {242245     sock.close();

view file

src/popup/App.tsx+6 −2
@@ -186,6 +186,9 @@186186   // re-opens onto a stored license that has changed via another window.187187   const [proStatus, setProStatus] = createSignal(false);188188   const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);189+  // When the sync card's "Chirp"/"Code" buttons open the sync view, deep-link190+  // straight into that pairing path instead of the rest screen.191+  const [syncPairIntent, setSyncPairIntent] = createSignal<"chirp" | "desktop" | null>(null);189192   const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");190193   const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);191194   const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");@@ -1518,7 +1521,7 @@15181521                   onExport={() => setView("export")}15191522                   onBreachCheckDone={loadEntries}15201523                   onOpenHealth={openHealth}1521-                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("chirp-sync"); }}1524+                  onOpenSyncPairing={(method) => { setSettingsInitialSection(settingsActiveCard()); setSyncPairIntent(method ?? null); setView("chirp-sync"); }}15221525                   onOpenFileImport={handleOpenFileImport}15231526                   onOpenPanic={() => setView("panic")}15241527                   onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}@@ -1559,7 +1562,8 @@15591562               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>15601563                 <ChirpSyncView15611564                   isPro={proStatus()}1562-                  onBack={() => setView("settings")}1565+                  initialPair={syncPairIntent()}1566+                  onBack={() => { setSyncPairIntent(null); setView("settings"); }}15631567                   onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}15641568                 />15651569               </Suspense>

view file

src/popup/styles/popup.css+2 −0
@@ -1047,6 +1047,8 @@10471047 .peach-pair-method-list {10481048   display: flex; flex-direction: column; gap: 10px; padding: 4px 18px 8px;10491049 }1050+/* On the rest screen the two options sit at the bottom, like the old CTA did. */1051+.peach-pair-method-list--rest { margin-top: auto; padding-bottom: 12px; }10501052 .peach-pair-method {10511053   display: flex; align-items: center; gap: 12px;10521054   width: 100%; text-align: left; cursor: pointer;

view file

src/popup/views/ChirpSync.desktop.test.tsx+12 −17
@@ -79,30 +79,24 @@7979     return container.querySelector("#desktop-pair-code") as HTMLInputElement;8080   }8181 82-  function gotoMethodSelect(container: HTMLElement): void {83-    buttonByText(container, "Pair a device")!.click();84-  }8582   function gotoDesktopEnter(container: HTMLElement): void {86-    gotoMethodSelect(container);87-    buttonContaining(container, "Desktop helper")!.click();83+    // No method-select screen — the rest view links straight into code entry.84+    buttonContaining(container, "Enter a code")!.click();8885   }8986 90-  it("offers both pairing methods as icon cards on the method-select screen", () => {87+  it("offers chirp and code as direct options on the rest screen", () => {9188     const { container } = mount();92-    gotoMethodSelect(container);93-    expect(container.textContent).toContain("How do you want to pair?");94-    // The redundant body copy was removed — the cards explain themselves.95-    expect(container.textContent).not.toContain("In the same room? Use a chirp");89+    // The intermediate "how do you want to pair?" screen is gone.90+    expect(container.textContent).not.toContain("How do you want to pair?");9691 9792     const sameRoom = buttonContaining(container, "Same room")!;98-    const desktop = buttonContaining(container, "Desktop helper")!;93+    const code = buttonContaining(container, "Enter a code")!;9994     expect(sameRoom).toBeTruthy();100-    expect(desktop).toBeTruthy();95+    expect(code).toBeTruthy();10196     // Each card carries its own description and a leading icon.102-    expect(sameRoom.textContent).toContain("Chirp — plays an inaudible sound");103-    expect(desktop.textContent).toContain("Enter the code shown by peach-sync");97+    expect(code.textContent).toContain("Type the code from your desktop sync helper");10498     expect(sameRoom.querySelector(".peach-pair-method-icon")).toBeTruthy();105-    expect(desktop.querySelector(".peach-pair-method-icon")).toBeTruthy();99+    expect(code.querySelector(".peach-pair-method-icon")).toBeTruthy();106100   });107101 108102   it("renders a 6-digit numeric code input with birds present", () => {@@ -247,9 +241,10 @@247241       pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });248242       expect(container.textContent).toContain("Paired!");249243       vi.advanceTimersByTime(2000);250-      // returns to the rest surface (the "Pair a device" hero is back)244+      // returns to the rest surface (the two direct pairing options are back)251245       expect(container.textContent).not.toContain("Paired!");252-      expect(buttonByText(container, "Pair a device")).toBeTruthy();246+      expect(buttonContaining(container, "Same room")).toBeTruthy();247+      expect(buttonContaining(container, "Enter a code")).toBeTruthy();253248     } finally {254249       vi.useRealTimers();255250     }

view file

src/popup/views/ChirpSync.tsx+53 −64
@@ -46,14 +46,19 @@4646   isPro: boolean;4747   onBack(): void;4848   onLearnPro(): void;49+  /**50+   * Open straight into a pairing path, skipping the rest screen — set when the51+   * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount.52+   */53+  initialPair?: "chirp" | "desktop" | null;4954 }5055 5156 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5257 5358 type Mode = "rest" | "pairing";5459 55-// Which pairing path the user picked on the method-select screen. `null` =56-// still choosing. The birds animate the same way on both paths.60+// Which pairing path the user picked. The two paths are entered directly from61+// the rest screen (or the Settings card); the birds animate the same way on both.5762 type PairKind = "chirp" | "desktop" | null;5863 5964 // Desktop helper (peach-sync) code-pairing phases. The SAS and success@@ -135,12 +140,19 @@135140 136141   const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());137142 138-  function openPairMethodSelect(): void {143+  // Two direct entry points — no intermediate "how do you want to pair?" screen.144+  function pairViaChirp(): void {139145     setError(null);140-    setPairKind(null);141146     setMode("pairing");147+    startChirpFlow();142148   }143149 150+  function pairViaDesktop(): void {151+    setError(null);152+    setMode("pairing");153+    startDesktopFlow();154+  }155+144156   function startDesktopFlow(): void {145157     setDesktopCode("");146158     setDesktopSas(null);@@ -248,6 +260,10 @@248260   onMount(() => {249261     void refresh();250262 263+    // Deep-link straight into a pairing path when opened from the sync card.264+    if (props.initialPair === "chirp") pairViaChirp();265+    else if (props.initialPair === "desktop") pairViaDesktop();266+251267     // Desktop pairing pushes from the background (bg → popup). The whole NK252268     // pairing runs in the service worker; the SAS to confirm and the terminal253269     // result arrive here as runtime messages.@@ -568,10 +584,33 @@568584         </div>569585       </Show>570586 571-      <div class="peach-chirp-action">572-        <button class="peach-chirp-pair-start" onClick={() => openPairMethodSelect()}>573-          <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>587+      <div class="peach-pair-method-list peach-pair-method-list--rest">588+        <button class="peach-pair-method" onClick={() => pairViaChirp()}>589+          <span class="peach-pair-method-icon" aria-hidden="true">590+            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">591+              <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />592+              <path d="M14 7.5 Q15.6 10 14 12.5" />593+              <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />594+            </svg>595+          </span>596+          <span class="peach-pair-method-text">597+            <span class="peach-pair-method-title">Same room</span>598+            <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>599+          </span>574600         </button>601+        <button class="peach-pair-method" onClick={() => pairViaDesktop()}>602+          <span class="peach-pair-method-icon" aria-hidden="true">603+            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">604+              <rect x="2" y="4" width="16" height="10" rx="1.4" />605+              <path d="M7 17 L13 17" />606+              <path d="M9 14 L9 17 M11 14 L11 17" />607+            </svg>608+          </span>609+          <span class="peach-pair-method-text">610+            <span class="peach-pair-method-title">Enter a code</span>611+            <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>612+          </span>613+        </button>575614       </div>576615 577616     </div>@@ -606,60 +645,12 @@606645     }607646   }608647 609-  // Method-select — the first screen, before any birds. Two paths: a same-room610-  // audio chirp, or a typed code from the peach-sync desktop helper.611-  const methodSelectView = () => (612-    <div class="peach-chirp-pair peach-desktop-pair" data-phase="method" role="region" aria-label="Choose a pairing method">613-      <header class="peach-chirp-head">614-        <button class="peach-chirp-back" aria-label="Back" onClick={() => setMode("rest")}>{backArrow}</button>615-        <div class="peach-chirp-title">Pair a device</div>616-        <div class="peach-chirp-head-spacer" />617-      </header>618-      <div class="peach-chirp-copy-host">619-        <div class="peach-chirp-copy">620-          <div class="peach-chirp-headline">How do you want to pair?</div>621-        </div>622-      </div>623-      <div class="peach-pair-method-list">624-        <button class="peach-pair-method" onClick={() => startChirpFlow()}>625-          <span class="peach-pair-method-icon" aria-hidden="true">626-            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">627-              <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />628-              <path d="M14 7.5 Q15.6 10 14 12.5" />629-              <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />630-            </svg>631-          </span>632-          <span class="peach-pair-method-text">633-            <span class="peach-pair-method-title">Same room</span>634-            <span class="peach-pair-method-sub">Chirp — plays an inaudible sound</span>635-          </span>636-        </button>637-        <button class="peach-pair-method" onClick={() => startDesktopFlow()}>638-          <span class="peach-pair-method-icon" aria-hidden="true">639-            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">640-              <rect x="2" y="4" width="16" height="10" rx="1.4" />641-              <path d="M7 17 L13 17" />642-              <path d="M9 14 L9 17 M11 14 L11 17" />643-            </svg>644-          </span>645-          <span class="peach-pair-method-text">646-            <span class="peach-pair-method-title">Desktop helper</span>647-            <span class="peach-pair-method-sub">Enter the code shown by peach-sync</span>648-          </span>649-        </button>650-      </div>651-      <div class="peach-chirp-action">652-        <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>653-      </div>654-    </div>655-  );656-657648   // Desktop helper code pairing — birds animate on every screen below.658649   const desktopView = () => (659650     <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">660651       <header class="peach-chirp-head">661-        <button class="peach-chirp-back" aria-label="Back" onClick={() => setPairKind(null)}>{backArrow}</button>662-        <div class="peach-chirp-title">Desktop Helper</div>652+        <button class="peach-chirp-back" aria-label="Back" onClick={() => { setMode("rest"); setPairKind(null); }}>{backArrow}</button>653+        <div class="peach-chirp-title">Enter a code</div>663654         <div class="peach-chirp-head-spacer" />664655       </header>665656 @@ -687,7 +678,7 @@687678             onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}688679           />689680           <div class="peach-chirp-action">690-            <button class="peach-btn peach-btn-ghost" onClick={() => setPairKind(null)}>Cancel</button>681+            <button class="peach-btn peach-btn-ghost" onClick={() => { setMode("rest"); setPairKind(null); }}>Cancel</button>691682             <button692683               class="peach-btn peach-btn-primary peach-desktop-connect"693684               disabled={!desktopCodeReady() || desktopBusy()}@@ -804,13 +795,11 @@804795     </Show>805796   );806797 807-  // Pairing surface dispatch: method-select first (no birds yet), then either808-  // the chirp flow or the desktop-helper flow depending on the chosen method.798+  // Pairing surface dispatch: the user already chose chirp vs. code on the rest799+  // screen (or the sync card), so there's no method-select step.809800   const pairingView = () => (810-    <Show when={pairKind() !== null} fallback={methodSelectView()}>811-      <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>812-        {desktopView()}813-      </Show>801+    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>802+      {desktopView()}814803     </Show>815804   );816805 

view file

src/popup/views/Settings.tsx+18 −8
@@ -60,7 +60,7 @@6060   onExport: () => void;6161   onBreachCheckDone: () => void;6262   onOpenHealth?: () => void;63-  onOpenSyncPairing?: () => void;63+  onOpenSyncPairing?: (method?: "chirp" | "desktop") => void;6464   onOpenFileImport?: () => void;6565   onOpenPanic?: () => void;6666   onOpenCodexGenerate?: () => void;@@ -1485,15 +1485,25 @@14851485             <div class="peach-settings-row-main">14861486               <span>Pair new device</span>14871487               <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1488-                Add a phone, another browser, or your desktop sync helper.1488+                A nearby phone or browser by chirp, or your desktop helper by code.14891489               </span>14901490             </div>1491-            <button1492-              class="peach-btn peach-btn--subtle peach-btn--sm"1493-              onClick={() => props.onOpenSyncPairing?.()}1494-            >1495-              pair1496-            </button>1491+            <div style="display:flex;gap:6px;flex-shrink:0">1492+              <button1493+                class="peach-btn peach-btn--subtle peach-btn--sm"1494+                disabled={!isPro()}1495+                onClick={() => props.onOpenSyncPairing?.("chirp")}1496+              >1497+                chirp1498+              </button>1499+              <button1500+                class="peach-btn peach-btn--subtle peach-btn--sm"1501+                disabled={!isPro()}1502+                onClick={() => props.onOpenSyncPairing?.("desktop")}1503+              >1504+                code1505+              </button>1506+            </div>14971507           </div>14981508           {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}14991509           <Show when={pendingVouched().length > 0}>

view file