25d58a4fe204
surface the relay choice on the invitation screen as a quiet change link that rebuilds the code, and say plainly on the adopt screen that the scanned code carries the relay rather than offering a choice it would overwrite (v4.2.4)
- time
- 2026-07-26 16:53 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- sync settings ux and relay surfacing
- parent
- 2fdc9c8169ea
↓ peach-browser-25d58a4f.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/SyncModeSelector.tsx+9 −1src/popup/views/AdoptVaultView.test.tsx+6 −1src/popup/views/AdoptVaultView.tsx+8 −1src/popup/views/PairDevice.test.tsx+52 −0src/popup/views/PairDevice.tsx+27 −6manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.2.3",4+ "version": "4.2.4",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.2.3",4+ "version": "4.2.4",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.2.3",3+ "version": "4.2.4",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/components/SyncModeSelector.tsx+9 −1
@@ -34,6 +34,9 @@3434 compact?: boolean;3535 /** Fires on every committed change, so a host mid-pairing can react. */3636 onChange?(settings: SyncSettingsSnapshot): void;37+ /** Compact only: the expanded selector is taller than the popup, so the host38+ * needs to know when to fold away whatever it was showing. */39+ onExpand?(expanded: boolean): void;3740 }3841 3942 export const SyncModeSelector: Component<SyncModeSelectorProps> = (props) => {@@ -42,7 +45,12 @@4245 const [draftUrl, setDraftUrl] = createSignal("");4346 const [saving, setSaving] = createSignal(false);4447 const [status, setStatus] = createSignal<string | null>(null);45- const [expanded, setExpanded] = createSignal(false);48+ const [expanded, setExpandedSignal] = createSignal(false);49+50+ function setExpanded(next: boolean) {51+ setExpandedSignal(next);52+ props.onExpand?.(next);53+ }4654 4755 /** Private chosen, no URL yet — never silently fall back to the public relay. */4856 const needsRelayUrl = () => mode() === "private" && relayUrl() === DEFAULT_RELAY_URL;src/popup/views/AdoptVaultView.test.tsx+6 −1
@@ -104,7 +104,12 @@104104 button(view.container, "Continue").click();105105 await flushView();106106 107- expect(view.container.textContent).toContain("Connecting via secure relay");107+ // No relay selector here: the code carries the relay, so this screen states108+ // it rather than offering a choice it would overwrite109+ // (docs/sync-relay-surfacing.md).110+ expect(view.container.textContent).toContain("relay carried in the code you scan");111+ expect(view.container.querySelector(".peach-sync-mode")).toBeNull();112+ expect(view.container.querySelector(".peach-sync-mode-summary")).toBeNull();108113 button(view.container, "Enter invitation manually").click();109114 await flushView();110115 expect(button(view.container, "Connect securely").disabled).toBe(true);src/popup/views/AdoptVaultView.tsx+8 −1
@@ -9,6 +9,7 @@99 import { sendMessage } from "../App";1010 import { RuneBackground } from "../components/RuneBackground";1111 import { parsePairingInvitation } from "../../shared/pairing-invitation";12+import { RELAY_DISCLOSURE } from "../components/SyncModeSelector";1213 import type { PairingQrScannerHandle } from "../pairing-qr-scanner";1314 1415 type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";@@ -242,7 +243,13 @@242243 <span />243244 </div>244245 <h1>Pair with your other device.</h1>245- <p>Connecting via secure relay — works anywhere.</p>246+ {/* No relay selector here, deliberately. The code carries the247+ relay, and joining a vault means joining its mesh — a choice248+ made on this screen would be overwritten by the code seconds249+ later. Say which relay is in use instead of pretending to ask250+ (docs/sync-relay-surfacing.md). */}251+ <p>This device joins on the relay carried in the code you scan.</p>252+ <p class="peach-hint">{RELAY_DISCLOSURE}</p>246253 <Show when={scanning()}>247254 <video248255 ref={videoEl}src/popup/views/PairDevice.test.tsx+52 −0
@@ -42,6 +42,12 @@4242 if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {4343 return Promise.resolve({ ok: true, data: { valid: true } });4444 }45+ if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {46+ return Promise.resolve({47+ ok: true,48+ data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },49+ });50+ }4551 return Promise.resolve({ ok: true });4652 });4753 (globalThis as unknown as { chrome: unknown }).chrome = {@@ -87,6 +93,52 @@8793 expect(container.querySelector("#pairing-invitation-manual")).toBeNull();8894 expect(container.textContent).not.toContain("or copy the invitation");8995 expect(container.textContent).toContain("single-use token");96+ });97+98+ // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.99+ it("names the relay in force without making it a decision", async () => {100+ const { container } = mount();101+ await vi.waitFor(() => {102+ expect(container.textContent).toContain("Scan this in Peach");103+ });104+105+ const summary = container.querySelector(".peach-sync-mode-summary");106+ expect(summary?.textContent).toContain("Using Public relay");107+ // Quiet means quiet: no selector on screen until asked for.108+ expect(container.querySelector(".peach-sync-mode")).toBeNull();109+110+ container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();111+ await vi.waitFor(() => {112+ expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);113+ });114+ // The expanded selector is taller than the popup, so the QR folds away.115+ expect(container.querySelector("svg.peach-pair-qr")).toBeNull();116+ });117+118+ it("rebuilds the invitation when the relay changes, since the code carries it", async () => {119+ const { container } = mount();120+ await vi.waitFor(() => {121+ expect(container.textContent).toContain("Scan this in Peach");122+ });123+ const opened = () =>124+ mockSendMessage.mock.calls.filter(125+ ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",126+ ).length;127+ expect(opened()).toBe(1);128+129+ container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();130+ await vi.waitFor(() => {131+ expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);132+ });133+ const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(134+ (b) => b.textContent?.includes("No sync"),135+ )!;136+ noSync.click();137+138+ await vi.waitFor(() => {139+ expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });140+ expect(opened()).toBe(2);141+ });90142 });91143 92144 it("requires the matching SAS and master password before sharing", async () => {src/popup/views/PairDevice.tsx+27 −6
@@ -24,6 +24,7 @@2424 import { DesktopBird, MobileBird } from "../components/PairingBirds";2525 import { AdoptionConsentModal } from "../components/AdoptionConsentModal";2626 import { PairingQrCode } from "../components/PairingQrCode";27+import { SyncModeSelector } from "../components/SyncModeSelector";2728 2829 export interface PairDeviceViewProps {2930 isPro: boolean;@@ -41,6 +42,7 @@4142 const [sourceSas, setSourceSas] = createSignal("");4243 const [receiverName, setReceiverName] = createSignal("New device");4344 const [shareError, setShareError] = createSignal("");45+ const [changingRelay, setChangingRelay] = createSignal(false);4446 4547 async function startShareFlow(): Promise<void> {4648 setSharePhase("opening");@@ -70,6 +72,12 @@7072 await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);7173 }7274 75+ /** The invitation embeds the relay, so a relay change invalidates it. */76+ async function restartShareFlow(): Promise<void> {77+ await stopShareFlow();78+ await startShareFlow();79+ }80+7381 async function verifySourcePassword(password: string): Promise<boolean> {7482 const response = (await sendMessage({7583 type: "ADOPTION_SOURCE_CONFIRM",@@ -149,6 +157,8 @@149157 return "Unlock Peach first, then try again.";150158 case "sync_disabled":151159 return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";160+ case "private_relay_unset":161+ return "Private relay is selected but has no URL yet. Enter it in Settings → Sync, then try again.";152162 default:153163 return "The secure pairing session ended. Start again with a fresh code.";154164 }@@ -195,12 +205,23 @@195205 196206 <Show when={sharePhase() === "invitation"}>197207 <div class="peach-adoption-source-invitation">198- <div class="peach-pair-headline">Scan this in Peach</div>199- <div class="peach-pair-sub">200- Open Peach on your phone, choose “Scan a code”, and point it here.201- The code carries only the relay address and a single-use token.202- </div>203- <PairingQrCode value={invitation()} size={188} />208+ <Show when={!changingRelay()}>209+ <div class="peach-pair-headline">Scan this in Peach</div>210+ <div class="peach-pair-sub">211+ Open Peach on your phone, choose “Scan a code”, and point it here.212+ The code carries only the relay address and a single-use token.213+ </div>214+ <PairingQrCode value={invitation()} size={188} />215+ </Show>216+ {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs217+ here because a relay is about to be used, but it is not a step —218+ pairing already works on the default. Changing it rebuilds the219+ invitation, which carries the relay address. */}220+ <SyncModeSelector221+ compact222+ onExpand={setChangingRelay}223+ onChange={() => void restartShareFlow()}224+ />204225 </div>205226 </Show>206227