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
4 files src/popup/App.tsx+2 −2src/popup/styles/popup.css+49 −0src/popup/views/Settings.tsx+121 −156src/popup/views/SyncPairing.tsx+66 −26
src/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");

view file

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 {

view file

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     );

view file

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()}>

view file