e7752164f70c
replace 6-digit pairing code with QR; expose Add a device to free users in Sync settings
- time
- 2026-05-09 01:21 UTC · 5 months ago
- machine
- koh2
- parent
- 739bb2d48de0
↓ peach-browser-e7752164.tar.gzatlas at this save
files changed4 files
src/popup/App.tsx+2 −2src/popup/styles/popup.css+49 −0src/popup/views/Settings.tsx+121 −156src/popup/views/SyncPairing.tsx+66 −26src/popup/App.tsx+2 −2
@@ -592,8 +592,8 @@592592 // - Strong downward velocity → close593593 // - Otherwise snap to whichever rest is closer594594 let nextOpen: boolean;595- if (velocity < -0.2) nextOpen = true;596- else if (velocity > 0.2) nextOpen = false;595+ if (velocity < -0.05) nextOpen = true;596+ else if (velocity > 0.05) nextOpen = false;597597 else nextOpen = current < restClosed * 0.5;598598 599599 if (nextOpen && v !== "list") setView("list");src/popup/styles/popup.css+49 −0
@@ -4028,6 +4028,14 @@40284028 letter-spacing: 0.05em;40294029 }40304030 4031+.peach-sync-pro-hint {4032+ display: flex;4033+ flex-direction: column;4034+ align-items: flex-start;4035+ gap: 8px;4036+ padding: 12px 0 4px;4037+}4038+40314039 .peach-sync-device-row {40324040 display: flex;40334041 justify-content: space-between;@@ -4895,6 +4903,47 @@48954903 color: var(--peach-t2);48964904 cursor: pointer;48974905 text-shadow: none;4906+}4907+4908+.peach-pairing-qr-container {4909+ display: flex;4910+ flex-direction: column;4911+ align-items: center;4912+ gap: 10px;4913+}4914+4915+.peach-pairing-qr {4916+ width: 240px;4917+ height: 240px;4918+ display: block;4919+}4920+4921+.peach-pairing-instruction {4922+ font-size: 0.75rem;4923+ color: var(--peach-t2);4924+ text-align: center;4925+ line-height: 1.5;4926+ margin: 0;4927+ max-width: 220px;4928+}4929+4930+.peach-pairing-expiry {4931+ font-family: "IBM Plex Mono", monospace;4932+ font-size: 13px;4933+ letter-spacing: 0.06em;4934+ color: var(--peach-t2);4935+ margin: 0;4936+}4937+4938+.peach-pairing-regen {4939+ font-size: 0.75rem;4940+ color: var(--peach-accent);4941+ background: transparent;4942+ border: 0;4943+ cursor: pointer;4944+ padding: 4px 0;4945+ text-decoration: underline;4946+ text-underline-offset: 2px;48984947 }48994948 49004949 .peach-pairing-code-input {src/popup/views/Settings.tsx+121 −156
@@ -1297,94 +1297,10 @@12971297 }12981298 12991299 function renderSync() {1300- if (!isPro()) {1301- return (1302- <div class="peach-settings-body">1303- <div class="peach-pro-upgrade">1304- <div class="peach-pro-upgrade-hero">1305- <h2 class="peach-pro-upgrade-title">Peach Sync</h2>1306- <p class="peach-pro-upgrade-tagline">Encrypted · peer-to-peer · local</p>1307- </div>1308-1309- <div class="peach-pro-features">1310- <div class="peach-pro-feature">1311- <span class="peach-pro-feature-mark">✦</span>1312- <div class="peach-pro-feature-text">1313- <span class="peach-pro-feature-name">Always in sync</span>1314- <span class="peach-pro-feature-desc">Your vault stays current across all your devices automatically</span>1315- </div>1316- </div>1317- <div class="peach-pro-feature">1318- <span class="peach-pro-feature-mark">✦</span>1319- <div class="peach-pro-feature-text">1320- <span class="peach-pro-feature-name">No cloud. No relay.</span>1321- <span class="peach-pro-feature-desc">Direct device-to-device on your local network — nothing leaves your hands</span>1322- </div>1323- </div>1324- <div class="peach-pro-feature">1325- <span class="peach-pro-feature-mark">✦</span>1326- <div class="peach-pro-feature-text">1327- <span class="peach-pro-feature-name">Tailscale supported</span>1328- <span class="peach-pro-feature-desc">Sync securely from anywhere over your private Tailscale network</span>1329- </div>1330- </div>1331- <div class="peach-pro-feature">1332- <span class="peach-pro-feature-mark">✦</span>1333- <div class="peach-pro-feature-text">1334- <span class="peach-pro-feature-name">Zero friction after setup</span>1335- <span class="peach-pro-feature-desc">Mark a network as trusted once — sync happens in the background</span>1336- </div>1337- </div>1338- </div>1339-1340- <button1341- class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1342- onClick={() => setActiveSection("license")}1343- >1344- Upgrade to Pro1345- </button>1346- <button class="peach-pro-key-link" onClick={() => setActiveSection("license")}>1347- Already have a key? Enter it →1348- </button>1349- </div>1350- </div>1351- );1352- }1353-13541300 return (13551301 <div class="peach-settings-body">13561302 <div class="peach-settings-group">1357- <div class="peach-settings-row">1358- <span>Status</span>1359- <span class="peach-settings-value peach-settings-value--status">1360- <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1361- {getStatusLabel(syncStatus())}1362- </span>1363- </div>1364- <div class="peach-settings-row peach-settings-row--meta">1365- <span>Last sync</span>1366- <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1367- </div>1368- <div class="peach-settings-row peach-settings-row--meta">1369- <button1370- class="peach-btn peach-btn--subtle peach-btn--sm"1371- onClick={() => void handleSyncNow()}1372- disabled={syncStatus() === "not_trusted" || syncingNow()}1373- >1374- <Switch>1375- <Match when={syncingNow()}>Syncing…</Match>1376- <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1377- <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1378- <Match when={true}>Sync now</Match>1379- </Switch>1380- </button>1381- </div>1382- <p class="peach-settings-hint">1383- If you edit on multiple devices before syncing, the most recently1384- changed device wins. Use Vault History above to recover older1385- versions if needed.1386- </p>1387-1303+ {/* Pairing is free — always show Add a device and paired list */}13881304 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">13891305 <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>13901306 <Show when={pairedDevices().length === 0}>@@ -1426,86 +1342,135 @@14261342 </div>14271343 </div>14281344 1429- <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1430- <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1431- <For each={trustedNetworks()}>1432- {(network) => (1433- <div class="peach-sync-device-row">1434- <span class="peach-sync-device-name">1435- {network.isTailscale ? "Tailscale" : (network.ssid ?? "Unknown network")}1345+ {/* Automatic sync is Pro-gated */}1346+ <Show1347+ when={!isPro()}1348+ fallback={1349+ <>1350+ <div class="peach-settings-row">1351+ <span>Status</span>1352+ <span class="peach-settings-value peach-settings-value--status">1353+ <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>1354+ {getStatusLabel(syncStatus())}14361355 </span>1437- <Show1438- when={!network.isTailscale}1439- fallback={<span class="peach-sync-device-meta">Always trusted</span>}1440- >1441- <button1442- class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1443- onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1444- >1445- Remove1446- </button>1447- </Show>14481356 </div>1449- )}1450- </For>1451- <div class="peach-settings-actions--row">1452- <Show when={!isCurrentNetworkTrusted()}>1453- <button1454- class="peach-btn peach-btn--subtle peach-btn--sm"1455- onClick={() => void handleMarkNetworkTrusted()}1456- disabled={markingNetwork()}1457- >1458- {markingNetwork() ? "Marking…" : "Mark current network as trusted"}1459- </button>1460- </Show>1461- </div>1462- </div>1463-1464- <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1465- <span class="peach-sync-section-label">Advanced</span>1466- <Show1467- when={resetIdentityConfirm()}1468- fallback={1469- <div class="peach-sync-advanced-row">1470- <div class="peach-sync-advanced-info">1471- <span class="peach-sync-advanced-name">Reset sync identity</span>1472- <span class="peach-sync-device-meta">1473- Generates a new keypair and unpairs every device.1474- </span>1475- </div>1357+ <div class="peach-settings-row peach-settings-row--meta">1358+ <span>Last sync</span>1359+ <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>1360+ </div>1361+ <div class="peach-settings-row peach-settings-row--meta">14761362 <button1477- class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1478- onClick={() => setResetIdentityConfirm(true)}1363+ class="peach-btn peach-btn--subtle peach-btn--sm"1364+ onClick={() => void handleSyncNow()}1365+ disabled={syncStatus() === "not_trusted" || syncingNow()}14791366 >1480- Reset1367+ <Switch>1368+ <Match when={syncingNow()}>Syncing…</Match>1369+ <Match when={syncFeedback()?.kind === "ok"}>✓ {syncFeedback()!.text}</Match>1370+ <Match when={syncFeedback()?.kind === "err"}>{syncFeedback()!.text}</Match>1371+ <Match when={true}>Sync now</Match>1372+ </Switch>14811373 </button>14821374 </div>1483- }1484- >1485- <div class="peach-sync-reset-confirm">1486- <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1487- <p class="peach-sync-reset-confirm-body">1488- This will unpair all devices. You will need to pair them again from scratch.1375+ <p class="peach-settings-hint">1376+ If you edit on multiple devices before syncing, the most recently1377+ changed device wins. Use Vault History above to recover older1378+ versions if needed.14891379 </p>1490- <div class="peach-settings-actions--row">1491- <button1492- class="peach-btn peach-btn--ghost peach-btn--sm"1493- onClick={() => setResetIdentityConfirm(false)}1494- disabled={resetIdentityRunning()}1380+1381+ <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1382+ <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>1383+ <For each={trustedNetworks()}>1384+ {(network) => (1385+ <div class="peach-sync-device-row">1386+ <span class="peach-sync-device-name">1387+ {network.isTailscale ? "Tailscale" : (network.ssid ?? "Unknown network")}1388+ </span>1389+ <Show1390+ when={!network.isTailscale}1391+ fallback={<span class="peach-sync-device-meta">Always trusted</span>}1392+ >1393+ <button1394+ class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1395+ onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}1396+ >1397+ Remove1398+ </button>1399+ </Show>1400+ </div>1401+ )}1402+ </For>1403+ <div class="peach-settings-actions--row">1404+ <Show when={!isCurrentNetworkTrusted()}>1405+ <button1406+ class="peach-btn peach-btn--subtle peach-btn--sm"1407+ onClick={() => void handleMarkNetworkTrusted()}1408+ disabled={markingNetwork()}1409+ >1410+ {markingNetwork() ? "Marking…" : "Mark current network as trusted"}1411+ </button>1412+ </Show>1413+ </div>1414+ </div>1415+1416+ <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">1417+ <span class="peach-sync-section-label">Advanced</span>1418+ <Show1419+ when={resetIdentityConfirm()}1420+ fallback={1421+ <div class="peach-sync-advanced-row">1422+ <div class="peach-sync-advanced-info">1423+ <span class="peach-sync-advanced-name">Reset sync identity</span>1424+ <span class="peach-sync-device-meta">1425+ Generates a new keypair and unpairs every device.1426+ </span>1427+ </div>1428+ <button1429+ class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"1430+ onClick={() => setResetIdentityConfirm(true)}1431+ >1432+ Reset1433+ </button>1434+ </div>1435+ }14951436 >1496- Cancel1497- </button>1498- <button1499- class="peach-btn peach-btn--danger peach-btn--sm"1500- onClick={() => void handleResetSyncIdentity()}1501- disabled={resetIdentityRunning()}1502- >1503- {resetIdentityRunning() ? "Resetting..." : "Reset"}1504- </button>1437+ <div class="peach-sync-reset-confirm">1438+ <p class="peach-sync-reset-confirm-headline">Reset sync identity?</p>1439+ <p class="peach-sync-reset-confirm-body">1440+ This will unpair all devices. You will need to pair them again from scratch.1441+ </p>1442+ <div class="peach-settings-actions--row">1443+ <button1444+ class="peach-btn peach-btn--ghost peach-btn--sm"1445+ onClick={() => setResetIdentityConfirm(false)}1446+ disabled={resetIdentityRunning()}1447+ >1448+ Cancel1449+ </button>1450+ <button1451+ class="peach-btn peach-btn--danger peach-btn--sm"1452+ onClick={() => void handleResetSyncIdentity()}1453+ disabled={resetIdentityRunning()}1454+ >1455+ {resetIdentityRunning() ? "Resetting..." : "Reset"}1456+ </button>1457+ </div>1458+ </div>1459+ </Show>15051460 </div>1506- </div>1507- </Show>1508- </div>1461+ </>1462+ }1463+ >1464+ <div class="peach-sync-pro-hint">1465+ <p class="peach-settings-hint">Automatic sync is a Peach Pro feature.</p>1466+ <button1467+ class="peach-btn peach-btn--subtle peach-btn--sm"1468+ onClick={() => setActiveSection("license")}1469+ >1470+ Upgrade to Pro1471+ </button>1472+ </div>1473+ </Show>15091474 </div>15101475 </div>15111476 );src/popup/views/SyncPairing.tsx+66 −26
@@ -1,3 +1,4 @@1+import QRCode from 'qrcode';12 import { createSignal, onCleanup, onMount, Show } from "solid-js";23 import { sendMessage } from "../App";34 import type { PairedDevice } from "../../shared/sync-types";@@ -11,25 +12,26 @@1112 return <span class="peach-pairing-spinner" />;1213 }1314 14-function formatCode(code: string): string {15- if (code.length <= 3) return code;16- return `${code.slice(0, 3)} ${code.slice(3)}`;17-}18-1915 export function SyncPairingView(props: SyncPairingProps) {20- const [code, setCode] = createSignal<string | null>(null);16+ const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);17+ const [timeLeft, setTimeLeft] = createSignal<number>(0);2118 const [expiresAt, setExpiresAt] = createSignal<number | null>(null);2219 const [showError, setShowError] = createSignal<string | null>(null);2320 2421 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);2522 2623 let expiryTimer: ReturnType<typeof setTimeout> | null = null;24+ let countdownInterval: ReturnType<typeof setInterval> | null = null;2725 2826 function clearExpiryTimer() {2927 if (expiryTimer !== null) {3028 clearTimeout(expiryTimer);3129 expiryTimer = null;3230 }31+ if (countdownInterval !== null) {32+ clearInterval(countdownInterval);33+ countdownInterval = null;34+ }3335 }3436 3537 function handleRuntimeMessage(msg: unknown) {@@ -42,8 +44,9 @@4244 pairedAt: Date.now(),4345 };4446 clearExpiryTimer();45- setCode(null);47+ setQrDataUrl(null);4648 setExpiresAt(null);49+ setTimeLeft(0);4750 setPairedDevice(device);4851 props.onPaired(device);4952 }@@ -51,8 +54,9 @@5154 5255 async function startShow() {5356 setShowError(null);54- setCode(null);57+ setQrDataUrl(null);5558 setExpiresAt(null);59+ setTimeLeft(0);5660 clearExpiryTimer();5761 const res = await sendMessage({ type: "START_PAIRING_SERVER" });5862 const response = res as {@@ -68,24 +72,48 @@6872 );6973 return;7074 }71- setCode(response.data.token);72- setExpiresAt(response.data.expiresAt);73- const remaining = Math.max(0, response.data.expiresAt - Date.now());75+76+ const { token, expiresAt: expAt } = response.data;77+ const url = await QRCode.toDataURL('peach-pair:' + token, {78+ width: 240,79+ margin: 2,80+ color: {81+ dark: '#E8E0D0',82+ light: '#00000000',83+ },84+ });85+ setQrDataUrl(url);86+ setExpiresAt(expAt);87+88+ const remaining = Math.max(0, expAt - Date.now());89+ setTimeLeft(Math.floor(remaining / 1000));90+91+ countdownInterval = setInterval(() => {92+ const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));93+ setTimeLeft(secs);94+ if (secs === 0 && countdownInterval !== null) {95+ clearInterval(countdownInterval);96+ countdownInterval = null;97+ }98+ }, 1000);99+74100 expiryTimer = setTimeout(() => {75- setCode(null);101+ setQrDataUrl(null);76102 setExpiresAt(null);103+ setTimeLeft(0);77104 setShowError("Code expired — tap to refresh.");78105 }, remaining);79106 }80107 81108 async function stopShow() {82109 clearExpiryTimer();83- setCode(null);110+ setQrDataUrl(null);84111 setExpiresAt(null);112+ setTimeLeft(0);85113 await sendMessage({ type: "STOP_PAIRING_SERVER" });86114 }87115 88- async function refreshCode() {116+ async function generateCode() {89117 await stopShow();90118 await startShow();91119 }@@ -103,8 +131,8 @@103131 // closes. The background clears it on success or vault-lock.104132 });105133 106- // expiresAt is read by callers that want to display a countdown; keep the107- // signal even though this view itself only uses it as a refresh trigger.134+ // expiresAt is stored for completeness; clearExpiryTimer is the primary135+ // expiry mechanism.108136 void expiresAt;109137 110138 return (@@ -154,11 +182,11 @@154182 <div class="peach-pairing-content peach-pairing-code-screen">155183 <p class="peach-pairing-headline">Add a device</p>156184 157- <Show when={code()} fallback={185+ <Show when={qrDataUrl()} fallback={158186 <Show when={!showError()} fallback={159187 <button160188 class="peach-pairing-code peach-pairing-code--expired"161- onClick={() => void refreshCode()}189+ onClick={() => void generateCode()}162190 >163191 refresh164192 </button>@@ -168,18 +196,30 @@168196 </div>169197 </Show>170198 }>171- {(c) => (172- <div class="peach-pairing-code" aria-label="pairing code">173- {formatCode(c())}199+ {(url) => (200+ <div class="peach-pairing-qr-container">201+ <img202+ class="peach-pairing-qr"203+ src={url()}204+ alt="Pairing QR"205+ />206+ <p class="peach-pairing-instruction">207+ Open Peach on your phone → Add a device → Scan this code.208+ </p>209+ <Show when={timeLeft() > 0}>210+ <p class="peach-pairing-expiry">211+ {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}212+ </p>213+ </Show>214+ <Show when={timeLeft() === 0}>215+ <button class="peach-pairing-regen" onClick={() => void generateCode()}>216+ Generate new code217+ </button>218+ </Show>174219 </div>175220 )}176221 </Show>177222 178- <p class="peach-pairing-subline peach-pairing-subline--center">179- Open Peach on your other device and scan180- <br />181- this code to pair.182- </p>183223 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>184224 185225 <Show when={showError()}>