ccb6a770acb0

fix: Let vault presence direct pairing (v4.3.3)

time
2026-07-27 21:27 UTC · 3 months ago
machine
koh2
parent
3b8b0dd8f317
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/SyncModeSelector.test.tsx+2 −2src/popup/components/SyncModeSelector.tsx+2 −2src/popup/styles/popup.css+0 −16src/popup/views/AdoptVaultView.test.tsx+2 −1src/popup/views/AdoptVaultView.tsx+9 −7src/popup/views/PairDevice.test.tsx+8 −30src/popup/views/PairDevice.tsx+12 −62
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.2",4+  "version": "4.3.3",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.3.2",4+  "version": "4.3.3",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.3.2",3+  "version": "4.3.3",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/components/SyncModeSelector.test.tsx+2 −2
@@ -83,8 +83,8 @@8383     await flushView();8484     const field = container.querySelector<HTMLInputElement>("#sync-relay-url");8585     expect(field).not.toBeNull();86-    // The public relay must not be pre-filled as if it were the user's own.87-    expect(field!.value).toBe("");86+    // Only the required secure scheme is pre-filled, never Peach's public host.87+    expect(field!.value).toBe("wss://");8888     const warn = container.querySelector(".peach-sync-mode-warn");8989     expect(warn?.textContent).toContain("Sync is paused until you enter your relay URL");9090   });

view file

