ccb6a770acb0
fix: Let vault presence direct pairing (v4.3.3)
- time
- 2026-07-27 21:27 UTC · 3 months ago
- machine
- koh2
- parent
- 3b8b0dd8f317
↓ peach-browser-ccb6a770.tar.gzatlas at this save
files changed10 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 −62manifest.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": {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",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",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 });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 () => {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 }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();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>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(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.