a340155ddd86

sync screen rebuild: new SyncPairingView hub (States A/B/C + Advanced), AddDeviceView QR flow, Settings sync section removed, GET_PAIRED_DEVICES + GET_SYNC_STATUS handlers, nav from Settings directly to hub (v2.36.0)

time
2026-05-09 02:14 UTC · 5 months ago
machine
koh2
parent
8c654a6d9948
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+11 −0src/popup/App.tsx+15 −2src/popup/styles/popup.css+212 −0src/popup/views/Settings.tsx+12 −371src/popup/views/SyncPairing.tsx+287 −50src/shared/types.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.35.9",4+  "version": "2.36.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.35.9",4+  "version": "2.36.0",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": "2.35.9",3+  "version": "2.36.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+11 −0
@@ -681,6 +681,17 @@681681 682682         return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };683683       }684+      case "GET_PAIRED_DEVICES": {685+        const devices = await getPairedDevices();686+        return { ok: true, data: { devices } };687+      }688+      case "GET_SYNC_STATUS": {689+        const lastSyncRaw = await storageGet("peach_last_sync_at");690+        const lastSyncParsed = lastSyncRaw ? parseInt(lastSyncRaw, 10) : NaN;691+        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : null;692+        const status: 'up_to_date' | 'never' = lastSyncAt ? 'up_to_date' : 'never';693+        return { ok: true, data: { status, lastSyncAt } };694+      }684695       case "ACCEPT_PAIRING": {685696         // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.686697         return { ok: true };

view file

src/popup/App.tsx+15 −2
@@ -37,6 +37,7 @@3737 const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3838 const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));3939 const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));40+const AddDeviceView   = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.AddDeviceView })));4041 4142 type View =4243   | "lock"@@ -46,6 +47,7 @@4647   | "add"4748   | "settings"4849   | "sync-pairing"50+  | "sync-add-device"4951   | "import"5052   | "export"5153   | "health"@@ -690,6 +692,7 @@690692         if (v === "list") { setView("entry"); return; }691693         if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }692694         if (v === "sync-pairing") { setView("settings"); return; }695+        if (v === "sync-add-device") { setView("sync-pairing"); return; }693696       }694697 695698       // Bindings-driven dispatch. Single-key letter bindings without@@ -1412,8 +1415,18 @@14121415             <Show when={view() === "sync-pairing"}>14131416               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>14141417                 <SyncPairingView1415-                  onClose={() => setView("settings")}1416-                  onPaired={() => { /* sync state refreshes when popup returns to Settings */ }}1418+                  isPro={proStatus()}1419+                  onAddDevice={() => setView("sync-add-device")}1420+                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}1421+                  onBack={() => setView("settings")}1422+                />1423+              </Suspense>1424+            </Show>1425+            <Show when={view() === "sync-add-device"}>1426+              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1427+                <AddDeviceView1428+                  onClose={() => setView("sync-pairing")}1429+                  onPaired={() => setView("sync-pairing")}14171430                 />14181431               </Suspense>14191432             </Show>

view file

