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
↓ peach-browser-a340155d.tar.gzatlas at this save
files changed9 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 −0manifest.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": {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",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": {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 };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>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+}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>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>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"