90d6ab2a5e88
feat: Discover-and-tap pairing flow via mDNS (v2.13.30)
- time
- 2026-04-30 15:17 UTC · 6 months ago
- machine
- koh2
- parent
- 51b129be8db6
↓ peach-browser-90d6ab2a.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+28 −0src/background/messages.ts+104 −0src/popup/styles/popup.css+44 −0src/popup/views/SyncPairing.tsx+98 −129src/shared/sync-types.ts+16 −0src/shared/types.ts+3 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.13.29",4+ "version": "2.13.30",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",4- "version": "2.13.29",4+ "version": "2.13.30",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": "2.13.29",3+ "version": "2.13.30",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/messages.test.ts+28 −0
@@ -697,6 +697,34 @@697697 expect(hits[0]!.useCount).toBe(3);698698 });699699 700+ it("START_PEER_DISCOVERY is Pro-gated and rejects free users", async () => {701+ const res = await dispatch({ type: "START_PEER_DISCOVERY" });702+ expect(res.ok).toBe(false);703+ expect((res as { error?: string }).error).toBe("pro_required");704+ });705+706+ it("INITIATE_PAIR_WITH_PEER is Pro-gated and rejects free users", async () => {707+ const res = await dispatch({708+ type: "INITIATE_PAIR_WITH_PEER",709+ payload: {710+ peer: {711+ deviceId: "p1",712+ deviceName: "iPhone",713+ host: "192.168.1.10",714+ port: 44100,715+ x25519PublicKey: "abc",716+ },717+ },718+ });719+ expect(res.ok).toBe(false);720+ expect((res as { error?: string }).error).toBe("pro_required");721+ });722+723+ it("STOP_PEER_DISCOVERY succeeds even when discovery never started", async () => {724+ const res = await dispatch({ type: "STOP_PEER_DISCOVERY" });725+ expect(res.ok).toBe(true);726+ });727+700728 it("DEBUG_INJECT_INCOMING_PAIRING + GET returns the request and 60s expiry window", async () => {701729 const inject = await dispatch({702730 type: "DEBUG_INJECT_INCOMING_PAIRING",src/background/messages.ts+104 −0
@@ -82,6 +82,67 @@8282 return { allowed: false };8383 }8484 85+// ---------------------------------------------------------------------------86+// Peer discovery glue (Phase 7 popup pairing flow).87+//88+// The popup opens discovery via START_PEER_DISCOVERY. We connect to the89+// native sync host, subscribe once, and re-broadcast every PEER_DISCOVERED /90+// PEER_LOST event to the popup as a SYNC_PEER_DISCOVERED / SYNC_PEER_LOST91+// runtime message. The popup is the only consumer; if it closes, STOP is92+// sent on cleanup which tears down subscriptions and the native discovery.93+// ---------------------------------------------------------------------------94+95+let peerDiscoverySubscriptions: Array<() => void> = [];96+97+async function startPeerDiscoveryForPopup(): Promise<void> {98+ await stopPeerDiscoveryForPopup();99+ const sync = await import('./sync');100+ const { getPairedDevices } = await import('./sync-storage');101+ sync.connectSyncHost();102+103+ const unsubDiscovered = sync.onPeerDiscovered((peer) => {104+ void (async () => {105+ const paired = await getPairedDevices();106+ const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);107+ if (Capacitor.isNativePlatform()) return;108+ chrome.runtime109+ .sendMessage({110+ type: "SYNC_PEER_DISCOVERED",111+ peer: {112+ deviceId: peer.deviceId,113+ deviceName: peer.name,114+ host: peer.host,115+ port: peer.port,116+ x25519PublicKey: peer.x25519PublicKey,117+ alreadyPaired,118+ },119+ })120+ .catch(() => {});121+ })();122+ });123+ const unsubLost = sync.onPeerLost((deviceId) => {124+ if (Capacitor.isNativePlatform()) return;125+ chrome.runtime126+ .sendMessage({ type: "SYNC_PEER_LOST", deviceId })127+ .catch(() => {});128+ });129+ peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];130+ sync.discoverPeers();131+}132+133+async function stopPeerDiscoveryForPopup(): Promise<void> {134+ for (const unsub of peerDiscoverySubscriptions) unsub();135+ peerDiscoverySubscriptions = [];136+ try {137+ const sync = await import('./sync');138+ if (sync.isHostConnected()) {139+ await sync.stopDiscovery();140+ }141+ } catch {142+ // best-effort143+ }144+}145+85146 const PRIVATE_IPV4_RANGES = [86147 { prefix: "10." },87148 { prefix: "127." },@@ -642,6 +703,49 @@642703 }643704 }644705 return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };706+ }707+ case "START_PEER_DISCOVERY": {708+ const gate = await requirePro("sync");709+ if (!gate.allowed) return { ok: false, error: "pro_required" };710+ try {711+ await startPeerDiscoveryForPopup();712+ return { ok: true };713+ } catch (err) {714+ return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };715+ }716+ }717+ case "STOP_PEER_DISCOVERY": {718+ try {719+ await stopPeerDiscoveryForPopup();720+ } catch {721+ // best-effort cleanup722+ }723+ return { ok: true };724+ }725+ case "INITIATE_PAIR_WITH_PEER": {726+ const gate = await requirePro("sync");727+ if (!gate.allowed) return { ok: false, error: "pro_required" };728+ const { peer } = msg.payload as {729+ peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };730+ };731+ try {732+ const sync = await import('./sync');733+ const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);734+ // Connection success means the peer accepted the dormant Noise NK735+ // pairing handshake. Trust persistence happens once the user-side736+ // approval banner on the *peer* device confirms — we record local737+ // state here so Settings reflects the new device immediately.738+ const { addPairedDevice } = await import('./sync-storage');739+ await addPairedDevice({740+ deviceId: peer.deviceId,741+ displayName: peer.deviceName,742+ x25519PublicKey: peer.x25519PublicKey,743+ pairedAt: Date.now(),744+ });745+ return { ok: true, data: { connectionId, deviceName: peer.deviceName } };746+ } catch (err) {747+ return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };748+ }645749 }646750 case "DEBUG_INJECT_INCOMING_PAIRING": {647751 // Manual E2E rehearsal hook. Inserts a synthetic incoming pairingsrc/popup/styles/popup.css+44 −0
@@ -4792,6 +4792,50 @@47924792 text-align: center;47934793 }47944794 4795+.peach-pairing-discover {4796+ justify-content: flex-start;4797+ align-items: stretch;4798+ text-align: left;4799+ gap: 12px;4800+}4801+4802+.peach-pairing-discover .peach-pairing-headline {4803+ text-align: center;4804+ margin-bottom: 4px;4805+}4806+4807+.peach-pairing-peer-card {4808+ display: flex;4809+ align-items: center;4810+ justify-content: space-between;4811+ gap: 12px;4812+ padding: 10px 12px;4813+ border: 1px solid var(--peach-t3);4814+ border-radius: 10px;4815+ background: var(--peach-bg-card);4816+}4817+4818+.peach-pairing-peer-info {4819+ display: flex;4820+ flex-direction: column;4821+ gap: 2px;4822+ flex: 1;4823+ min-width: 0;4824+}4825+4826+.peach-pairing-peer-name {4827+ font-size: 13px;4828+ color: var(--peach-t0);4829+ white-space: nowrap;4830+ overflow: hidden;4831+ text-overflow: ellipsis;4832+}4833+4834+.peach-pairing-peer-meta {4835+ font-size: 11px;4836+ color: var(--peach-t2);4837+}4838+47954839 .peach-pairing-headline {47964840 font-family: var(--font-display);47974841 font-style: italic;src/popup/views/SyncPairing.tsx+98 −129
@@ -1,6 +1,6 @@1-import { createSignal, onCleanup, Show } from "solid-js";1+import { createSignal, For, onCleanup, onMount, Show } from "solid-js";22 import { sendMessage } from "../App";3-import type { PairedDevice, PairingStep } from "../../shared/sync-types";3+import type { DiscoveredPeer, PairedDevice, PairingStep } from "../../shared/sync-types";44 55 interface SyncPairingProps {66 mode: "initiate" | "accept";@@ -8,49 +8,6 @@88 onPaired: (device: PairedDevice) => void;99 }1010 11-function generatePairingCode(): string {12- const array = new Uint32Array(1);13- crypto.getRandomValues(array);14- return String(array[0] % 1000000).padStart(6, "0");15-}16-17-function CodeDisplay(props: { code: string }) {18- return (19- <div class="peach-pairing-code-display">20- {props.code.split("").map((digit) => (21- <span class="peach-pairing-code-digit">{digit}</span>22- ))}23- </div>24- );25-}26-27-function CountdownRing(props: { secondsLeft: number }) {28- const radius = 22;29- const circumference = 2 * Math.PI * radius;30- const progress = () => (props.secondsLeft / 30) * circumference;31- return (32- <div class="peach-pairing-countdown-ring">33- <svg width="56" height="56" viewBox="0 0 56 56">34- <circle35- cx="28"36- cy="28"37- r={radius}38- class="peach-pairing-ring-track"39- />40- <circle41- cx="28"42- cy="28"43- r={radius}44- class="peach-pairing-ring-progress"45- stroke-dasharray={String(circumference)}46- stroke-dashoffset={String(circumference - progress())}47- />48- </svg>49- <span class="peach-pairing-countdown-num">{props.secondsLeft}</span>50- </div>51- );52-}53-5411 function Spinner() {5512 return <span class="peach-pairing-spinner" />;5613 }@@ -58,60 +15,77 @@5815 export function SyncPairingView(props: SyncPairingProps) {5916 const [step, setStep] = createSignal<PairingStep>({ stage: "idle" });6017 const [codeInput, setCodeInput] = createSignal("");61- const [secondsLeft, setSecondsLeft] = createSignal(30);6218 const [codeError, setCodeError] = createSignal<string | null>(null);6319 64- let countdownTimer: number | undefined;65- let pairingTimer: number | undefined;20+ // Discover-and-tap state for the initiate flow. The background broadcasts21+ // SYNC_PEER_DISCOVERED / SYNC_PEER_LOST as peers come and go on mDNS;22+ // tapping a peer routes to INITIATE_PAIR_WITH_PEER.23+ const [discoveredPeers, setDiscoveredPeers] = createSignal<DiscoveredPeer[]>([]);24+ const [discoveryActive, setDiscoveryActive] = createSignal(false);25+ const [discoveryError, setDiscoveryError] = createSignal<string | null>(null);26+ const [pairingPeerId, setPairingPeerId] = createSignal<string | null>(null);6627 28+ onMount(() => {29+ if (props.mode !== "initiate") return;30+ void startDiscovery();31+ });32+6733 onCleanup(() => {68- if (countdownTimer !== undefined) window.clearInterval(countdownTimer);69- if (pairingTimer !== undefined) window.clearTimeout(pairingTimer);34+ if (discoveryActive()) void sendMessage({ type: "STOP_PEER_DISCOVERY" });7035 });7136 72- function startCountdown(expiresAt: number) {73- if (countdownTimer !== undefined) window.clearInterval(countdownTimer);74- setSecondsLeft(Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)));75- countdownTimer = window.setInterval(() => {76- const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));77- setSecondsLeft(remaining);78- if (remaining === 0) {79- window.clearInterval(countdownTimer);80- setStep({ stage: "idle" });81- void sendMessage({ type: "CANCEL_PAIRING" });82- }83- }, 500);37+ function handleRuntimeMessage(msg: unknown) {38+ const m = msg as { type?: string; peer?: DiscoveredPeer; deviceId?: string };39+ if (m?.type === "SYNC_PEER_DISCOVERED" && m.peer) {40+ const peer = m.peer;41+ setDiscoveredPeers((prev) => {42+ if (prev.some((p) => p.deviceId === peer.deviceId)) return prev;43+ return [...prev, peer];44+ });45+ }46+ if (m?.type === "SYNC_PEER_LOST" && m.deviceId) {47+ const id = m.deviceId;48+ setDiscoveredPeers((prev) => prev.filter((p) => p.deviceId !== id));49+ }8450 }8551 86- async function handleGenerateCode() {87- const code = generatePairingCode();88- const expiresAt = Date.now() + 30_000;89- setStep({ stage: "generating_code", code, expiresAt });90- startCountdown(expiresAt);52+ async function startDiscovery() {53+ chrome.runtime.onMessage.addListener(handleRuntimeMessage);54+ onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));9155 56+ const res = await sendMessage({ type: "START_PEER_DISCOVERY" });57+ const response = res as { ok: boolean; error?: string };58+ if (response.ok) {59+ setDiscoveryActive(true);60+ setDiscoveryError(null);61+ } else {62+ setDiscoveryError(response.error ?? "discovery_failed");63+ }64+ }65+66+ async function handlePairWithPeer(peer: DiscoveredPeer) {67+ setPairingPeerId(peer.deviceId);68+ setStep({ stage: "connecting", peerName: peer.deviceName });9269 const res = await sendMessage({93- type: "START_PAIRING",94- payload: { code, expiresAt },70+ type: "INITIATE_PAIR_WITH_PEER",71+ payload: { peer },9572 });96- if (!(res as { ok: boolean }).ok) {97- setStep({ stage: "error", message: "Failed to start pairing" });73+ const response = res as { ok: boolean; error?: string };74+ setPairingPeerId(null);75+ if (!response.ok) {76+ setStep({ stage: "error", message: response.error ?? "pair_failed" });9877 return;9978 }100- setStep({ stage: "waiting_for_peer" });101-102- pairingTimer = window.setTimeout(() => {103- setStep({ stage: "idle" });104- void sendMessage({ type: "CANCEL_PAIRING" });105- }, 30_000);79+ const paired: PairedDevice = {80+ deviceId: peer.deviceId,81+ displayName: peer.deviceName,82+ x25519PublicKey: peer.x25519PublicKey,83+ pairedAt: Date.now(),84+ };85+ setStep({ stage: "done", device: paired });86+ props.onPaired(paired);10687 }10788 108- async function handleCancel() {109- if (countdownTimer !== undefined) window.clearInterval(countdownTimer);110- if (pairingTimer !== undefined) window.clearTimeout(pairingTimer);111- setStep({ stage: "idle" });112- await sendMessage({ type: "CANCEL_PAIRING" });113- }114-11589 async function handleConnect() {11690 const code = codeInput().replace(/\D/g, "").slice(0, 6);11791 if (code.length !== 6) {@@ -163,52 +137,47 @@163137 </div>164138 165139 <div class="peach-pairing-body">166- <Show when={props.mode === "initiate"}>167- <Show when={step().stage === "idle"}>168- <div class="peach-pairing-content">169- <div class="peach-pairing-hero">170- <svg width="40" height="40" viewBox="0 0 40 40" fill="none" class="peach-pairing-icon">171- <circle cx="20" cy="20" r="18" stroke="var(--peach-accent)" stroke-width="1.5" stroke-dasharray="4 3" />172- <path d="M14 20h12M20 14v12" stroke="var(--peach-accent)" stroke-width="1.5" stroke-linecap="round" />173- </svg>174- </div>175- <p class="peach-pairing-headline">Pair a new device</p>176- <p class="peach-pairing-subline">177- Generate a code and wait for another device to connect.140+ <Show when={props.mode === "initiate" && step().stage !== "done" && step().stage !== "error" && step().stage !== "connecting" && step().stage !== "verifying"}>141+ <div class="peach-pairing-content peach-pairing-discover">142+ <p class="peach-pairing-headline">Pair a device</p>143+ <Show when={discoveryError()}>144+ <p class="peach-pairing-error">145+ {discoveryError() === "pro_required"146+ ? "Sync requires Peach Pro."147+ : "Native sync host is not installed yet."}178148 </p>179- <button180- class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"181- onClick={() => void handleGenerateCode()}182- >183- Generate pairing code184- </button>185- </div>186- </Show>187-188- <Show when={step().stage === "generating_code" || step().stage === "waiting_for_peer"}>189- <div class="peach-pairing-content">190- <Show when={step().stage === "generating_code"}>191- <CodeDisplay code={(step() as { stage: "generating_code"; code: string }).code} />192- <CountdownRing secondsLeft={secondsLeft()} />193- </Show>194- <Show when={step().stage === "waiting_for_peer"}>195- <CodeDisplay code="------" />196- <p class="peach-pairing-status">197- <Spinner />198- Waiting for peer...199- </p>200- <p class="peach-pairing-subline peach-pairing-subline--center">201- Make sure the other device is searching for peers.202- </p>203- </Show>204- <button205- class="peach-btn peach-btn--ghost peach-btn--md"206- onClick={() => void handleCancel()}207- >208- Cancel209- </button>210- </div>211- </Show>149+ </Show>150+ <Show when={!discoveryError() && discoveredPeers().length === 0}>151+ <p class="peach-pairing-status">152+ <Spinner />153+ Searching for Peach on your local network...154+ </p>155+ </Show>156+ <For each={discoveredPeers()}>157+ {(peer) => (158+ <div class="peach-pairing-peer-card">159+ <div class="peach-pairing-peer-info">160+ <span class="peach-pairing-peer-name">{peer.deviceName}</span>161+ <span class="peach-pairing-peer-meta">162+ {peer.alreadyPaired ? "Already paired ✓" : "Found on network"}163+ </span>164+ </div>165+ <Show when={!peer.alreadyPaired}>166+ <button167+ class="peach-btn peach-btn--accent peach-btn--sm"168+ onClick={() => void handlePairWithPeer(peer)}169+ disabled={pairingPeerId() !== null}170+ >171+ {pairingPeerId() === peer.deviceId ? "..." : "Pair →"}172+ </button>173+ </Show>174+ </div>175+ )}176+ </For>177+ <p class="peach-pairing-subline peach-pairing-subline--center">178+ Make sure both devices are on the same Wi-Fi network.179+ </p>180+ </div>212181 </Show>213182 214183 <Show when={props.mode === "accept"}>src/shared/sync-types.ts+16 −0
@@ -86,3 +86,19 @@8686 receivedAt: number;8787 expiresAt: number;8888 }89+90+/**91+ * A peer the native sync host has seen on mDNS. The popup pairing flow lists92+ * these and routes a tap to `INITIATE_PAIR_WITH_PEER` with the same shape.93+ *94+ * `alreadyPaired` is computed against `getPairedDevices()` at broadcast time95+ * so the popup can render "Already paired ✓" without a second round-trip.96+ */97+export interface DiscoveredPeer {98+ deviceId: string;99+ deviceName: string;100+ host: string;101+ port: number;102+ x25519PublicKey: string;103+ alreadyPaired: boolean;104+}src/shared/types.ts+3 −0
@@ -250,6 +250,9 @@250250 | "GET_INCOMING_PAIRING_REQUEST"251251 | "RESPOND_INCOMING_PAIRING"252252 | "DEBUG_INJECT_INCOMING_PAIRING"253+ | "START_PEER_DISCOVERY"254+ | "STOP_PEER_DISCOVERY"255+ | "INITIATE_PAIR_WITH_PEER"253256 | "GET_PENDING_PASSKEY"254257 | "APPROVE_PASSKEY"255258 | "DENY_PASSKEY"