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
8 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 −6
manifest.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": {

view file

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

view file

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

view file

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;

view file

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);

view file

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}

view file

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

view file

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 

view file