e3e3441ec3be
refactor: delete dead rest/device-list code from ChirpSync (pairing-only view); Settings sync hub shows paired devices with type + revoke (remove) + sync now; bump 4.0.8→4.0.9
- time
- 2026-06-19 02:41 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- consolidate sync into settings
- parent
- aa9010ad3c68
↓ peach-browser-e3e3441e.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+0 −2src/popup/views/ChirpSync.desktop.test.tsx+10 −20src/popup/views/ChirpSync.tsx+26 −305src/popup/views/Settings.tsx+10 −1manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "4.0.8",5+ "version": "4.0.9",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.8",4+ "version": "4.0.9",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.0.8",3+ "version": "4.0.9",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/styles/popup.css+0 −2
@@ -1047,8 +1047,6 @@10471047 .peach-pair-method-list {10481048 display: flex; flex-direction: column; gap: 10px; padding: 4px 18px 8px;10491049 }1050-/* On the rest screen the two options sit at the bottom, like the old CTA did. */1051-.peach-pair-method-list--rest { margin-top: auto; padding-bottom: 12px; }10521050 .peach-pair-method {10531051 display: flex; align-items: center; gap: 12px;10541052 width: 100%; text-align: left; cursor: pointer;src/popup/views/ChirpSync.desktop.test.tsx+10 −20
@@ -57,9 +57,11 @@5757 while (cleanups.length) cleanups.pop()!();5858 });5959 60+ // ChirpSync is pairing-only now; it opens straight into the desktop code flow61+ // (the device list / method choice live on the Settings sync card).6062 function mount(onBack: () => void = () => {}) {6163 const r = renderView(62- <ChirpSyncView isPro={true} onBack={onBack} onLearnPro={() => {}} />,64+ <ChirpSyncView isPro={true} initialPair="desktop" onBack={onBack} onLearnPro={() => {}} />,6365 );6466 cleanups.push(r.dispose);6567 return r;@@ -70,33 +72,21 @@7072 (b) => (b.textContent ?? "").trim() === text,7173 ) as HTMLButtonElement | undefined;7274 }73- function buttonContaining(container: HTMLElement, text: string): HTMLButtonElement | undefined {74- return Array.from(container.querySelectorAll("button")).find(75- (b) => (b.textContent ?? "").includes(text),76- ) as HTMLButtonElement | undefined;77- }7875 function codeInput(container: HTMLElement): HTMLInputElement {7976 return container.querySelector("#desktop-pair-code") as HTMLInputElement;8077 }8178 82- function gotoDesktopEnter(container: HTMLElement): void {83- // No method-select screen — the rest view links straight into code entry.84- buttonContaining(container, "Enter a code")!.click();79+ // initialPair="desktop" lands directly on the code-entry screen.80+ function gotoDesktopEnter(_container: HTMLElement): void {81+ /* no-op: mount() already opens the desktop code flow */8582 }8683 87- it("offers chirp and code as direct options on the rest screen", () => {84+ it("opens straight into the desktop code flow (no method-select screen)", () => {8885 const { container } = mount();89- // The intermediate "how do you want to pair?" screen is gone.9086 expect(container.textContent).not.toContain("How do you want to pair?");91-92- const sameRoom = buttonContaining(container, "Same room")!;93- const code = buttonContaining(container, "Enter a code")!;94- expect(sameRoom).toBeTruthy();95- expect(code).toBeTruthy();96- // Each card carries its own description and a leading icon.97- expect(code.textContent).toContain("Type the code from your desktop sync helper");98- expect(sameRoom.querySelector(".peach-pair-method-icon")).toBeTruthy();99- expect(code.querySelector(".peach-pair-method-icon")).toBeTruthy();87+ // The code input is present immediately, without any intermediate choice.88+ expect(codeInput(container)).toBeTruthy();89+ expect(buttonByText(container, "Connect")).toBeTruthy();10090 });10191 10292 it("renders a 6-digit numeric code input with birds present", () => {src/popup/views/ChirpSync.tsx+26 −305
@@ -1,33 +1,29 @@1-// ChirpSync — the v3 Settings → Sync surface.1+// ChirpSync — the pairing surface, opened from the Settings → Sync card.22 //3-// Three modes the user can be in:3+// Settings owns the sync "hub" (paired-device list, revoke, sync-now); this4+// view is purely the pairing flow, entered straight into a method via5+// `initialPair` and returning to Settings (`onBack`) when done or cancelled:46 //5-// 1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6-// to the License section.7-// 2. Idle — list of paired devices + "Pair a device" hero button. Stale8-// devices (>7 days since last sync) show a soft warning indicator;9-// vouched devices (received via device manifest, v2 brief §4.2) show10-// an "Added automatically" badge.11-// 3. Pairing — ChirpPairingFlow takes over the full surface and drives12-// the three-act bird animation in lockstep with the audio protocol.7+// - Locked behind Pro (free user) — soft upsell card, "Learn more" routes8+// to the License section.9+// - Chirp — ChirpPairingFlow takes over the surface and drives the three-act10+// bird animation in lockstep with the audio protocol.11+// - Code — the peach-sync desktop-helper NK flow (enter code → SAS → paired).1312 //14-// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15-// the binding between that orchestrator, the background-message16-// CHIRP_* handlers, and the Settings navigation surrounding it.13+// The chirp orchestrator lives in `popup/chirp-pair.ts`; the desktop NK pairing14+// runs in the background and reaches this view via the PAIR_*/DESKTOP_PAIR_*15+// messages.1716 1817 import {1918 Component,2019 Show,2120 createEffect,22- createMemo,2321 createSignal,24- For,2522 onCleanup,2623 onMount,2724 } from "solid-js";2825 import { sendMessage } from "../../shared/bridge";2926 import type {30- DeviceType,3127 PairedDevice,3228 PairingProgress,3329 } from "../../shared/sync-types";@@ -53,12 +49,8 @@5349 initialPair?: "chirp" | "desktop" | null;5450 }5551 56-const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;57-58-type Mode = "rest" | "pairing";59-60-// Which pairing path the user picked. The two paths are entered directly from61-// the rest screen (or the Settings card); the birds animate the same way on both.52+// Which pairing path the user picked, passed in via `initialPair`. The birds53+// animate the same way on both paths.6254 type PairKind = "chirp" | "desktop" | null;6355 6456 // Desktop helper (peach-sync) code-pairing phases. The SAS and success@@ -107,15 +99,7 @@10799 }108100 109101 export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {110- const [devices, setDevices] = createSignal<PairedDevice[]>([]);111- // Opened from the Settings sync card straight into a pairing path, so start in112- // "pairing" when a method was passed (avoids a flash of the fallback screen).113- const [mode, setMode] = createSignal<Mode>(props.initialPair ? "pairing" : "rest");114102 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });115- const [error, setError] = createSignal<string | null>(null);116- const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);117- const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");118- const [syncFlash, setSyncFlash] = createSignal<string | null>(null);119103 // Out-of-band pairing verification: the 6-digit code both devices derive120104 // from the shared handshake; the user confirms it matches before the pair121105 // is persisted (audit HIGH — pairing initiator authentication).@@ -142,19 +126,6 @@142126 143127 const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());144128 145- // Two direct entry points — no intermediate "how do you want to pair?" screen.146- function pairViaChirp(): void {147- setError(null);148- setMode("pairing");149- startChirpFlow();150- }151-152- function pairViaDesktop(): void {153- setError(null);154- setMode("pairing");155- startDesktopFlow();156- }157-158129 function startDesktopFlow(): void {159130 setDesktopCode("");160131 setDesktopSas(null);@@ -219,50 +190,13 @@219190 onCleanup(() => clearTimeout(t));220191 });221192 222- async function handleSyncNow(): Promise<void> {223- setSyncStatus("syncing");224- setSyncFlash(null);225- const res = await sendMessage({226- type: "TRIGGER_SYNC",227- payload: { reason: "manual", force: true },228- }) as { ok: boolean; error?: string };229- // Poll briefly for the new state — the dispatcher broadcasts but the230- // popup doesn't yet listen, so a short re-fetch covers it.231- setTimeout(async () => {232- const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {233- ok: boolean;234- data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };235- };236- const s = state.data;237- if (!s) return;238- setSyncStatus(s.status);239- if (s.status === "idle") {240- setSyncFlash(241- (s.entriesReceived ?? 0) > 0242- ? `Synced — ${s.entriesReceived} entries updated`243- : "Vault up to date",244- );245- setTimeout(() => setSyncFlash(null), 3000);246- await refresh();247- } else if (s.status === "error") {248- setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);249- setTimeout(() => setSyncFlash(null), 4000);250- }251- }, 250);252- if (!res.ok) {253- setSyncStatus("error");254- setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);255- setTimeout(() => setSyncFlash(null), 4000);256- }257- }258-259193 onMount(() => {260- void refresh();194+ // Opened straight into a pairing path from the Settings sync card. With no195+ // method there's nothing to do here — return to Settings (the hub).196+ if (props.initialPair === "chirp") startChirpFlow();197+ else if (props.initialPair === "desktop") startDesktopFlow();198+ else props.onBack();261199 262- // Deep-link straight into a pairing path when opened from the sync card.263- if (props.initialPair === "chirp") pairViaChirp();264- else if (props.initialPair === "desktop") pairViaDesktop();265-266200 // Desktop pairing pushes from the background (bg → popup). The whole NK267201 // pairing runs in the service worker; the SAS to confirm and the terminal268202 // result arrive here as runtime messages.@@ -286,16 +220,7 @@286220 }287221 });288222 289- async function refresh(): Promise<void> {290- if (!props.isPro) return;291- const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });292- const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;293- setDevices(data?.devices ?? []);294- }295-296223 async function handleStartPairing(): Promise<void> {297- setError(null);298-299224 // audioCapture is declared as optional_permissions in the manifest.300225 // Chrome will silently deny getUserMedia unless we request it here,301226 // during a user gesture. Firefox ignores this API but getUserMedia works@@ -306,13 +231,11 @@306231 });307232 if (!granted) {308233 setPairing({ phase: "mic_denied" });309- setMode("pairing");310234 return;311235 }312236 }313237 314238 setPairing({ phase: "idle" });315- setMode("pairing");316239 317240 const deps: ChirpPairingDeps = {318241 onProgress: (p) => setPairing(p),@@ -397,43 +320,18 @@397320 398321 try {399322 await runChirpPairing(deps, role);400- await refresh();323+ // Success surfaces in `pairing()` as the "paired" phase; ChirpPairingFlow324+ // shows it and onDismiss returns to Settings.401325 } catch (err) {402326 if (err instanceof ChirpPairingFailure) {403- // The orchestrator already pushed the terminal phase into404- // `pairing()`; the inline error copy will render. No further405- // action needed — the user can tap "Try again" or "Cancel".327+ // The orchestrator already pushed the terminal phase into `pairing()`;328+ // the inline error copy renders. The user can tap "Try again"/"Cancel".406329 return;407330 }408- setError((err as Error).message);409- setMode("rest");331+ setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });410332 }411333 }412334 413- async function handleUnpair(deviceId: string): Promise<void> {414- await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });415- setConfirmUnpair(null);416- await refresh();417- }418-419- function relativeTime(when: number | undefined): string {420- if (!when) return "Not synced yet";421- const elapsed = Date.now() - when;422- if (elapsed < 60_000) return "Just now";423- const minutes = Math.floor(elapsed / 60_000);424- if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;425- const hours = Math.floor(minutes / 60);426- if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;427- const days = Math.floor(hours / 24);428- if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;429- return "Not seen recently";430- }431-432- function isStale(d: PairedDevice): boolean {433- if (d.lastSyncedAt === undefined) return false;434- return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;435- }436-437335 // --- Sub-views --------------------------------------------------438336 439337 const proGateView = () => (@@ -471,150 +369,6 @@471369 </div>472370 );473371 474- const restView = () => (475- <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">476- <header class="peach-chirp-head">477- <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>478- <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">479- <path d="M10 3 L5 8 L10 13" />480- </svg>481- </button>482- <div class="peach-chirp-title">Sync</div>483- <div class="peach-chirp-head-spacer" />484- </header>485-486- <div class="peach-chirp-chip-row">487- <div class="peach-chirp-chip" data-phase="idle">488- <span class="peach-chirp-chip-dot" />489- <span class="peach-chirp-chip-text">ready</span>490- </div>491- </div>492-493- <Show when={error()}>494- <div class="peach-chirp-settings-error" role="alert">{error()}</div>495- </Show>496-497- <div class="peach-chirp-stage act-idle" aria-hidden="true">498- <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">499- <DesktopBird pose="idle" />500- </div>501- <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">502- <MobileBird pose="hidden" />503- </div>504- </div>505-506- <div class="peach-chirp-copy-host">507- <div class="peach-chirp-copy peach-chirp-copy-rest">508- <div class="peach-chirp-headline">509- {devices().length === 0 ? "Pair a device" : "Sync is ready"}510- </div>511- <div class="peach-chirp-sub">512- {devices().length === 0513- ? "Sync over your local network. No cloud, no account, no servers."514- : "Pair another device, or manage the ones already connected below."}515- </div>516- </div>517- </div>518-519- <Show when={devices().length > 0}>520- <section class="peach-chirp-paired-section">521- <h2 class="peach-chirp-paired-heading">Paired devices</h2>522- <ul class="peach-chirp-device-list">523- <For each={devices()}>524- {(d) => (525- <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>526- <DeviceTypeIcon type={d.deviceType ?? "extension"} />527- <div class="peach-chirp-device-info">528- <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>529- <span class="peach-chirp-device-meta">530- {relativeTime(d.lastSyncedAt)}531- <Show when={d.trustSource === "vouched"}>532- <span class="peach-chirp-device-badge">Added automatically</span>533- </Show>534- <Show when={isStale(d)}>535- <span class="peach-chirp-device-stale">Not seen recently</span>536- </Show>537- </span>538- </div>539- <Show540- when={confirmUnpair() !== d.deviceId}541- fallback={542- <div class="peach-chirp-unpair-confirm">543- <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>544- Cancel545- </button>546- <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>547- Remove548- </button>549- </div>550- }551- >552- <button553- class="peach-icon-btn peach-chirp-device-remove"554- aria-label={`Unpair ${d.deviceName}`}555- onClick={() => setConfirmUnpair(d.deviceId)}556- >557- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">558- <path d="M3 3 L11 11 M11 3 L3 11" />559- </svg>560- </button>561- </Show>562- </li>563- )}564- </For>565- </ul>566- </section>567- </Show>568-569- <Show when={devices().length > 0}>570- <div class="peach-chirp-sync-now-row">571- <button572- class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"573- disabled={syncStatus() === "syncing"}574- onClick={() => void handleSyncNow()}575- >576- {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}577- </button>578- <Show when={syncFlash()}>579- <span class="peach-chirp-sync-flash" aria-live="polite">580- {syncFlash()}581- </span>582- </Show>583- </div>584- </Show>585-586- <div class="peach-pair-method-list peach-pair-method-list--rest">587- <button class="peach-pair-method" onClick={() => pairViaChirp()}>588- <span class="peach-pair-method-icon" aria-hidden="true">589- <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">590- <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />591- <path d="M14 7.5 Q15.6 10 14 12.5" />592- <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />593- </svg>594- </span>595- <span class="peach-pair-method-text">596- <span class="peach-pair-method-title">Same room</span>597- <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>598- </span>599- </button>600- <button class="peach-pair-method" onClick={() => pairViaDesktop()}>601- <span class="peach-pair-method-icon" aria-hidden="true">602- <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">603- <rect x="2" y="4" width="16" height="10" rx="1.4" />604- <path d="M7 17 L13 17" />605- <path d="M9 14 L9 17 M11 14 L11 17" />606- </svg>607- </span>608- <span class="peach-pair-method-text">609- <span class="peach-pair-method-title">Enter a code</span>610- <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>611- </span>612- </button>613- </div>614-615- </div>616- );617-618372 const backArrow = (619373 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">620374 <path d="M10 3 L5 8 L10 13" />@@ -799,42 +553,9 @@799553 </Show>800554 );801555 802- const currentMode = createMemo(() => {803- if (!props.isPro) return "gated";804- if (mode() === "pairing") return "pairing";805- return "rest";806- });807-808556 return (809- <Show810- when={currentMode() === "gated"}811- fallback={812- <Show when={currentMode() === "pairing"} fallback={restView()}>813- {pairingView()}814- </Show>815- }816- >817- {proGateView()}557+ <Show when={props.isPro} fallback={proGateView()}>558+ {pairingView()}818559 </Show>819- );820-};821-822-const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {823- return (824- <div class="peach-chirp-device-icon" data-type={props.type}>825- <Show when={props.type === "android" || props.type === "ios"}>826- <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">827- <rect x="6" y="2" width="8" height="16" rx="1.5" />828- <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />829- </svg>830- </Show>831- <Show when={props.type === "extension" || props.type === "desktop"}>832- <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">833- <rect x="2" y="4" width="16" height="10" rx="1.4" />834- <path d="M7 17 L13 17" />835- <path d="M9 14 L9 17 M11 14 L11 17" />836- </svg>837- </Show>838- </div>839560 );840561 };src/popup/views/Settings.tsx+10 −1
@@ -285,6 +285,15 @@285285 function isSyncStale(d: PairedDevice): boolean {286286 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;287287 }288+ function deviceTypeLabel(t: PairedDevice["deviceType"]): string {289+ switch (t) {290+ case "android": return "Android";291+ case "ios": return "iPhone";292+ case "desktop": return "Desktop helper";293+ case "extension": return "Browser";294+ default: return "Device";295+ }296+ }288297 289298 async function loadPairedDevices() {290299 if (!isPro()) return;@@ -1588,7 +1597,7 @@15881597 <div class="peach-settings-row-main">15891598 <span>{d.deviceName ?? "Unnamed device"}</span>15901599 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1591- {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1600+ {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}15921601 </span>15931602 </div>15941603 <Show