e74aecb551e9

feat: make Settings → Sync the single hub — drop the separate sync-hub screen; pick Chirp/Code and begin, paired-device list + sync now + unpair live in Settings; ChirpSync is pairing-only and returns to Settings on done/cancel (v4.0.8)

time
2026-06-19 00:25 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
consolidate sync into settings
parent
6f1942819ccd
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/ChirpSync.desktop.test.tsx+7 −8src/popup/views/ChirpSync.tsx+13 −17src/popup/views/Settings.tsx+141 −25
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.7",5+  "version": "4.0.8",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.7",4+  "version": "4.0.8",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.7",3+  "version": "4.0.8",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/views/ChirpSync.desktop.test.tsx+7 −8
@@ -57,9 +57,9 @@5757     while (cleanups.length) cleanups.pop()!();5858   });5959 60-  function mount() {60+  function mount(onBack: () => void = () => {}) {6161     const r = renderView(62-      <ChirpSyncView isPro={true} onBack={() => {}} onLearnPro={() => {}} />,62+      <ChirpSyncView isPro={true} onBack={onBack} onLearnPro={() => {}} />,6363     );6464     cleanups.push(r.dispose);6565     return r;@@ -226,11 +226,12 @@226226     expect(container.textContent).toContain("Connected to June's iMac");227227   });228228 229-  it("auto-dismisses to the device list after the paired state settles", async () => {229+  it("auto-dismisses back to settings after the paired state settles", async () => {230230     vi.useFakeTimers();231231     try {232232       const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };233-      const { container } = mount();233+      const onBack = vi.fn();234+      const { container } = mount(onBack);234235       gotoDesktopEnter(container);235236       inputText(codeInput(container), "428913");236237       buttonByText(container, "Connect")!.click();@@ -241,10 +242,8 @@241242       pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });242243       expect(container.textContent).toContain("Paired!");243244       vi.advanceTimersByTime(2000);244-      // returns to the rest surface (the two direct pairing options are back)245-      expect(container.textContent).not.toContain("Paired!");246-      expect(buttonContaining(container, "Same room")).toBeTruthy();247-      expect(buttonContaining(container, "Enter a code")).toBeTruthy();245+      // returns to the Settings sync hub, which owns the device list now246+      expect(onBack).toHaveBeenCalled();248247     } finally {249248       vi.useRealTimers();250249     }

view file

src/popup/views/ChirpSync.tsx+13 −17
@@ -108,7 +108,9 @@108108 109109 export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {110110   const [devices, setDevices] = createSignal<PairedDevice[]>([]);111-  const [mode, setMode] = createSignal<Mode>("rest");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");112114   const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });113115   const [error, setError] = createSignal<string | null>(null);114116   const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);@@ -130,7 +132,7 @@130132   // --- Desktop helper (peach-sync) code pairing -------------------------131133   // The whole NK pairing runs in the background; this view drives the UI and132134   // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.133-  const [pairKind, setPairKind] = createSignal<PairKind>(null);135+  const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);134136   const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");135137   const [desktopCode, setDesktopCode] = createSignal("");136138   const [desktopSas, setDesktopSas] = createSignal<string | null>(null);@@ -209,14 +211,11 @@209211     }210212   }211213 212-  // Auto-dismiss after the paired state settles, like the chirp flow.214+  // Auto-dismiss after the paired state settles — back to the Settings hub,215+  // which reloads the paired-device list on mount.213216   createEffect(() => {214217     if (desktopPhase() !== "paired") return;215-    const t = setTimeout(() => {216-      setMode("rest");217-      setPairKind(null);218-      void refresh();219-    }, DESKTOP_PAIRED_DISMISS_MS);218+    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);220219     onCleanup(() => clearTimeout(t));221220   });222221 @@ -649,7 +648,7 @@649648   const desktopView = () => (650649     <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">651650       <header class="peach-chirp-head">652-        <button class="peach-chirp-back" aria-label="Back" onClick={() => { setMode("rest"); setPairKind(null); }}>{backArrow}</button>651+        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>653652         <div class="peach-chirp-title">Enter a code</div>654653         <div class="peach-chirp-head-spacer" />655654       </header>@@ -678,7 +677,7 @@678677             onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}679678           />680679           <div class="peach-chirp-action">681-            <button class="peach-btn peach-btn-ghost" onClick={() => { setMode("rest"); setPairKind(null); }}>Cancel</button>680+            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>682681             <button683682               class="peach-btn peach-btn-primary peach-desktop-connect"684683               disabled={!desktopCodeReady() || desktopBusy()}@@ -727,7 +726,7 @@727726             {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}728727           </div>729728           <div class="peach-chirp-action">730-            <button class="peach-btn peach-btn-primary" onClick={() => { setMode("rest"); setPairKind(null); void refresh(); }}>Done</button>729+            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>731730           </div>732731         </div>733732       </Show>@@ -737,7 +736,7 @@737736           <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>738737           <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>739738           <div class="peach-chirp-action">740-            <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>739+            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>741740             <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>742741           </div>743742         </div>@@ -753,12 +752,9 @@753752       fallback={754753         <ChirpPairingFlow755754           progress={pairing()}756-          onCancel={() => setMode("rest")}755+          onCancel={() => props.onBack()}757756           onRetry={() => void handleStartPairing()}758-          onDismiss={() => {759-            setMode("rest");760-            void refresh();761-          }}757+          onDismiss={() => props.onBack()}762758         />763759       }764760     >