src/popup/styles/popup.css+212 −0
@@ -7618,3 +7618,215 @@76187618 .peach-btn--full {76197619   width: 100%;76207620 }7621+7622+/* ── Sync screen ── */7623+7624+.peach-sync-screen {7625+  display: flex;7626+  flex-direction: column;7627+  height: 100%;7628+}7629+7630+.peach-sync-content {7631+  flex: 1;7632+  overflow-y: auto;7633+  padding: 24px 20px 40px;7634+  display: flex;7635+  flex-direction: column;7636+  gap: 24px;7637+}7638+7639+/* Intro (State A) */7640+.peach-sync-headline {7641+  font-family: 'Cormorant Garamond', serif;7642+  font-style: italic;7643+  font-size: 28px;7644+  font-weight: 600;7645+  color: var(--peach-t0);7646+  margin: 0 0 10px;7647+  line-height: 1.2;7648+}7649+7650+.peach-sync-intro-body {7651+  font-size: 14px;7652+  color: var(--peach-t2);7653+  line-height: 1.6;7654+  margin: 0;7655+}7656+7657+.peach-sync-pro-note {7658+  display: flex;7659+  flex-direction: column;7660+  gap: 6px;7661+  margin-top: -8px;7662+}7663+7664+.peach-sync-pro-note-text {7665+  font-size: 12px;7666+  color: var(--peach-t3);7667+  line-height: 1.5;7668+  margin: 0;7669+}7670+7671+.peach-sync-pro-link {7672+  background: none;7673+  border: none;7674+  font-size: 12px;7675+  color: var(--peach-accent);7676+  cursor: pointer;7677+  padding: 0;7678+  font-family: inherit;7679+  text-align: left;7680+}7681+7682+/* Status (State C) */7683+.peach-sync-status {7684+  padding: 12px 0;7685+  border-bottom: 1px solid var(--peach-border);7686+}7687+7688+.peach-sync-status-row {7689+  display: flex;7690+  align-items: center;7691+  gap: 8px;7692+}7693+7694+.peach-sync-dot {7695+  width: 7px;7696+  height: 7px;7697+  border-radius: 50%;7698+  flex-shrink: 0;7699+}7700+7701+.peach-sync-dot--green { background: var(--peach-health-ok); }7702+.peach-sync-dot--amber { background: var(--peach-accent); }7703+7704+.peach-sync-status-text {7705+  font-family: 'IBM Plex Mono', monospace;7706+  font-size: 11px;7707+  color: var(--peach-t2);7708+}7709+7710+/* Paired devices */7711+.peach-sync-section {7712+  display: flex;7713+  flex-direction: column;7714+  gap: 2px;7715+}7716+7717+.peach-sync-section-label {7718+  font-family: 'IBM Plex Mono', monospace;7719+  font-size: 10px;7720+  letter-spacing: 0.1em;7721+  color: var(--peach-t3);7722+  margin: 0 0 10px;7723+}7724+7725+.peach-sync-device-row {7726+  display: flex;7727+  justify-content: space-between;7728+  align-items: baseline;7729+  padding: 10px 0;7730+  border-bottom: 1px solid var(--peach-border);7731+  gap: 12px;7732+}7733+7734+.peach-sync-device-name {7735+  font-size: 14px;7736+  color: var(--peach-t1);7737+}7738+7739+.peach-sync-device-meta {7740+  font-family: 'IBM Plex Mono', monospace;7741+  font-size: 11px;7742+  color: var(--peach-t3);7743+  white-space: nowrap;7744+}7745+7746+/* Action buttons */7747+.peach-sync-actions {7748+  display: flex;7749+  gap: 10px;7750+  flex-wrap: wrap;7751+}7752+7753+/* Upgrade prompt (State B) */7754+.peach-sync-upgrade {7755+  display: flex;7756+  flex-direction: column;7757+  gap: 12px;7758+  padding: 16px;7759+  border: 1px solid var(--peach-border);7760+  border-radius: 10px;7761+}7762+7763+.peach-sync-upgrade-text {7764+  font-size: 13px;7765+  color: var(--peach-t2);7766+  line-height: 1.6;7767+  margin: 0;7768+}7769+7770+/* Network note */7771+.peach-sync-network-note {7772+  font-size: 12px;7773+  color: var(--peach-t3);7774+  line-height: 1.6;7775+  margin: 0;7776+}7777+7778+/* Advanced */7779+.peach-sync-advanced {7780+  margin-top: auto;7781+  padding-top: 16px;7782+  border-top: 1px solid var(--peach-border);7783+}7784+7785+.peach-sync-advanced-toggle {7786+  display: flex;7787+  align-items: center;7788+  gap: 8px;7789+  background: none;7790+  border: none;7791+  font-family: 'IBM Plex Mono', monospace;7792+  font-size: 11px;7793+  letter-spacing: 0.08em;7794+  color: var(--peach-t3);7795+  cursor: pointer;7796+  padding: 0;7797+  text-transform: uppercase;7798+}7799+7800+.peach-sync-advanced-chevron {7801+  display: inline-block;7802+  transition: transform 200ms ease;7803+  font-size: 10px;7804+}7805+7806+.peach-sync-advanced-chevron.is-open {7807+  transform: rotate(90deg);7808+}7809+7810+.peach-sync-advanced-content {7811+  margin-top: 14px;7812+}7813+7814+.peach-sync-advanced-row {7815+  display: flex;7816+  justify-content: space-between;7817+  align-items: center;7818+  gap: 16px;7819+}7820+7821+.peach-sync-advanced-title {7822+  font-size: 14px;7823+  color: var(--peach-t1);7824+  margin: 0 0 2px;7825+}7826+7827+.peach-sync-advanced-subtitle {7828+  font-size: 12px;7829+  color: var(--peach-t3);7830+  margin: 0;7831+  line-height: 1.5;7832+}

view file

