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
↓ peach-browser-e74aecb5.tar.gzatlas at this save
files changed6 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 −25manifest.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": {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",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",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 }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 >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">