view file

src/popup/views/Settings.tsx+141 −25
@@ -12,6 +12,7 @@1212   ProFeature,1313   VaultSnapshot,1414 } from "../../shared/types";15+import type { PairedDevice } from "../../shared/sync-types";1516 import {1617   DEFAULT_BINDINGS,1718   findConflict,@@ -263,6 +264,71 @@263264   interface PendingVouched { deviceId: string; deviceName: string }264265   const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);265266   const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);267+  // Paired-device management lives here — Settings is the sync hub.268+  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);269+  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270+  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271+  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272+273+  const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;274+  function syncRelativeTime(when: number | undefined): string {275+    if (!when) return "Not synced yet";276+    const elapsed = Date.now() - when;277+    if (elapsed < 60_000) return "Just now";278+    const minutes = Math.floor(elapsed / 60_000);279+    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;280+    const hours = Math.floor(minutes / 60);281+    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;282+    const days = Math.floor(hours / 24);283+    return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";284+  }285+  function isSyncStale(d: PairedDevice): boolean {286+    return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;287+  }288+289+  async function loadPairedDevices() {290+    if (!isPro()) return;291+    const r = (await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" })) as {292+      ok: boolean;293+      data?: { devices: PairedDevice[] };294+    };295+    if (r.ok) setPairedDevices(r.data?.devices ?? []);296+  }297+  async function unpairDevice(deviceId: string) {298+    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });299+    setConfirmUnpair(null);300+    await loadPairedDevices();301+  }302+  async function syncNow() {303+    setSyncStatus("syncing");304+    setSyncFlash(null);305+    const res = (await sendMessage({306+      type: "TRIGGER_SYNC",307+      payload: { reason: "manual", force: true },308+    })) as { ok: boolean; error?: string };309+    setTimeout(async () => {310+      const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {311+        ok: boolean;312+        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };313+      };314+      const s = state.data;315+      if (!s) return;316+      setSyncStatus(s.status);317+      if (s.status === "idle") {318+        setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");319+        setTimeout(() => setSyncFlash(null), 3000);320+        await loadPairedDevices();321+      } else if (s.status === "error") {322+        setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);323+        setTimeout(() => setSyncFlash(null), 4000);324+      }325+    }, 250);326+    if (!res.ok) {327+      setSyncStatus("error");328+      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);329+      setTimeout(() => setSyncFlash(null), 4000);330+    }331+  }266332   // UI-4 / MV-8: rotate sync identity confirmation dialog.267333   const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);268334   const [rotating, setRotating] = createSignal(false);@@ -479,6 +545,7 @@479545     // UI-3 / MV-7: surface devices a paired peer vouched for, and refresh the480546     // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.481547     void loadPendingVouched();548+    void loadPairedDevices();482549     const onBroadcast = (msg: unknown) => {483550       if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {484551         void loadPendingVouched();@@ -1473,38 +1540,87 @@14731540             <span>Status</span>14741541             <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>14751542           </div>1543+1544+          {/* Pick a pairing method and begin — no separate hub screen. */}1545+          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>14761546           <button1477-            class="peach-settings-row peach-settings-row--meta peach-settings-row--tappable"1478-            onClick={() => props.onOpenSyncPairing?.()}1547+            class="peach-pair-method"1548+            disabled={!isPro()}1549+            onClick={() => props.onOpenSyncPairing?.("chirp")}14791550           >1480-            <span>Sync hub</span>1481-            <span class="peach-settings-advanced-chevron" aria-hidden="true">›</span>1551+            <span class="peach-pair-method-icon" aria-hidden="true">1552+              <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1553+                <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />1554+                <path d="M14 7.5 Q15.6 10 14 12.5" />1555+                <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />1556+              </svg>1557+            </span>1558+            <span class="peach-pair-method-text">1559+              <span class="peach-pair-method-title">Same room</span>1560+              <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>1561+            </span>14821562           </button>1563+          <button1564+            class="peach-pair-method"1565+            style="margin-top:8px"1566+            disabled={!isPro()}1567+            onClick={() => props.onOpenSyncPairing?.("desktop")}1568+          >1569+            <span class="peach-pair-method-icon" aria-hidden="true">1570+              <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1571+                <rect x="2" y="4" width="16" height="10" rx="1.4" />1572+                <path d="M7 17 L13 17" />1573+                <path d="M9 14 L9 17 M11 14 L11 17" />1574+              </svg>1575+            </span>1576+            <span class="peach-pair-method-text">1577+              <span class="peach-pair-method-title">Enter a code</span>1578+              <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>1579+            </span>1580+          </button>14831581 1484-          <div class="peach-settings-row peach-settings-row--top">1485-            <div class="peach-settings-row-main">1486-              <span>Pair new device</span>1487-              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1488-                A nearby phone or browser by chirp, or your desktop helper by code.1489-              </span>1490-            </div>1491-            <div style="display:flex;gap:6px;flex-shrink:0">1582+          {/* Paired devices + manual sync — Settings is the hub. */}1583+          <Show when={pairedDevices().length > 0}>1584+            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1585+            <For each={pairedDevices()}>1586+              {(d) => (1587+                <div class="peach-settings-row peach-settings-row--top">1588+                  <div class="peach-settings-row-main">1589+                    <span>{d.deviceName ?? "Unnamed device"}</span>1590+                    <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" : ""}1592+                    </span>1593+                  </div>1594+                  <Show1595+                    when={confirmUnpair() === d.deviceId}1596+                    fallback={1597+                      <button1598+                        class="peach-btn peach-btn--subtle peach-btn--sm"1599+                        onClick={() => setConfirmUnpair(d.deviceId)}1600+                      >remove</button>1601+                    }1602+                  >1603+                    <div style="display:flex;gap:6px;flex-shrink:0">1604+                      <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1605+                      <button1606+                        class="peach-btn peach-btn--sm"1607+                        style="background:var(--peach-breach);border-color:var(--peach-breach)"1608+                        onClick={() => void unpairDevice(d.deviceId)}1609+                      >remove</button>1610+                    </div>1611+                  </Show>1612+                </div>1613+              )}1614+            </For>1615+            <div class="peach-settings-row peach-settings-row--meta">1616+              <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>14921617               <button14931618                 class="peach-btn peach-btn--subtle peach-btn--sm"1494-                disabled={!isPro()}1495-                onClick={() => props.onOpenSyncPairing?.("chirp")}1496-              >1497-                chirp1498-              </button>1499-              <button1500-                class="peach-btn peach-btn--subtle peach-btn--sm"1501-                disabled={!isPro()}1502-                onClick={() => props.onOpenSyncPairing?.("desktop")}1503-              >1504-                code1505-              </button>1619+                disabled={syncStatus() === "syncing"}1620+                onClick={() => void syncNow()}1621+              >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>15061622             </div>1507-          </div>1623+          </Show>15081624           {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}15091625           <Show when={pendingVouched().length > 0}>15101626             <p class="peach-settings-sub-label">

view file