src/popup/components/SyncModeSelector.tsx+2 −2
@@ -42,7 +42,7 @@4242 export const SyncModeSelector: Component<SyncModeSelectorProps> = (props) => {4343   const [mode, setMode] = createSignal<SyncMode>("public");4444   const [relayUrl, setRelayUrl] = createSignal("");45-  const [draftUrl, setDraftUrl] = createSignal("");45+  const [draftUrl, setDraftUrl] = createSignal("wss://");4646   const [saving, setSaving] = createSignal(false);4747   const [status, setStatus] = createSignal<string | null>(null);4848   const [expanded, setExpandedSignal] = createSignal(false);@@ -58,7 +58,7 @@5858   function apply(next: SyncSettingsSnapshot) {5959     setMode(next.mode);6060     setRelayUrl(next.relayUrl);61-    setDraftUrl(next.relayUrl === DEFAULT_RELAY_URL ? "" : next.relayUrl);61+    setDraftUrl(next.relayUrl === DEFAULT_RELAY_URL ? "wss://" : next.relayUrl);6262   }6363 6464   onMount(async () => {

view file

src/popup/styles/popup.css+0 −16
@@ -7048,22 +7048,6 @@70487048   background: #fff;70497049 }70507050 7051-/* The same invitation the QR carries, as text — the fallback when a camera7052-   won't cooperate. Monospace and full width so it can be read out loud. */7053-.peach-pair-invitation-text {7054-  width: 100%;7055-  resize: none;7056-  padding: 8px 10px;7057-  border-radius: 10px;7058-  border: 1px solid var(--peach-border);7059-  background: color-mix(in srgb, var(--peach-t0) 3%, transparent);7060-  color: var(--peach-t1);7061-  font-family: var(--peach-mono, ui-monospace, SFMono-Regular, Menlo, monospace);7062-  font-size: 10px;7063-  line-height: 1.5;7064-  word-break: break-all;7065-}7066-70677051 .peach-adopt-card > .peach-btn + .peach-btn {70687052   margin-top: 8px;70697053 }

view file

src/popup/views/AdoptVaultView.test.tsx+2 −1
@@ -109,7 +109,8 @@109109     // No relay selector here: the code carries the relay, so this screen states110110     // it rather than offering a choice it would overwrite111111     // (docs/sync-relay-surfacing.md).112-    expect(view.container.textContent).toContain("relay carried in the code you scan");112+    expect(view.container.textContent).toContain("This extension is empty");113+    expect(view.container.textContent).toContain("Public or Private relay");113114     expect(view.container.querySelector(".peach-sync-mode")).toBeNull();114115     expect(view.container.querySelector(".peach-sync-mode-summary")).toBeNull();115116     button(view.container, "Enter invitation manually").click();

view file

src/popup/views/AdoptVaultView.tsx+9 −7
@@ -243,12 +243,14 @@243243               <span />244244             </div>245245             <h1>Pair with your other device.</h1>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>246+            {/* The populated source owns mesh configuration. Its invitation247+                carries either Peach's public relay or a self-hosted relay, so248+                an independent receiver choice would be overwritten. */}249+            <p>250+              This extension is empty. The device with your vault chooses251+              Public or Private relay, and this extension joins the relay252+              carried in its QR.253+            </p>252254             <p class="peach-hint">{RELAY_DISCLOSURE}</p>253255             <Show when={scanning()}>254256               <video@@ -269,7 +271,7 @@269271               class="peach-btn peach-btn--ghost peach-btn--sm"270272               onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}271273             >272-              {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}274+              {manualEntry() ? "Hide manual entry" : "Can't scan? Enter invitation manually"}273275             </button>274276             <Show when={manualEntry()}>275277               <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>

view file

src/popup/views/PairDevice.test.tsx+8 −30
@@ -75,49 +75,26 @@7575     return rendered;7676   }7777 78-  it("opens the relay room before rendering its QR, and offers no manual copy", async () => {78+  it("opens the relay room before rendering one source QR", async () => {7979     const { container } = mount();8080     await vi.waitFor(() => {81-      expect(container.textContent).toContain("Scan this in Peach");81+      expect(container.textContent).toContain("This extension has the vault");8282     });8383 8484     expect(mockSendMessage).toHaveBeenCalledWith({8585       type: "START_ADOPTION_SOURCE",8686     });8787     expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();88-    // The QR is the default — the text form is behind an explicit fallback.8988     expect(container.querySelector("#pairing-invitation-manual")).toBeNull();89+    expect(container.textContent).not.toContain("Show a code instead");90+    expect(container.textContent).not.toContain("peach-pair-v1:");9091   });9192 92-  it("offers the same invitation as text when a camera won't cooperate", async () => {93-    const { container } = mount();94-    await vi.waitFor(() => {95-      expect(container.textContent).toContain("Scan this in Peach");96-    });97-98-    const toggle = () =>99-      [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) =>100-        /Show a code instead|Show the QR instead/.test(b.textContent ?? ""),101-      )!;102-    toggle().click();103-104-    const text = container.querySelector<HTMLTextAreaElement>("#pairing-invitation-manual");105-    expect(text?.value).toBe(SOURCE_INVITATION);106-    // What the code contains is disclosed where the user can see the string.107-    expect(container.textContent).toContain("single-use token");108-    // One invitation, two renderings — never two live codes on screen.109-    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();110-111-    toggle().click();112-    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();113-    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();114-  });115-11693   // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.11794   it("names the relay in force without making it a decision", async () => {11895     const { container } = mount();11996     await vi.waitFor(() => {120-      expect(container.textContent).toContain("Scan this in Peach");97+      expect(container.textContent).toContain("This extension has the vault");12198     });12299 123100     const summary = container.querySelector(".peach-sync-mode-summary");@@ -136,7 +113,7 @@136113   it("rebuilds the invitation when the relay changes, since the code carries it", async () => {137114     const { container } = mount();138115     await vi.waitFor(() => {139-      expect(container.textContent).toContain("Scan this in Peach");116+      expect(container.textContent).toContain("This extension has the vault");140117     });141118     const opened = () =>142119       mockSendMessage.mock.calls.filter(@@ -162,7 +139,7 @@162139   it("shows the SAS and waits for the phone's single confirmation", async () => {163140     const { container } = mount();164141     await vi.waitFor(() => {165-      expect(container.textContent).toContain("Scan this in Peach");142+      expect(container.textContent).toContain("This extension has the vault");166143     });167144 168145     pushFromBackground("ADOPTION_SOURCE_SAS", {@@ -171,6 +148,7 @@171148     });172149     expect(container.textContent).toContain("June's phone");173150     expect(container.textContent).toContain("48219371");151+    expect(container.textContent).toContain("Compare the 8-digit safety code");174152     expect(container.textContent).toContain("tap “They match” there");175153     expect(container.querySelector("#adoption-consent-password")).toBeNull();176154     expect(mockSendMessage).not.toHaveBeenCalledWith(

view file

src/popup/views/PairDevice.tsx+12 −62
@@ -4,13 +4,11 @@44 // view is purely the pairing flow, returning to Settings (`onBack`) when done55 // or cancelled.66 //7-// There is one path because there can only be one: a browser extension cannot8-// listen and has no camera it can point at anything, so it is always the side9-// that *displays* an invitation for another device to scan. The invitation can10-// be shown as a QR or as its text, which are the same string — the text is the11-// fallback when a camera won't cooperate, not a second pairing mode. LAN12-// pairing codes are retired along with LAN sync13-// (docs/sync-lan-retirement.md), so there is nothing to type in here.7+// A populated extension is the source, so this screen displays one QR for the8+// empty device to scan. A fresh extension takes the complementary receiver path9+// in AdoptVaultView, where camera/manual invitation fallback belongs. Keeping10+// the raw invitation off this source screen prevents it from being mistaken for11+// the later 8-digit safety code.1412 //1513 // The pairing itself runs in the background (relay-adoption-source-bg.ts) and1614 // reaches this view via the ADOPTION_SOURCE_* messages.@@ -44,15 +42,12 @@4442   const [receiverName, setReceiverName] = createSignal("New device");4543   const [shareError, setShareError] = createSignal("");4644   const [changingRelay, setChangingRelay] = createSignal(false);47-  const [showTextCode, setShowTextCode] = createSignal(false);48-  const [copied, setCopied] = createSignal(false);4945 5046   async function startShareFlow(): Promise<void> {5147     setSharePhase("opening");5248     setShareError("");5349     setInvitation("");5450     setSourceSas("");55-    setCopied(false);5651     try {5752       const response = (await sendMessage({5853         type: "START_ADOPTION_SOURCE",@@ -163,7 +158,7 @@163158   function liveText(): string {164159     switch (sharePhase()) {165160       case "opening": return "Opening a secure pairing room";166-      case "invitation": return "Scan this code on your other device";161+      case "invitation": return "Scan this QR on the empty device";167162       case "sas": return "Confirm the code matches";168163       case "paired": return "Device added";169164       case "error": return "Could not add the device";@@ -201,57 +196,12 @@201196       <Show when={sharePhase() === "invitation"}>202197         <div class="peach-adoption-source-invitation">203198           <Show when={!changingRelay()}>204-            <div class="peach-pair-headline">205-              {showTextCode() ? "Enter this in Peach" : "Scan this in Peach"}199+            <div class="peach-pair-headline">Pair a device</div>200+            <div class="peach-pair-sub">201+              This extension has the vault. Scan this QR on the empty device,202+              then compare the 8-digit safety code.206203             </div>207-            {/* The QR screen is the tallest in the flow and the popup does not208-                scroll, so what the code contains is said on the text view —209-                where the user is looking straight at the string — rather than210-                pushing the QR below the fold to say it twice. */}211-            <Show212-              when={showTextCode()}213-              fallback={214-                <>215-                  <div class="peach-pair-sub">216-                    Open Peach on your phone, choose “Scan a code”, and point it here.217-                  </div>218-                  <PairingQrCode value={invitation()} size={176} />219-                </>220-              }221-            >222-              {/* Fallback for a camera that won't focus, a phone with no223-                  camera permission, or pairing two windows on one machine. */}224-              <div class="peach-pair-sub">225-                Type or paste this into Peach on your other device. The whole226-                code is a relay address and a single-use token.227-              </div>228-              <textarea229-                id="pairing-invitation-manual"230-                class="peach-pair-invitation-text"231-                readonly232-                rows={4}233-                aria-label="Pairing invitation"234-                onFocus={(event) => (event.target as HTMLTextAreaElement).select()}235-              >{invitation()}</textarea>236-            </Show>237-            <button238-              class="peach-btn peach-btn--ghost peach-btn--sm"239-              onClick={() => {240-                setCopied(false);241-                setShowTextCode(!showTextCode());242-              }}243-            >244-              {showTextCode() ? "Show the QR instead" : "Show a code instead"}245-            </button>246-            <Show when={showTextCode()}>247-              <button248-                class="peach-btn peach-btn--subtle peach-btn--sm"249-                onClick={() => {250-                  void navigator.clipboard?.writeText(invitation()).catch(() => undefined);251-                  setCopied(true);252-                }}253-              >{copied() ? "copied" : "copy"}</button>254-            </Show>204+            <PairingQrCode value={invitation()} size={176} />255205           </Show>256206           {/* Moment 1 (docs/sync-relay-surfacing.md): the relay choice belongs257207               here because a relay is about to be used, but it is not a step —@@ -288,7 +238,7 @@288238 289239       <Show when={sharePhase() === "sas"}>290240         <div class="peach-desktop-searching">291-          <div class="peach-pair-headline">Confirm this code on your phone</div>241+          <div class="peach-pair-headline">Compare the 8-digit safety code</div>292242           <div class="peach-pair-sub">293243             If {receiverName()} shows the same code, tap “They match” there. Peach294244             will finish only after both devices save the pairing.

view file