1import QRCode from 'qrcode';2import { Component, createSignal, For, onCleanup, onMount, Show } from 'solid-js';3import { sendMessage } from '../App';4import type { PairedDevice } from '../../shared/sync-types';5import { getHasPaired, setHasPaired } from '../../background/sync-storage';6import { SyncInfoSheet } from '../components/SyncInfoSheet';7import { isNativePlatform } from '../../shared/native';891011function formatRelativeTime(ts: number): string {12 const diff = Date.now() - ts;13 const mins = Math.floor(diff / 60_000);14 if (mins < 1) return 'just now';15 if (mins < 60) return `${mins} ${mins === 1 ? 'minute' : 'minutes'} ago`;16 const hrs = Math.floor(mins / 60);17 if (hrs < 24) return `${hrs} ${hrs === 1 ? 'hour' : 'hours'} ago`;18 const days = Math.floor(hrs / 24);19 if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'} ago`;20 return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });21}22232425interface SyncPairingProps {26 isPro: boolean;27 onAddDevice: () => void;28 onLearnPro: () => void;29 onBack: () => void;30}3132export const SyncPairingView: Component<SyncPairingProps> = (props) => {33 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);34 const [hasPaired, setHasPairedSignal] = createSignal(false);35 const [syncStatus, setSyncStatus] = createSignal<'up_to_date' | 'unreachable' | 'syncing' | 'never'>('never');36 const [lastSyncAt, setLastSyncAt] = createSignal<number | null>(null);37 const [syncing, setSyncing] = createSignal(false);38 const [showAdvanced, setShowAdvanced] = createSignal(false);39 const [resetting, setResetting] = createSignal(false);40 const [showInfoSheet, setShowInfoSheet] = createSignal(false);4142 onMount(async () => {43 const [paired, devices, syncInfo] = await Promise.all([44 getHasPaired(),45 sendMessage({ type: 'GET_PAIRED_DEVICES' }),46 sendMessage({ type: 'GET_SYNC_STATUS' }),47 ]);4849 setHasPairedSignal(paired);50 if (!paired && !props.isPro) setShowInfoSheet(true);5152 const d = devices as { ok: boolean; data?: { devices?: PairedDevice[] } };53 if (d?.ok) setPairedDevices(d.data?.devices ?? []);5455 const s = syncInfo as { ok: boolean; data?: { status?: 'up_to_date' | 'unreachable' | 'never'; lastSyncAt?: number | null } };56 if (s?.ok) {57 setSyncStatus(s.data?.status ?? 'never');58 setLastSyncAt(s.data?.lastSyncAt ?? null);59 }60 });6162 async function handleSyncNow() {63 setSyncing(true);64 const result = await sendMessage({ type: 'TRIGGER_SYNC' });65 setSyncing(false);66 const r = result as { ok: boolean; syncedCount?: number };67 if (r?.ok && (r.syncedCount ?? 0) > 0) {68 setLastSyncAt(Date.now());69 setSyncStatus('up_to_date');70 const devices = await sendMessage({ type: 'GET_PAIRED_DEVICES' });71 const d = devices as { ok: boolean; devices?: PairedDevice[] };72 if (d?.ok) setPairedDevices(d.devices ?? []);73 }74 }7576 async function handleResetIdentity() {77 if (!confirm('This will unpair all devices. Are you sure?')) return;78 setResetting(true);79 await sendMessage({ type: 'RESET_SYNC_IDENTITY' });80 setResetting(false);81 setPairedDevices([]);82 setHasPairedSignal(false);83 }8485 return (86 <div class="peach-sync-screen peach-overlay-view ov-enter-right">87 <Show when={showInfoSheet()}>88 <SyncInfoSheet89 onGotIt={() => setShowInfoSheet(false)}90 onLearnPro={() => { setShowInfoSheet(false); props.onLearnPro(); }}91 />92 </Show>9394 <div class="peach-overlay-header">95 <button96 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"97 onClick={props.onBack}98 aria-label="Back"99 >100 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">101 <path d="M6 1L1 6L6 11" />102 </svg>103 </button>104 <span class="peach-overlay-title">SYNC</span>105 <div class="peach-overlay-spacer" />106 </div>107108 <div class="peach-sync-content">109110 {}111 <Show when={!hasPaired()}>112 <div class="peach-sync-intro">113 <h2 class="peach-sync-headline">sync your vault.</h2>114 <p class="peach-sync-intro-body">115 Peach can transfer your vault to another device instantly116 — just scan a code.117 </p>118 </div>119120 <button121 class="peach-btn peach-btn--primary peach-btn--full"122 onClick={props.onAddDevice}123 >124 Add a device125 </button>126127 <div class="peach-sync-pro-note">128 <p class="peach-sync-pro-note-text">129 Continuous sync across multiple devices requires Peach Pro.130 </p>131 <button132 class="peach-sync-pro-link"133 onClick={props.onLearnPro}134 >135 Learn about Pro →136 </button>137 </div>138 </Show>139140 {}141 <Show when={hasPaired()}>142143 {}144 <Show when={props.isPro}>145 <div class="peach-sync-status">146 <Show147 when={syncStatus() !== 'unreachable'}148 fallback={149 <div class="peach-sync-status-row">150 <span class="peach-sync-dot peach-sync-dot--amber" />151 <span class="peach-sync-status-text">152 Not on a compatible network153 </span>154 </div>155 }156 >157 <div class="peach-sync-status-row">158 <span class="peach-sync-dot peach-sync-dot--green" />159 <span class="peach-sync-status-text">160 <Show when={lastSyncAt()} fallback="Never synced">161 Last synced {formatRelativeTime(lastSyncAt()!)}162 </Show>163 </span>164 </div>165 </Show>166 </div>167 </Show>168169 {}170 <Show when={pairedDevices().length > 0}>171 <div class="peach-sync-section">172 <p class="peach-sync-section-label">PAIRED DEVICES</p>173 <For each={pairedDevices()}>174 {(device) => (175 <div class="peach-sync-device-row">176 <span class="peach-sync-device-name">177 {device.displayName}178 </span>179 <span class="peach-sync-device-meta">180 {device.lastSyncAt181 ? formatRelativeTime(device.lastSyncAt)182 : 'Paired — never synced'}183 </span>184 </div>185 )}186 </For>187 </div>188 </Show>189190 {}191 <div class="peach-sync-actions">192 <button193 class="peach-btn peach-btn--sm"194 onClick={props.onAddDevice}195 >196 Add a device197 </button>198 <Show when={props.isPro}>199 <button200 class="peach-btn peach-btn--sm"201 onClick={handleSyncNow}202 disabled={syncing()}203 >204 {syncing() ? 'Syncing…' : 'Sync now'}205 </button>206 </Show>207 </div>208209 {}210 <Show when={!props.isPro}>211 <div class="peach-sync-upgrade">212 <p class="peach-sync-upgrade-text">213 Want automatic sync? Upgrade to Peach Pro to keep214 all your devices up to date in the background.215 </p>216 <button217 class="peach-btn peach-btn--sm"218 onClick={props.onLearnPro}219 >220 Upgrade to Pro221 </button>222 </div>223 </Show>224225 {}226 <Show when={props.isPro}>227 <p class="peach-sync-network-note">228 Sync works on your home and trusted networks. Some229 public or corporate networks block device connections.230 </p>231 </Show>232233 </Show>234235 {}236 <div class="peach-sync-advanced">237 <button238 class="peach-sync-advanced-toggle"239 onClick={() => setShowAdvanced(v => !v)}240 >241 <span class={`peach-sync-advanced-chevron${showAdvanced() ? ' is-open' : ''}`}>242 ▸243 </span>244 Advanced245 </button>246247 <Show when={showAdvanced()}>248 <div class="peach-sync-advanced-content">249 <div class="peach-sync-advanced-row">250 <div>251 <p class="peach-sync-advanced-title">252 Reset sync identity253 </p>254 <p class="peach-sync-advanced-subtitle">255 Generates a new keypair and unpairs every device.256 </p>257 </div>258 <button259 class="peach-btn peach-btn--danger peach-btn--sm"260 onClick={handleResetIdentity}261 disabled={resetting() || !props.isPro}262 >263 {resetting() ? 'Resetting…' : 'Reset'}264 </button>265 </div>266 </div>267 </Show>268 </div>269270 </div>271 </div>272 );273};274275276277interface AddDeviceProps {278 onClose: () => void;279 onPaired: (device: PairedDevice) => void;280}281282function Spinner() {283 return <span class="peach-pairing-spinner" />;284}285286type JsQRFn = (data: Uint8ClampedArray, width: number, height: number) => { data: string } | null;287288export function AddDeviceView(props: AddDeviceProps) {289 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);290 const [timeLeft, setTimeLeft] = createSignal<number>(0);291 const [expiresAt, setExpiresAt] = createSignal<number | null>(null);292 const [showError, setShowError] = createSignal<string | null>(null);293 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);294 295 const [tab, setTab] = createSignal<'scan' | 'show'>(isNativePlatform() ? 'scan' : 'show');296297 let videoRef: HTMLVideoElement | undefined;298 let canvasRef: HTMLCanvasElement | undefined;299 let animRef: number | null = null;300 let jsQRFn: JsQRFn | null = null;301302 let expiryTimer: ReturnType<typeof setTimeout> | null = null;303 let countdownInterval: ReturnType<typeof setInterval> | null = null;304305 function clearExpiryTimer() {306 if (expiryTimer !== null) { clearTimeout(expiryTimer); expiryTimer = null; }307 if (countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }308 }309310 function handleRuntimeMessage(msg: unknown) {311 const m = msg as { type?: string; payload?: { deviceName?: string } };312 if (m?.type === 'PAIRING_COMPLETED' && m.payload?.deviceName) {313 const device: PairedDevice = {314 deviceId: m.payload.deviceName,315 displayName: m.payload.deviceName,316 x25519PublicKey: '',317 pairedAt: Date.now(),318 };319 clearExpiryTimer();320 stopCamera();321 setQrDataUrl(null);322 setExpiresAt(null);323 setTimeLeft(0);324 setPairedDevice(device);325 void setHasPaired();326 props.onPaired(device);327 }328 }329330 async function startShow() {331 setShowError(null);332 setQrDataUrl(null);333 setExpiresAt(null);334 setTimeLeft(0);335 clearExpiryTimer();336 const res = await sendMessage({ type: 'START_PAIRING_SERVER' });337 const response = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };338 if (!response.ok || !response.data) {339 setShowError("Couldn't start the pairing server.");340 return;341 }342 const { token, expiresAt: expAt } = response.data;343 const url = await QRCode.toDataURL('peach-pair:' + token, {344 width: 240,345 margin: 2,346 color: { dark: '#E8E0D0', light: '#00000000' },347 });348 setQrDataUrl(url);349 setExpiresAt(expAt);350 const remaining = Math.max(0, expAt - Date.now());351 setTimeLeft(Math.floor(remaining / 1000));352 countdownInterval = setInterval(() => {353 const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));354 setTimeLeft(secs);355 if (secs === 0 && countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }356 }, 1000);357 expiryTimer = setTimeout(() => {358 setQrDataUrl(null);359 setExpiresAt(null);360 setTimeLeft(0);361 setShowError('Code expired — tap to refresh.');362 }, remaining);363 }364365 async function stopShow() {366 clearExpiryTimer();367 setQrDataUrl(null);368 setExpiresAt(null);369 setTimeLeft(0);370 await sendMessage({ type: 'STOP_PAIRING_SERVER' });371 }372373 async function generateCode() {374 await stopShow();375 await startShow();376 }377378 async function startCamera() {379 setShowError(null);380 if (!jsQRFn) {381 jsQRFn = ((await import('jsqr')) as { default: JsQRFn }).default;382 }383 try {384 const stream = await navigator.mediaDevices.getUserMedia({385 video: { facingMode: 'environment' },386 audio: false,387 });388 if (!videoRef) { for (const t of stream.getTracks()) t.stop(); return; }389 videoRef.srcObject = stream;390 await videoRef.play();391 scanLoop();392 } catch {393 setShowError('Camera unavailable — check camera permissions.');394 }395 }396397 function stopCamera() {398 if (videoRef) {399 const stream = videoRef.srcObject as MediaStream | null;400 if (stream) {401 for (const track of stream.getTracks()) track.stop();402 videoRef.srcObject = null;403 }404 }405 if (animRef !== null) { cancelAnimationFrame(animRef); animRef = null; }406 }407408 function scanLoop() {409 if (tab() !== 'scan' || !videoRef || !canvasRef || !jsQRFn) return;410 if (videoRef.readyState >= videoRef.HAVE_ENOUGH_DATA) {411 const ctx = canvasRef.getContext('2d');412 if (ctx) {413 canvasRef.width = videoRef.videoWidth;414 canvasRef.height = videoRef.videoHeight;415 ctx.drawImage(videoRef, 0, 0);416 const imageData = ctx.getImageData(0, 0, canvasRef.width, canvasRef.height);417 const code = jsQRFn(imageData.data, imageData.width, imageData.height);418 if (code?.data?.startsWith('peach-pair:')) {419 stopCamera();420 const token = code.data.slice('peach-pair:'.length);421 void handleScannedToken(token);422 return;423 }424 }425 }426 animRef = requestAnimationFrame(scanLoop);427 }428429 async function handleScannedToken(token: string) {430 setShowError(null);431 const result = await sendMessage({ type: 'SCAN_PAIRING_QR', payload: { token } });432 const r = result as { ok: boolean; error?: string };433 if (!r?.ok) {434 setShowError('Could not connect. Try again.');435 void startCamera();436 }437 }438439 onMount(() => {440 chrome.runtime.onMessage.addListener(handleRuntimeMessage);441 if (isNativePlatform()) {442 void startCamera();443 } else {444 void startShow();445 }446 });447448 onCleanup(() => {449 chrome.runtime.onMessage.removeListener(handleRuntimeMessage);450 clearExpiryTimer();451 stopCamera();452 453 });454455 void expiresAt;456457 return (458 <div class="peach-overlay-view ov-enter-right">459 <div class="peach-overlay-header">460 <button461 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"462 onClick={() => { stopCamera(); void sendMessage({ type: 'STOP_PAIRING_SERVER' }); props.onClose(); }}463 aria-label="Close"464 >465 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">466 <path d="M6 1L1 6L6 11" />467 </svg>468 </button>469 <span class="peach-overlay-title">add a device</span>470 <div class="peach-overlay-spacer" />471 </div>472473 <div class="peach-pairing-body">474 <Show when={pairedDevice()}>475 {(device) => (476 <div class="peach-pairing-content">477 <div class="peach-pairing-success-icon">478 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">479 <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />480 <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />481 </svg>482 </div>483 <p class="peach-pairing-headline">Paired!</p>484 <p class="peach-pairing-subline peach-pairing-subline--center">485 {device().displayName}486 </p>487 <button488 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"489 onClick={props.onClose}490 >491 Done492 </button>493 </div>494 )}495 </Show>496497 <Show when={!pairedDevice()}>498 <div class="peach-pairing-content peach-pairing-code-screen">499 <p class="peach-pairing-headline">Add a device</p>500501 {}502 <Show when={isNativePlatform()}>503 <div class="peach-pairing-tabs">504 <button505 class={`peach-pairing-tab${tab() === 'scan' ? ' is-active' : ''}`}506 onClick={() => {507 if (tab() === 'scan') return;508 setTab('scan');509 void stopShow();510 void startCamera();511 }}512 >513 Scan a code514 </button>515 <button516 class={`peach-pairing-tab${tab() === 'show' ? ' is-active' : ''}`}517 onClick={() => {518 if (tab() === 'show') return;519 setTab('show');520 stopCamera();521 void startShow();522 }}523 >524 Show a code525 </button>526 </div>527 </Show>528529 {}530 <Show when={isNativePlatform() && tab() === 'scan'}>531 <div class="peach-pairing-camera-container">532 <video533 ref={(el) => { videoRef = el; }}534 class="peach-pairing-video"535 muted536 playsinline537 />538 <canvas ref={(el) => { canvasRef = el; }} style={{ display: 'none' }} />539 <div class="peach-pairing-scan-frame" />540 </div>541 <p class="peach-pairing-hint">542 Point your camera at the code on your computer.543 </p>544 </Show>545546 {}547 <Show when={!isNativePlatform() || tab() === 'show'}>548 <Show when={qrDataUrl()} fallback={549 <Show when={!showError()} fallback={550 <button551 class="peach-pairing-code peach-pairing-code--expired"552 onClick={() => void generateCode()}553 >554 refresh555 </button>556 }>557 <div class="peach-pairing-code peach-pairing-code--loading">558 <Spinner />559 </div>560 </Show>561 }>562 {(url) => (563 <div class="peach-pairing-qr-container">564 <img class="peach-pairing-qr" src={url()} alt="Pairing QR" />565 <p class="peach-pairing-instruction">566 {isNativePlatform()567 ? 'Open Peach on the other phone and scan this code.'568 : 'Open Peach on your phone → Add a device → Scan this code.'}569 </p>570 <Show when={timeLeft() > 0}>571 <p class="peach-pairing-expiry">572 {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}573 </p>574 </Show>575 <Show when={timeLeft() === 0}>576 <button class="peach-pairing-regen" onClick={() => void generateCode()}>577 Generate new code578 </button>579 </Show>580 </div>581 )}582 </Show>583 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>584 </Show>585586 <Show when={showError()}>587 <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>588 </Show>589 </div>590 </Show>591 </div>592 </div>593 );594}