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 <Show when={pairedDevices().length >= 2}>191 <div class="peach-sync-hub-note">192 <p class="peach-sync-hub-note-title">Your phone keeps everything in sync</p>193 <p class="peach-sync-hub-note-body">194 Peach has no servers — your phone acts as the hub between your other devices.195 Desktop-to-desktop sync happens automatically through your phone when it's on196 the same network.197 </p>198 </div>199 </Show>200201 {}202 <div class="peach-sync-actions">203 <button204 class="peach-btn peach-btn--sm"205 onClick={props.onAddDevice}206 >207 Add a device208 </button>209 <Show when={props.isPro}>210 <button211 class="peach-btn peach-btn--sm"212 onClick={handleSyncNow}213 disabled={syncing()}214 >215 {syncing() ? 'Syncing…' : 'Sync now'}216 </button>217 </Show>218 </div>219220 {}221 <Show when={!props.isPro}>222 <div class="peach-sync-upgrade">223 <p class="peach-sync-upgrade-text">224 Want automatic sync? Upgrade to Peach Pro to keep225 all your devices up to date in the background.226 </p>227 <button228 class="peach-btn peach-btn--sm"229 onClick={props.onLearnPro}230 >231 Upgrade to Pro232 </button>233 </div>234 </Show>235236 {}237 <Show when={props.isPro}>238 <p class="peach-sync-network-note">239 Sync works on your home and trusted networks. Some240 public or corporate networks block device connections.241 </p>242 </Show>243244 </Show>245246 {}247 <div class="peach-sync-advanced">248 <button249 class="peach-sync-advanced-toggle"250 onClick={() => setShowAdvanced(v => !v)}251 >252 <span class={`peach-sync-advanced-chevron${showAdvanced() ? ' is-open' : ''}`}>253 ▸254 </span>255 Advanced256 </button>257258 <Show when={showAdvanced()}>259 <div class="peach-sync-advanced-content">260 <div class="peach-sync-advanced-row">261 <div>262 <p class="peach-sync-advanced-title">263 Reset sync identity264 </p>265 <p class="peach-sync-advanced-subtitle">266 Generates a new keypair and unpairs every device.267 </p>268 </div>269 <button270 class="peach-btn peach-btn--danger peach-btn--sm"271 onClick={handleResetIdentity}272 disabled={resetting() || !props.isPro}273 >274 {resetting() ? 'Resetting…' : 'Reset'}275 </button>276 </div>277 </div>278 </Show>279 </div>280281 </div>282 </div>283 );284};285286287288interface AddDeviceProps {289 onClose: () => void;290 onPaired: (device: PairedDevice) => void;291}292293function Spinner() {294 return <span class="peach-pairing-spinner" />;295}296297type JsQRFn = (data: Uint8ClampedArray, width: number, height: number) => { data: string } | null;298299export function AddDeviceView(props: AddDeviceProps) {300 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);301 const [timeLeft, setTimeLeft] = createSignal<number>(0);302 const [expiresAt, setExpiresAt] = createSignal<number | null>(null);303 const [showError, setShowError] = createSignal<string | null>(null);304 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);305 306 const [tab, setTab] = createSignal<'scan' | 'show'>(isNativePlatform() ? 'scan' : 'show');307308 let videoRef: HTMLVideoElement | undefined;309 let canvasRef: HTMLCanvasElement | undefined;310 let animRef: number | null = null;311 let jsQRFn: JsQRFn | null = null;312313 let expiryTimer: ReturnType<typeof setTimeout> | null = null;314 let countdownInterval: ReturnType<typeof setInterval> | null = null;315316 function clearExpiryTimer() {317 if (expiryTimer !== null) { clearTimeout(expiryTimer); expiryTimer = null; }318 if (countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }319 }320321 function handleRuntimeMessage(msg: unknown) {322 const m = msg as { type?: string; payload?: { deviceName?: string } };323 if (m?.type === 'PAIRING_COMPLETED' && m.payload?.deviceName) {324 const device: PairedDevice = {325 deviceId: m.payload.deviceName,326 displayName: m.payload.deviceName,327 x25519PublicKey: '',328 pairedAt: Date.now(),329 };330 clearExpiryTimer();331 stopCamera();332 setQrDataUrl(null);333 setExpiresAt(null);334 setTimeLeft(0);335 setPairedDevice(device);336 void setHasPaired();337 props.onPaired(device);338 }339 }340341 async function startShow() {342 setShowError(null);343 setQrDataUrl(null);344 setExpiresAt(null);345 setTimeLeft(0);346 clearExpiryTimer();347 const res = await sendMessage({ type: 'START_PAIRING_SERVER' });348 const response = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };349 if (!response.ok || !response.data) {350 setShowError("Couldn't start the pairing server.");351 return;352 }353 const { token, expiresAt: expAt } = response.data;354 const url = await QRCode.toDataURL('peach-pair:' + token, {355 width: 240,356 margin: 2,357 color: { dark: '#E8E0D0', light: '#00000000' },358 });359 setQrDataUrl(url);360 setExpiresAt(expAt);361 const remaining = Math.max(0, expAt - Date.now());362 setTimeLeft(Math.floor(remaining / 1000));363 countdownInterval = setInterval(() => {364 const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));365 setTimeLeft(secs);366 if (secs === 0 && countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }367 }, 1000);368 expiryTimer = setTimeout(() => {369 setQrDataUrl(null);370 setExpiresAt(null);371 setTimeLeft(0);372 setShowError('Code expired — tap to refresh.');373 }, remaining);374 }375376 async function stopShow() {377 clearExpiryTimer();378 setQrDataUrl(null);379 setExpiresAt(null);380 setTimeLeft(0);381 await sendMessage({ type: 'STOP_PAIRING_SERVER' });382 }383384 async function generateCode() {385 await stopShow();386 await startShow();387 }388389 async function startCamera() {390 setShowError(null);391 if (!jsQRFn) {392 jsQRFn = ((await import('jsqr')) as { default: JsQRFn }).default;393 }394 try {395 const stream = await navigator.mediaDevices.getUserMedia({396 video: { facingMode: 'environment' },397 audio: false,398 });399 if (!videoRef) { for (const t of stream.getTracks()) t.stop(); return; }400 videoRef.srcObject = stream;401 await videoRef.play();402 scanLoop();403 } catch {404 setShowError('Camera unavailable — check camera permissions.');405 }406 }407408 function stopCamera() {409 if (videoRef) {410 const stream = videoRef.srcObject as MediaStream | null;411 if (stream) {412 for (const track of stream.getTracks()) track.stop();413 videoRef.srcObject = null;414 }415 }416 if (animRef !== null) { cancelAnimationFrame(animRef); animRef = null; }417 }418419 function scanLoop() {420 if (tab() !== 'scan' || !videoRef || !canvasRef || !jsQRFn) return;421 if (videoRef.readyState >= videoRef.HAVE_ENOUGH_DATA) {422 const ctx = canvasRef.getContext('2d');423 if (ctx) {424 canvasRef.width = videoRef.videoWidth;425 canvasRef.height = videoRef.videoHeight;426 ctx.drawImage(videoRef, 0, 0);427 const imageData = ctx.getImageData(0, 0, canvasRef.width, canvasRef.height);428 const code = jsQRFn(imageData.data, imageData.width, imageData.height);429 if (code?.data?.startsWith('peach-pair:')) {430 stopCamera();431 const token = code.data.slice('peach-pair:'.length);432 void handleScannedToken(token);433 return;434 }435 }436 }437 animRef = requestAnimationFrame(scanLoop);438 }439440 async function handleScannedToken(token: string) {441 setShowError(null);442 const result = await sendMessage({ type: 'SCAN_PAIRING_QR', payload: { token } });443 const r = result as { ok: boolean; error?: string };444 if (!r?.ok) {445 setShowError('Could not connect. Try again.');446 void startCamera();447 }448 }449450 onMount(() => {451 chrome.runtime.onMessage.addListener(handleRuntimeMessage);452 if (isNativePlatform()) {453 void startCamera();454 } else {455 void startShow();456 }457 });458459 onCleanup(() => {460 chrome.runtime.onMessage.removeListener(handleRuntimeMessage);461 clearExpiryTimer();462 stopCamera();463 464 });465466 void expiresAt;467468 return (469 <div class="peach-overlay-view ov-enter-right">470 <div class="peach-overlay-header">471 <button472 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"473 onClick={() => { stopCamera(); void sendMessage({ type: 'STOP_PAIRING_SERVER' }); props.onClose(); }}474 aria-label="Close"475 >476 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">477 <path d="M6 1L1 6L6 11" />478 </svg>479 </button>480 <span class="peach-overlay-title">add a device</span>481 <div class="peach-overlay-spacer" />482 </div>483484 <div class="peach-pairing-body">485 <Show when={pairedDevice()}>486 {(device) => (487 <div class="peach-pairing-content">488 <div class="peach-pairing-success-icon">489 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">490 <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />491 <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />492 </svg>493 </div>494 <p class="peach-pairing-headline">Paired!</p>495 <p class="peach-pairing-subline peach-pairing-subline--center">496 {device().displayName}497 </p>498 <button499 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"500 onClick={props.onClose}501 >502 Done503 </button>504 </div>505 )}506 </Show>507508 <Show when={!pairedDevice()}>509 <div class="peach-pairing-content peach-pairing-code-screen">510 <p class="peach-pairing-headline">Add a device</p>511512 {}513 <Show when={isNativePlatform()}>514 <div class="peach-pairing-tabs">515 <button516 class={`peach-pairing-tab${tab() === 'scan' ? ' is-active' : ''}`}517 onClick={() => {518 if (tab() === 'scan') return;519 setTab('scan');520 void stopShow();521 void startCamera();522 }}523 >524 Scan a code525 </button>526 <button527 class={`peach-pairing-tab${tab() === 'show' ? ' is-active' : ''}`}528 onClick={() => {529 if (tab() === 'show') return;530 setTab('show');531 stopCamera();532 void startShow();533 }}534 >535 Show a code536 </button>537 </div>538 </Show>539540 {}541 <Show when={isNativePlatform() && tab() === 'scan'}>542 <div class="peach-pairing-camera-container">543 <video544 ref={(el) => { videoRef = el; }}545 class="peach-pairing-video"546 muted547 playsinline548 />549 <canvas ref={(el) => { canvasRef = el; }} style={{ display: 'none' }} />550 <div class="peach-pairing-scan-frame" />551 </div>552 <p class="peach-pairing-hint">553 Point your camera at the code on your computer.554 </p>555 </Show>556557 {}558 <Show when={!isNativePlatform() || tab() === 'show'}>559 <Show when={qrDataUrl()} fallback={560 <Show when={!showError()} fallback={561 <button562 class="peach-pairing-code peach-pairing-code--expired"563 onClick={() => void generateCode()}564 >565 refresh566 </button>567 }>568 <div class="peach-pairing-code peach-pairing-code--loading">569 <Spinner />570 </div>571 </Show>572 }>573 {(url) => (574 <div class="peach-pairing-qr-container">575 <img class="peach-pairing-qr" src={url()} alt="Pairing QR" />576 <p class="peach-pairing-instruction">577 {isNativePlatform()578 ? 'Open Peach on the other phone and scan this code.'579 : 'Open Peach on your phone → Add a device → Scan this code.'}580 </p>581 <Show when={timeLeft() > 0}>582 <p class="peach-pairing-expiry">583 {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}584 </p>585 </Show>586 <Show when={timeLeft() === 0}>587 <button class="peach-pairing-regen" onClick={() => void generateCode()}>588 Generate new code589 </button>590 </Show>591 </div>592 )}593 </Show>594 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>595 </Show>596597 <Show when={showError()}>598 <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>599 </Show>600 </div>601 </Show>602 </div>603 </div>604 );605}