src/popup/views/Settings.tsx+12 −371
@@ -11,7 +11,6 @@1111   ProFeature,1212   VaultSnapshot,1313 } from "../../shared/types";14-import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";1514 import {1615   DEFAULT_BINDINGS,1716   findConflict,@@ -22,8 +21,6 @@2221   type ShortcutAction,2322 } from "../../shared/shortcuts";2423 import { isNativePlatform, nativePlugin } from "../../shared/native";25-import { getHasPaired } from "../../background/sync-storage";26-import { SyncInfoSheet } from "../components/SyncInfoSheet";2724 2825 interface LicenseInfo {2926   valid: boolean;@@ -70,7 +67,6 @@7067 type SectionName =7168   | "security"7269   | "vault"73-  | "sync"7470   | "appearance"7571   | "shortcuts"7672   | "license"@@ -93,7 +89,6 @@9389   return (9490     value === "security" ||9591     value === "vault" ||96-    value === "sync" ||9792     value === "appearance" ||9893     value === "shortcuts" ||9994     value === "license" ||@@ -116,25 +111,11 @@116111   const [licenseSuccess, setLicenseSuccess] = createSignal<string | null>(null);117112   const [activatingLicense, setActivatingLicense] = createSignal(false);118113 119-  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);120-  const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);121-  const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");122-  const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);123-  // Prefer the prop (cached at App boot), fall back to the sync-state probe.124-  // The prop is authoritative once present so license activation reflects125-  // immediately without waiting for a sync round-trip.126-  const [syncProStatus, setSyncProStatus] = createSignal(false);127-  const isPro = () => props.isPro ?? syncProStatus();128-  const [hasPaired, setHasPairedLocal] = createSignal(false);129-  const [showSyncInfoSheet, setShowSyncInfoSheet] = createSignal(false);130-  const [syncingNow, setSyncingNow] = createSignal(false);114+  const isPro = () => props.isPro ?? false;131115   const [biometricSupported, setBiometricSupported] = createSignal(false);132116   const [biometricStored, setBiometricStored] = createSignal(false);133117   const [biometricBusy, setBiometricBusy] = createSignal(false);134118   const [biometricError, setBiometricError] = createSignal<string | null>(null);135-  const [syncFeedback, setSyncFeedback] = createSignal<136-    { kind: "ok" | "err"; text: string } | null137-  >(null);138119   const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);139120   const [passkeySaving, setPasskeySaving] = createSignal(false);140121 @@ -233,18 +214,14 @@233214   const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);234215 235216   onMount(async () => {236-    const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes, keyRes, pairedFlag] = await Promise.all([217+    const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([237218       sendMessage({ type: "GET_LICENSE" }),238-      sendMessage({ type: "GET_SYNC_STATE" }),239219       sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),240220       sendMessage({ type: "IS_DURESS_ENABLED" }),241221       sendMessage({ type: "GET_SETTINGS" }),242222       sendMessage({ type: "GET_LICENSE_KEY" }),243-      getHasPaired(),244223     ]);245224 246-    setHasPairedLocal(pairedFlag);247-248225     // Vault history loads in the background — the Vault sub-view picks it up249226     // when the user navigates in. Loading it lazily keeps Settings boot fast.250227     void loadVaultHistory();@@ -253,23 +230,6 @@253230       setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);254231     }255232 256-    if ((syncRes as { ok: boolean }).ok) {257-      const data = syncRes as { ok: boolean; data?: {258-        pairedDevices?: PairedDevice[];259-        trustedNetworks?: TrustedNetwork[];260-        status?: SyncStatus;261-        lastSyncAt?: number;262-        isPro?: boolean;263-      }};264-      if (data.data) {265-        setPairedDevices(data.data.pairedDevices ?? []);266-        setTrustedNetworks(data.data.trustedNetworks ?? []);267-        setSyncStatus(data.data.status ?? "offline");268-        setLastSyncAt(data.data.lastSyncAt);269-        setSyncProStatus(data.data.isPro ?? false);270-      }271-    }272-273233     if ((passkeyRes as { ok: boolean }).ok) {274234       setPasskeyState(275235         (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null@@ -299,18 +259,6 @@299259       );300260     }301261 302-    chrome.runtime.onMessage.addListener((msg) => {303-      if (msg.type === "SYNC_STATE_UPDATE") {304-        setPairedDevices(msg.pairedDevices ?? []);305-        setTrustedNetworks(msg.trustedNetworks ?? []);306-        setSyncStatus(msg.status ?? "offline");307-        setLastSyncAt(msg.lastSyncAt);308-        setSyncProStatus(msg.isPro ?? false);309-      }310-      if (msg.type === "PAIRING_COMPLETED") {311-        setHasPairedLocal(true);312-      }313-    });314262 315263     if (isNativePlatform()) {316264       const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");@@ -328,14 +276,6 @@328276     }329277   });330278 331-  createEffect(() => {332-    if (activeSection() === "sync") {333-      setShowSyncInfoSheet(!isPro() && !hasPaired());334-    } else {335-      setShowSyncInfoSheet(false);336-    }337-  });338-339279   async function handleEnableBiometrics() {340280     setBiometricBusy(true);341281     setBiometricError(null);@@ -464,121 +404,7 @@464404     }465405   }466406 467-  async function handleSyncNow() {468-    if (syncStatus() === "not_trusted") return;469-    setSyncingNow(true);470-    setSyncFeedback(null);471-    const res = await sendMessage({ type: "TRIGGER_SYNC" });472-    const ok = (res as { ok: boolean }).ok;473-    if (ok) {474-      const data = (res as { data?: { syncedCount?: number; totalReachable?: number } }).data;475-      const count = data?.syncedCount ?? 0;476-      setSyncFeedback({ kind: "ok", text: count > 0 ? `Synced with ${count} ${count === 1 ? "device" : "devices"}` : "Up to date" });477-      setLastSyncAt(Date.now());478-      void refreshSyncState();479-    } else {480-      const error = (res as { error?: string }).error;481-      const map: Record<string, string> = {482-        no_paired_devices: "No paired devices",483-        no_devices_reachable: "No devices found",484-        vault_locked: "Vault is locked",485-        pro_required: "Sync requires Pro",486-      };487-      setSyncFeedback({ kind: "err", text: map[error ?? ""] ?? "Sync failed" });488-    }489-    setTimeout(() => {490-      setSyncingNow(false);491-      setSyncFeedback(null);492-    }, 3500);493-  }494407 495-  async function refreshSyncState() {496-    const res = await sendMessage({ type: "GET_SYNC_STATE" });497-    const data = (res as { ok: boolean; data?: {498-      pairedDevices?: PairedDevice[];499-      trustedNetworks?: TrustedNetwork[];500-      status?: SyncStatus;501-      lastSyncAt?: number;502-    }}).data;503-    if (!data) return;504-    setPairedDevices(data.pairedDevices ?? []);505-    setTrustedNetworks(data.trustedNetworks ?? []);506-    setSyncStatus(data.status ?? "offline");507-    setLastSyncAt(data.lastSyncAt);508-  }509-510-  async function handleRemoveDevice(deviceId: string) {511-    setPairedDevices((prev) => prev.filter((d) => d.deviceId !== deviceId));512-    await sendMessage({ type: "REMOVE_PAIRED_DEVICE", payload: { deviceId } });513-    void refreshSyncState();514-  }515-516-  async function handleRemoveNetwork(ssid?: string, bssid?: string, isTailscale?: boolean) {517-    await sendMessage({ type: "REMOVE_TRUSTED_NETWORK", payload: { ssid, bssid, isTailscale } });518-    void refreshSyncState();519-  }520-521-  const [markingNetwork, setMarkingNetwork] = createSignal(false);522-523-  async function handleMarkNetworkTrusted() {524-    if (markingNetwork()) return;525-    setMarkingNetwork(true);526-    await sendMessage({ type: "MARK_NETWORK_TRUSTED" });527-    await refreshSyncState();528-    setMarkingNetwork(false);529-  }530-531-  const [resetIdentityConfirm, setResetIdentityConfirm] = createSignal(false);532-  const [resetIdentityRunning, setResetIdentityRunning] = createSignal(false);533-534-  async function handleResetSyncIdentity() {535-    setResetIdentityRunning(true);536-    const res = await sendMessage({ type: "RESET_SYNC_IDENTITY" });537-    setResetIdentityRunning(false);538-    if ((res as { ok: boolean }).ok) {539-      setPairedDevices([]);540-      setResetIdentityConfirm(false);541-    }542-  }543-544-  function formatRelativeTime(timestamp?: number): string {545-    if (!timestamp) return "Never";546-    const diff = Date.now() - timestamp;547-    const minutes = Math.floor(diff / 60000);548-    const hours = Math.floor(diff / 3600000);549-    const days = Math.floor(diff / 86400000);550-    if (minutes < 1) return "Just now";551-    if (minutes < 60) return `${minutes}m ago`;552-    if (hours < 24) return `${hours}h ago`;553-    return `${days}d ago`;554-  }555-556-  function getStatusColor(status: SyncStatus): string {557-    switch (status) {558-      case "synced": return "var(--peach-sync-ok)";559-      case "syncing": return "var(--peach-sync-busy)";560-      case "no_peers": return "var(--peach-sync-off)";561-      case "not_trusted": return "var(--peach-sync-err)";562-      case "offline": return "var(--peach-sync-off)";563-      default: return "var(--peach-sync-off)";564-    }565-  }566-567-  function getStatusLabel(status: SyncStatus): string {568-    switch (status) {569-      case "synced": return "Synced";570-      case "syncing": return "Syncing";571-      case "no_peers": return "No peers visible";572-      case "not_trusted": return "Not on a trusted network";573-      case "offline": return "Offline";574-      default: return "Unknown";575-    }576-  }577-578-  function isCurrentNetworkTrusted(): boolean {579-    return trustedNetworks().length > 0;580-  }581-582408   async function handleSetupDuress() {583409     const mainPw = duressMainPassword().trim();584410     const duressPw = duressPassword().trim();@@ -787,7 +613,16 @@787613 788614         <p class="peach-settings-nav-label">VAULT</p>789615         {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}790-        {renderCatRow({ section: "sync", name: "Sync", desc: "Pair devices · sync is free", icon: iconSync })}616+        <button class="peach-settings-cat-row" onClick={() => props.onOpenSyncPairing?.()}>617+          <div class="peach-settings-cat-icon">{iconSync()}</div>618+          <div class="peach-settings-cat-content">619+            <span class="peach-settings-cat-name">Sync</span>620+            <span class="peach-settings-cat-desc">Pair devices · sync is free</span>621+          </div>622+          <svg class="peach-settings-cat-chevron" width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">623+            <path d="M1 1l4 4-4 4" />624+          </svg>625+        </button>791626 792627         <p class="peach-settings-nav-label">PREFERENCES</p>793628         {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}@@ -1314,199 +1149,6 @@13141149     );13151150   }13161151 1317-  function renderSync() {1318-    return (1319-      <>1320-        <Show when={showSyncInfoSheet()}>1321-          <SyncInfoSheet1322-            onGotIt={() => setShowSyncInfoSheet(false)}1323-            onLearnPro={() => {1324-              setShowSyncInfoSheet(false);1325-              setActiveSection("license");1326-            }}1327-          />1328-        </Show>1329-1330-        <div class="peach-settings-body">1331-        <div class="peach-settings-group">1332-          <p class="peach-sync-network-note">1333-            Sync works on your home and trusted networks. Some public or1334-            corporate networks block device connections.1335-          </p>1336-1337-          {/* Add a device — FREE, always shown */}1338-          <button1339-            class="peach-settings-row peach-settings-row--tappable"1340-            onClick={() => props.onOpenSyncPairing?.()}1341-          >1342-            <span>Add a device</span>1343-            <svg width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1344-              <path d="M1 1l4 4-4 4" />1345-            </svg>1346-          </button>1347-1348-          {/* Paired devices list — shown when any exist */}1349-          <Show when={pairedDevices().length > 0}>1350-            <div class="peach-history-list">1351-              <For each={pairedDevices()}>1352-                {(device) => (1353-                  <div class="peach-history-row">1354-                    <div class="peach-history-meta">1355-                      <span class="peach-history-time">{device.displayName}</span>1356-                      <span class="peach-history-sub">1357-                        {device.lastSyncAt1358-                          ? `Last synced ${formatRelativeTime(device.lastSyncAt)}`1359-                          : "Never synced"}1360-                      </span>1361-                    </div>1362-                    <button1363-                      class="peach-btn peach-btn--subtle peach-btn--sm peach-history-btn"1364-                      onClick={() => void handleRemoveDevice(device.deviceId)}1365-                    >1366-                      Remove1367-                    </button>1368-                  </div>1369-                )}1370-              </For>1371-            </div>1372-          </Show>1373-1374-          {/* Automatic sync — PRO only */}1375-          <Show1376-            when={isPro()}1377-            fallback={1378-              <Show when={hasPaired()}>1379-                <div class="peach-sync-pro-hint">1380-                  <p class="peach-settings-hint">1381-                    Upgrade to Pro for automatic background sync across all your devices.1382-                  </p>1383-                  <button1384-                    class="peach-btn peach-btn--subtle peach-btn--sm"1385-                    onClick={() => setActiveSection("license")}1386-                  >1387-                    Upgrade to Pro1388-                  </button>1389-                </div>1390-              </Show>1391-            }1392-          >1393-            <div class="peach-settings-row">1394-              <span>Status</span>1395-              <span class="peach-settings-value peach-settings-value--status">1396-                <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1397-                {getStatusLabel(syncStatus())}1398-              </span>1399-            </div>1400-            <div class="peach-settings-row peach-settings-row--meta">1401-              <span>Last sync</span>1402-              <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1403-            </div>1404-            <div class="peach-settings-row peach-settings-row--meta">1405-              <button1406-                class="peach-btn peach-btn--subtle peach-btn--sm"1407-                onClick={() => void handleSyncNow()}1408-                disabled={syncStatus() === "not_trusted" || syncingNow()}1409-              >1410-                <Switch>1411-                  <Match when={syncingNow()}>Syncing…</Match>1412-                  <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1413-                  <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1414-                  <Match when={true}>Sync now</Match>1415-                </Switch>1416-              </button>1417-            </div>1418-            <p class="peach-settings-hint">1419-              If you edit on multiple devices before syncing, the most recently1420-              changed device wins. Use Vault History to recover older versions.1421-            </p>1422-1423-            <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1424-              <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1425-              <For each={trustedNetworks()}>1426-                {(network) => (1427-                  <div class="peach-sync-device-row">1428-                    <span class="peach-sync-device-name">1429-                      {network.isTailscale ? "Tailscale" : (network.ssid ?? "Unknown network")}1430-                    </span>1431-                    <Show1432-                      when={!network.isTailscale}1433-                      fallback={<span class="peach-sync-device-meta">Always trusted</span>}1434-                    >1435-                      <button1436-                        class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1437-                        onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1438-                      >1439-                        Remove1440-                      </button>1441-                    </Show>1442-                  </div>1443-                )}1444-              </For>1445-              <div class="peach-settings-actions--row">1446-                <Show when={!isCurrentNetworkTrusted()}>1447-                  <button1448-                    class="peach-btn peach-btn--subtle peach-btn--sm"1449-                    onClick={() => void handleMarkNetworkTrusted()}1450-                    disabled={markingNetwork()}1451-                  >1452-                    {markingNetwork() ? "Marking…" : "Mark current network as trusted"}1453-                  </button>1454-                </Show>1455-              </div>1456-            </div>1457-1458-            <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1459-              <span class="peach-sync-section-label">Advanced</span>1460-              <Show1461-                when={resetIdentityConfirm()}1462-                fallback={1463-                  <div class="peach-sync-advanced-row">1464-                    <div class="peach-sync-advanced-info">1465-                      <span class="peach-sync-advanced-name">Reset sync identity</span>1466-                      <span class="peach-sync-device-meta">1467-                        Generates a new keypair and unpairs every device.1468-                      </span>1469-                    </div>1470-                    <button1471-                      class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1472-                      onClick={() => setResetIdentityConfirm(true)}1473-                    >1474-                      Reset1475-                    </button>1476-                  </div>1477-                }1478-              >1479-                <div class="peach-sync-reset-confirm">1480-                  <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1481-                  <p class="peach-sync-reset-confirm-body">1482-                    This will unpair all devices. You will need to pair them again from scratch.1483-                  </p>1484-                  <div class="peach-settings-actions--row">1485-                    <button1486-                      class="peach-btn peach-btn--ghost peach-btn--sm"1487-                      onClick={() => setResetIdentityConfirm(false)}1488-                      disabled={resetIdentityRunning()}1489-                    >1490-                      Cancel1491-                    </button>1492-                    <button1493-                      class="peach-btn peach-btn--danger peach-btn--sm"1494-                      onClick={() => void handleResetSyncIdentity()}1495-                      disabled={resetIdentityRunning()}1496-                    >1497-                      {resetIdentityRunning() ? "Resetting..." : "Reset"}1498-                    </button>1499-                  </div>1500-                </div>1501-              </Show>1502-            </div>1503-          </Show>1504-        </div>1505-      </div>1506-      </>1507-    );1508-  }1509-15101152   function renderAppearance() {15111153     const fontOptions: Array<{ id: "cormorant-italic" | "cormorant" | "inter" | "system"; label: string; preview: string }> = [15121154       { id: "cormorant-italic", label: "Cormorant Italic", preview: "Aa" },@@ -2009,7 +1651,6 @@20091651           <Switch fallback={renderHome()}>20101652             <Match when={activeSection() === "security"}>{renderSecurity()}</Match>20111653             <Match when={activeSection() === "vault"}>{renderVault()}</Match>2012-            <Match when={activeSection() === "sync"}>{renderSync()}</Match>20131654             <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>20141655             <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>20151656             <Match when={activeSection() === "license"}>{renderLicense()}</Match>

