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

view file

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

view file

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

view file

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;

view file

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

view file

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

view file

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

view file