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
↓ peach-browser-b97a6418.tar.gzatlas at this save
files changed11 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 −8manifest.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": {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",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",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);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;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();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>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;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 }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 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}>