view file

src/popup/views/SyncPairing.tsx+287 −50
@@ -1,10 +1,279 @@11 import QRCode from 'qrcode';2-import { createSignal, onCleanup, onMount, Show } from "solid-js";3-import { sendMessage } from "../App";4-import type { PairedDevice } from "../../shared/sync-types";5-import { setHasPaired } from "../../background/sync-storage";2+import { Component, createSignal, For, onCleanup, onMount, Show } from 'solid-js';3+import { sendMessage } from '../App';4+import type { PairedDevice } from '../../shared/sync-types';5+import { getHasPaired, setHasPaired } from '../../background/sync-storage';6+import { SyncInfoSheet } from '../components/SyncInfoSheet';67 8+// ── Shared ──────────────────────────────────────────────────────────────────9+10+function formatRelativeTime(ts: number): string {11+  const diff = Date.now() - ts;12+  const mins = Math.floor(diff / 60_000);13+  if (mins < 1) return 'just now';14+  if (mins < 60) return `${mins} ${mins === 1 ? 'minute' : 'minutes'} ago`;15+  const hrs = Math.floor(mins / 60);16+  if (hrs < 24) return `${hrs} ${hrs === 1 ? 'hour' : 'hours'} ago`;17+  const days = Math.floor(hrs / 24);18+  if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'} ago`;19+  return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });20+}21+22+// ── Sync Hub — States A / B / C ──────────────────────────────────────────────23+724 interface SyncPairingProps {25+  isPro: boolean;26+  onAddDevice: () => void;27+  onLearnPro: () => void;28+  onBack: () => void;29+}30+31+export const SyncPairingView: Component<SyncPairingProps> = (props) => {32+  const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);33+  const [hasPaired, setHasPairedSignal] = createSignal(false);34+  const [syncStatus, setSyncStatus] = createSignal<'up_to_date' | 'unreachable' | 'syncing' | 'never'>('never');35+  const [lastSyncAt, setLastSyncAt] = createSignal<number | null>(null);36+  const [syncing, setSyncing] = createSignal(false);37+  const [showAdvanced, setShowAdvanced] = createSignal(false);38+  const [resetting, setResetting] = createSignal(false);39+  const [showInfoSheet, setShowInfoSheet] = createSignal(false);40+41+  onMount(async () => {42+    const [paired, devices, syncInfo] = await Promise.all([43+      getHasPaired(),44+      sendMessage({ type: 'GET_PAIRED_DEVICES' }),45+      sendMessage({ type: 'GET_SYNC_STATUS' }),46+    ]);47+48+    setHasPairedSignal(paired);49+    if (!paired && !props.isPro) setShowInfoSheet(true);50+51+    const d = devices as { ok: boolean; data?: { devices?: PairedDevice[] } };52+    if (d?.ok) setPairedDevices(d.data?.devices ?? []);53+54+    const s = syncInfo as { ok: boolean; data?: { status?: 'up_to_date' | 'unreachable' | 'never'; lastSyncAt?: number | null } };55+    if (s?.ok) {56+      setSyncStatus(s.data?.status ?? 'never');57+      setLastSyncAt(s.data?.lastSyncAt ?? null);58+    }59+  });60+61+  async function handleSyncNow() {62+    setSyncing(true);63+    const result = await sendMessage({ type: 'TRIGGER_SYNC' });64+    setSyncing(false);65+    const r = result as { ok: boolean; syncedCount?: number };66+    if (r?.ok && (r.syncedCount ?? 0) > 0) {67+      setLastSyncAt(Date.now());68+      setSyncStatus('up_to_date');69+      const devices = await sendMessage({ type: 'GET_PAIRED_DEVICES' });70+      const d = devices as { ok: boolean; devices?: PairedDevice[] };71+      if (d?.ok) setPairedDevices(d.devices ?? []);72+    }73+  }74+75+  async function handleResetIdentity() {76+    if (!confirm('This will unpair all devices. Are you sure?')) return;77+    setResetting(true);78+    await sendMessage({ type: 'RESET_SYNC_IDENTITY' });79+    setResetting(false);80+    setPairedDevices([]);81+    setHasPairedSignal(false);82+  }83+84+  return (85+    <div class="peach-sync-screen peach-overlay-view ov-enter-right">86+      <Show when={showInfoSheet()}>87+        <SyncInfoSheet88+          onGotIt={() => setShowInfoSheet(false)}89+          onLearnPro={() => { setShowInfoSheet(false); props.onLearnPro(); }}90+        />91+      </Show>92+93+      <div class="peach-overlay-header">94+        <button95+          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"96+          onClick={props.onBack}97+          aria-label="Back"98+        >99+          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">100+            <path d="M6 1L1 6L6 11" />101+          </svg>102+        </button>103+        <span class="peach-overlay-title">SYNC</span>104+        <div class="peach-overlay-spacer" />105+      </div>106+107+      <div class="peach-sync-content">108+109+        {/* ── State A: Never paired ── */}110+        <Show when={!hasPaired()}>111+          <div class="peach-sync-intro">112+            <h2 class="peach-sync-headline">sync your vault.</h2>113+            <p class="peach-sync-intro-body">114+              Peach can transfer your vault to another device instantly115+              — just scan a code.116+            </p>117+          </div>118+119+          <button120+            class="peach-btn peach-btn--primary peach-btn--full"121+            onClick={props.onAddDevice}122+          >123+            Add a device124+          </button>125+126+          <div class="peach-sync-pro-note">127+            <p class="peach-sync-pro-note-text">128+              Continuous sync across multiple devices requires Peach Pro.129+            </p>130+            <button131+              class="peach-sync-pro-link"132+              onClick={props.onLearnPro}133+            >134+              Learn about Pro →135+            </button>136+          </div>137+        </Show>138+139+        {/* ── States B + C: Has paired ── */}140+        <Show when={hasPaired()}>141+142+          {/* Sync status — Pro only */}143+          <Show when={props.isPro}>144+            <div class="peach-sync-status">145+              <Show146+                when={syncStatus() !== 'unreachable'}147+                fallback={148+                  <div class="peach-sync-status-row">149+                    <span class="peach-sync-dot peach-sync-dot--amber" />150+                    <span class="peach-sync-status-text">151+                      Not on a compatible network152+                    </span>153+                  </div>154+                }155+              >156+                <div class="peach-sync-status-row">157+                  <span class="peach-sync-dot peach-sync-dot--green" />158+                  <span class="peach-sync-status-text">159+                    <Show when={lastSyncAt()} fallback="Never synced">160+                      Last synced {formatRelativeTime(lastSyncAt()!)}161+                    </Show>162+                  </span>163+                </div>164+              </Show>165+            </div>166+          </Show>167+168+          {/* Paired devices */}169+          <Show when={pairedDevices().length > 0}>170+            <div class="peach-sync-section">171+              <p class="peach-sync-section-label">PAIRED DEVICES</p>172+              <For each={pairedDevices()}>173+                {(device) => (174+                  <div class="peach-sync-device-row">175+                    <span class="peach-sync-device-name">176+                      {device.displayName}177+                    </span>178+                    <span class="peach-sync-device-meta">179+                      {device.lastSyncAt180+                        ? formatRelativeTime(device.lastSyncAt)181+                        : 'Paired — never synced'}182+                    </span>183+                  </div>184+                )}185+              </For>186+            </div>187+          </Show>188+189+          {/* Action buttons */}190+          <div class="peach-sync-actions">191+            <button192+              class="peach-btn peach-btn--sm"193+              onClick={props.onAddDevice}194+            >195+              Add a device196+            </button>197+            <Show when={props.isPro}>198+              <button199+                class="peach-btn peach-btn--sm"200+                onClick={handleSyncNow}201+                disabled={syncing()}202+              >203+                {syncing() ? 'Syncing…' : 'Sync now'}204+              </button>205+            </Show>206+          </div>207+208+          {/* State B: upgrade prompt */}209+          <Show when={!props.isPro}>210+            <div class="peach-sync-upgrade">211+              <p class="peach-sync-upgrade-text">212+                Want automatic sync? Upgrade to Peach Pro to keep213+                all your devices up to date in the background.214+              </p>215+              <button216+                class="peach-btn peach-btn--sm"217+                onClick={props.onLearnPro}218+              >219+                Upgrade to Pro220+              </button>221+            </div>222+          </Show>223+224+          {/* State C: network note */}225+          <Show when={props.isPro}>226+            <p class="peach-sync-network-note">227+              Sync works on your home and trusted networks. Some228+              public or corporate networks block device connections.229+            </p>230+          </Show>231+232+        </Show>233+234+        {/* ── Advanced (all states, collapsed) ── */}235+        <div class="peach-sync-advanced">236+          <button237+            class="peach-sync-advanced-toggle"238+            onClick={() => setShowAdvanced(v => !v)}239+          >240+            <span class={`peach-sync-advanced-chevron${showAdvanced() ? ' is-open' : ''}`}>241+              ▸242+            </span>243+            Advanced244+          </button>245+246+          <Show when={showAdvanced()}>247+            <div class="peach-sync-advanced-content">248+              <div class="peach-sync-advanced-row">249+                <div>250+                  <p class="peach-sync-advanced-title">251+                    Reset sync identity252+                  </p>253+                  <p class="peach-sync-advanced-subtitle">254+                    Generates a new keypair and unpairs every device.255+                  </p>256+                </div>257+                <button258+                  class="peach-btn peach-btn--danger peach-btn--sm"259+                  onClick={handleResetIdentity}260+                  disabled={resetting() || !props.isPro}261+                >262+                  {resetting() ? 'Resetting…' : 'Reset'}263+                </button>264+              </div>265+            </div>266+          </Show>267+        </div>268+269+      </div>270+    </div>271+  );272+};273+274+// ── Add Device — QR pairing flow ─────────────────────────────────────────────275+276+interface AddDeviceProps {8277   onClose: () => void;9278   onPaired: (device: PairedDevice) => void;10279 }@@ -13,35 +282,28 @@13282   return <span class="peach-pairing-spinner" />;14283 }15284 16-export function SyncPairingView(props: SyncPairingProps) {285+export function AddDeviceView(props: AddDeviceProps) {17286   const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);18287   const [timeLeft, setTimeLeft] = createSignal<number>(0);19288   const [expiresAt, setExpiresAt] = createSignal<number | null>(null);20289   const [showError, setShowError] = createSignal<string | null>(null);21-22290   const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);23291 24292   let expiryTimer: ReturnType<typeof setTimeout> | null = null;25293   let countdownInterval: ReturnType<typeof setInterval> | null = null;26294 27295   function clearExpiryTimer() {28-    if (expiryTimer !== null) {29-      clearTimeout(expiryTimer);30-      expiryTimer = null;31-    }32-    if (countdownInterval !== null) {33-      clearInterval(countdownInterval);34-      countdownInterval = null;35-    }296+    if (expiryTimer !== null) { clearTimeout(expiryTimer); expiryTimer = null; }297+    if (countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }36298   }37299 38300   function handleRuntimeMessage(msg: unknown) {39301     const m = msg as { type?: string; payload?: { deviceName?: string } };40-    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {302+    if (m?.type === 'PAIRING_COMPLETED' && m.payload?.deviceName) {41303       const device: PairedDevice = {42304         deviceId: m.payload.deviceName,43305         displayName: m.payload.deviceName,44-        x25519PublicKey: "",306+        x25519PublicKey: '',45307         pairedAt: Date.now(),46308       };47309       clearExpiryTimer();@@ -60,46 +322,32 @@60322     setExpiresAt(null);61323     setTimeLeft(0);62324     clearExpiryTimer();63-    const res = await sendMessage({ type: "START_PAIRING_SERVER" });64-    const response = res as {65-      ok: boolean;66-      error?: string;67-      data?: { token: string; expiresAt: number };68-    };325+    const res = await sendMessage({ type: 'START_PAIRING_SERVER' });326+    const response = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };69327     if (!response.ok || !response.data) {70328       setShowError("Couldn't start the pairing server.");71329       return;72330     }73-74331     const { token, expiresAt: expAt } = response.data;75332     const url = await QRCode.toDataURL('peach-pair:' + token, {76333       width: 240,77334       margin: 2,78-      color: {79-        dark: '#E8E0D0',80-        light: '#00000000',81-      },335+      color: { dark: '#E8E0D0', light: '#00000000' },82336     });83337     setQrDataUrl(url);84338     setExpiresAt(expAt);85-86339     const remaining = Math.max(0, expAt - Date.now());87340     setTimeLeft(Math.floor(remaining / 1000));88-89341     countdownInterval = setInterval(() => {90342       const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));91343       setTimeLeft(secs);92-      if (secs === 0 && countdownInterval !== null) {93-        clearInterval(countdownInterval);94-        countdownInterval = null;95-      }344+      if (secs === 0 && countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }96345     }, 1000);97-98346     expiryTimer = setTimeout(() => {99347       setQrDataUrl(null);100348       setExpiresAt(null);101349       setTimeLeft(0);102-      setShowError("Code expired — tap to refresh.");350+      setShowError('Code expired — tap to refresh.');103351     }, remaining);104352   }105353 @@ -108,7 +356,7 @@108356     setQrDataUrl(null);109357     setExpiresAt(null);110358     setTimeLeft(0);111-    await sendMessage({ type: "STOP_PAIRING_SERVER" });359+    await sendMessage({ type: 'STOP_PAIRING_SERVER' });112360   }113361 114362   async function generateCode() {@@ -124,13 +372,9 @@124372   onCleanup(() => {125373     chrome.runtime.onMessage.removeListener(handleRuntimeMessage);126374     clearExpiryTimer();127-    // Pairing server keeps running in the background per spec — the user128-    // showed the code, so a peer can still complete pairing after the popup129-    // closes. The background clears it on success or vault-lock.375+    // Pairing server keeps running — peer can complete after popup closes.130376   });131377 132-  // expiresAt is stored for completeness; clearExpiryTimer is the primary133-  // expiry mechanism.134378   void expiresAt;135379 136380   return (@@ -138,10 +382,7 @@138382       <div class="peach-overlay-header">139383         <button140384           class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"141-          onClick={() => {142-            void sendMessage({ type: "STOP_PAIRING_SERVER" });143-            props.onClose();144-          }}385+          onClick={() => { void sendMessage({ type: 'STOP_PAIRING_SERVER' }); props.onClose(); }}145386           aria-label="Close"146387         >147388           <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">@@ -196,11 +437,7 @@196437             }>197438               {(url) => (198439                 <div class="peach-pairing-qr-container">199-                  <img200-                    class="peach-pairing-qr"201-                    src={url()}202-                    alt="Pairing QR"203-                  />440+                  <img class="peach-pairing-qr" src={url()} alt="Pairing QR" />204441                   <p class="peach-pairing-instruction">205442                     Open Peach on your phone → Add a device → Scan this code.206443                   </p>

view file

src/shared/types.ts+2 −0
@@ -245,6 +245,8 @@245245   | "CHANGE_PASSWORD"246246   | "GET_SITE_PASSWORD_POLICY"247247   | "GET_SYNC_STATE"248+  | "GET_PAIRED_DEVICES"249+  | "GET_SYNC_STATUS"248250   | "TRIGGER_SYNC"249251   | "ACCEPT_PAIRING"250252   | "REMOVE_PAIRED_DEVICE"

view file