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 { SyncComingSoonBanner } from '../components/SyncComingSoonBanner';7import { SyncInfoSheet } from '../components/SyncInfoSheet';8import { isNativePlatform } from '../../shared/native';9101112function formatRelativeTime(ts: number): string {13 const diff = Date.now() - ts;14 const mins = Math.floor(diff / 60_000);15 if (mins < 1) return 'just now';16 if (mins < 60) return `${mins} ${mins === 1 ? 'minute' : 'minutes'} ago`;17 const hrs = Math.floor(mins / 60);18 if (hrs < 24) return `${hrs} ${hrs === 1 ? 'hour' : 'hours'} ago`;19 const days = Math.floor(hrs / 24);20 if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'} ago`;21 return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });22}23242526interface SyncPairingProps {27 isPro: boolean;28 onAddDevice: () => void;29 onLearnPro: () => void;30 onBack: () => void;31}3233export const SyncPairingView: Component<SyncPairingProps> = (props) => {34 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);35 const [hasPaired, setHasPairedSignal] = createSignal(false);36 const [syncStatus, setSyncStatus] = createSignal<'up_to_date' | 'unreachable' | 'syncing' | 'never'>('never');37 const [lastSyncAt, setLastSyncAt] = createSignal<number | null>(null);38 const [syncing, setSyncing] = createSignal(false);39 const [showAdvanced, setShowAdvanced] = createSignal(false);40 const [resetting, setResetting] = createSignal(false);41 const [showInfoSheet, setShowInfoSheet] = createSignal(false);4243 onMount(async () => {44 const [paired, devices, syncInfo] = await Promise.all([45 getHasPaired(),46 sendMessage({ type: 'GET_PAIRED_DEVICES' }),47 sendMessage({ type: 'GET_SYNC_STATUS' }),48 ]);4950 setHasPairedSignal(paired);51 if (!paired && !props.isPro) setShowInfoSheet(true);5253 const d = devices as { ok: boolean; data?: { devices?: PairedDevice[] } };54 if (d?.ok) setPairedDevices(d.data?.devices ?? []);5556 const s = syncInfo as { ok: boolean; data?: { status?: 'up_to_date' | 'unreachable' | 'never'; lastSyncAt?: number | null } };57 if (s?.ok) {58 setSyncStatus(s.data?.status ?? 'never');59 setLastSyncAt(s.data?.lastSyncAt ?? null);60 }61 });6263 async function handleSyncNow() {64 setSyncing(true);65 const result = await sendMessage({ type: 'TRIGGER_SYNC' });66 setSyncing(false);67 const r = result as { ok: boolean; syncedCount?: number };68 if (r?.ok && (r.syncedCount ?? 0) > 0) {69 setLastSyncAt(Date.now());70 setSyncStatus('up_to_date');71 const devices = await sendMessage({ type: 'GET_PAIRED_DEVICES' });72 const d = devices as { ok: boolean; devices?: PairedDevice[] };73 if (d?.ok) setPairedDevices(d.devices ?? []);74 }75 }7677 async function handleResetIdentity() {78 if (!confirm('This will unpair all devices. Are you sure?')) return;79 setResetting(true);80 await sendMessage({ type: 'RESET_SYNC_IDENTITY' });81 setResetting(false);82 setPairedDevices([]);83 setHasPairedSignal(false);84 }8586 return (87 <div class="peach-sync-screen peach-overlay-view ov-enter-right">88 <Show when={showInfoSheet()}>89 <SyncInfoSheet90 onGotIt={() => setShowInfoSheet(false)}91 onLearnPro={() => { setShowInfoSheet(false); props.onLearnPro(); }}92 />93 </Show>9495 <div class="peach-overlay-header">96 <button97 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"98 onClick={props.onBack}99 aria-label="Back"100 >101 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">102 <path d="M6 1L1 6L6 11" />103 </svg>104 </button>105 <span class="peach-overlay-title">SYNC</span>106 <div class="peach-overlay-spacer" />107 </div>108109 <div class="peach-sync-content">110111 <SyncComingSoonBanner />112113 {}114 <Show when={!hasPaired()}>115 <div class="peach-sync-intro">116 <h2 class="peach-sync-headline">sync your vault.</h2>117 <p class="peach-sync-intro-body">118 Peach can transfer your vault to another device instantly119 — just scan a code.120 </p>121 </div>122123 <button124 class="peach-btn peach-btn--primary peach-btn--full"125 onClick={props.onAddDevice}126 >127 Add a device128 </button>129130 <div class="peach-sync-pro-note">131 <p class="peach-sync-pro-note-text">132 Continuous sync across multiple devices requires Peach Pro.133 </p>134 <button135 class="peach-sync-pro-link"136 onClick={props.onLearnPro}137 >138 Learn about Pro →139 </button>140 </div>141 </Show>142143 {}144 <Show when={hasPaired()}>145146 {}147 <Show when={props.isPro}>148 <div class="peach-sync-status">149 <Show150 when={syncStatus() !== 'unreachable'}151 fallback={152 <div class="peach-sync-status-row">153 <span class="peach-sync-dot peach-sync-dot--amber" />154 <span class="peach-sync-status-text">155 Not on a compatible network156 </span>157 </div>158 }159 >160 <div class="peach-sync-status-row">161 <span class="peach-sync-dot peach-sync-dot--green" />162 <span class="peach-sync-status-text">163 <Show when={lastSyncAt()} fallback="Never synced">164 Last synced {formatRelativeTime(lastSyncAt()!)}165 </Show>166 </span>167 </div>168 </Show>169 </div>170 </Show>171172 {}173 <Show when={pairedDevices().length > 0}>174 <div class="peach-sync-section">175 <p class="peach-sync-section-label">PAIRED DEVICES</p>176 <For each={pairedDevices()}>177 {(device) => (178 <div class="peach-sync-device-row">179 <span class="peach-sync-device-name">180 {device.displayName}181 </span>182 <span class="peach-sync-device-meta">183 {device.lastSyncAt184 ? formatRelativeTime(device.lastSyncAt)185 : 'Paired — never synced'}186 </span>187 </div>188 )}189 </For>190 </div>191 </Show>192193 <Show when={pairedDevices().length >= 2}>194 <div class="peach-sync-hub-note">195 <p class="peach-sync-hub-note-title">Your phone keeps everything in sync</p>196 <p class="peach-sync-hub-note-body">197 Peach has no servers — your phone acts as the hub between your other devices.198 Desktop-to-desktop sync happens automatically through your phone when it's on199 the same network.200 </p>201 </div>202 </Show>203204 {}205 <div class="peach-sync-actions">206 <button207 class="peach-btn peach-btn--sm"208 onClick={props.onAddDevice}209 >210 Add a device211 </button>212 <Show when={props.isPro}>213 <button214 class="peach-btn peach-btn--sm"215 onClick={handleSyncNow}216 disabled={syncing()}217 >218 {syncing() ? 'Syncing…' : 'Sync now'}219 </button>220 </Show>221 </div>222223 {}224 <Show when={!props.isPro}>225 <div class="peach-sync-upgrade">226 <p class="peach-sync-upgrade-text">227 Want automatic sync? Upgrade to Peach Pro to keep228 all your devices up to date in the background.229 </p>230 <button231 class="peach-btn peach-btn--sm"232 onClick={props.onLearnPro}233 >234 Upgrade to Pro235 </button>236 </div>237 </Show>238239 {}240 <Show when={props.isPro}>241 <p class="peach-sync-network-note">242 Sync works on your home and trusted networks. Some243 public or corporate networks block device connections.244 </p>245 </Show>246247 </Show>248249 {}250 <div class="peach-sync-advanced">251 <button252 class="peach-sync-advanced-toggle"253 onClick={() => setShowAdvanced(v => !v)}254 >255 <span class={`peach-sync-advanced-chevron${showAdvanced() ? ' is-open' : ''}`}>256 ▸257 </span>258 Advanced259 </button>260261 <Show when={showAdvanced()}>262 <div class="peach-sync-advanced-content">263 <div class="peach-sync-advanced-row">264 <div>265 <p class="peach-sync-advanced-title">266 Reset sync identity267 </p>268 <p class="peach-sync-advanced-subtitle">269 Generates a new keypair and unpairs every device.270 </p>271 </div>272 <button273 class="peach-btn peach-btn--danger peach-btn--sm"274 onClick={handleResetIdentity}275 disabled={resetting() || !props.isPro}276 >277 {resetting() ? 'Resetting…' : 'Reset'}278 </button>279 </div>280 </div>281 </Show>282 </div>283284 </div>285 </div>286 );287};288289290291interface AddDeviceProps {292 onClose: () => void;293 onPaired: (device: PairedDevice) => void;294}295296function Spinner() {297 return <span class="peach-pairing-spinner" />;298}299300type JsQRFn = (data: Uint8ClampedArray, width: number, height: number) => { data: string } | null;301302export function AddDeviceView(props: AddDeviceProps) {303 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);304 const [timeLeft, setTimeLeft] = createSignal<number>(0);305 const [expiresAt, setExpiresAt] = createSignal<number | null>(null);306 const [showError, setShowError] = createSignal<string | null>(null);307 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);308 309 const [tab, setTab] = createSignal<'scan' | 'show'>(isNativePlatform() ? 'scan' : 'show');310311 let videoRef: HTMLVideoElement | undefined;312 let canvasRef: HTMLCanvasElement | undefined;313 let animRef: number | null = null;314 let jsQRFn: JsQRFn | null = null;315316 let expiryTimer: ReturnType<typeof setTimeout> | null = null;317 let countdownInterval: ReturnType<typeof setInterval> | null = null;318319 function clearExpiryTimer() {320 if (expiryTimer !== null) { clearTimeout(expiryTimer); expiryTimer = null; }321 if (countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }322 }323324 function handleRuntimeMessage(msg: unknown) {325 const m = msg as { type?: string; payload?: { deviceName?: string } };326 if (m?.type === 'PAIRING_COMPLETED' && m.payload?.deviceName) {327 const device: PairedDevice = {328 deviceId: m.payload.deviceName,329 displayName: m.payload.deviceName,330 x25519PublicKey: '',331 pairedAt: Date.now(),332 };333 clearExpiryTimer();334 stopCamera();335 setQrDataUrl(null);336 setExpiresAt(null);337 setTimeLeft(0);338 setPairedDevice(device);339 void setHasPaired();340 props.onPaired(device);341 }342 }343344 async function startShow() {345 setShowError(null);346 setQrDataUrl(null);347 setExpiresAt(null);348 setTimeLeft(0);349 clearExpiryTimer();350 const res = await sendMessage({ type: 'START_PAIRING_SERVER' });351 const response = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };352 if (!response.ok || !response.data) {353 setShowError("Couldn't start the pairing server.");354 return;355 }356 const { token, expiresAt: expAt } = response.data;357 const url = await QRCode.toDataURL('peach-pair:' + token, {358 width: 240,359 margin: 2,360 color: { dark: '#E8E0D0', light: '#00000000' },361 });362 setQrDataUrl(url);363 setExpiresAt(expAt);364 const remaining = Math.max(0, expAt - Date.now());365 setTimeLeft(Math.floor(remaining / 1000));366 countdownInterval = setInterval(() => {367 const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));368 setTimeLeft(secs);369 if (secs === 0 && countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }370 }, 1000);371 expiryTimer = setTimeout(() => {372 setQrDataUrl(null);373 setExpiresAt(null);374 setTimeLeft(0);375 setShowError('Code expired — tap to refresh.');376 }, remaining);377 }378379 async function stopShow() {380 clearExpiryTimer();381 setQrDataUrl(null);382 setExpiresAt(null);383 setTimeLeft(0);384 await sendMessage({ type: 'STOP_PAIRING_SERVER' });385 }386387 async function generateCode() {388 await stopShow();389 await startShow();390 }391392 async function startCamera() {393 setShowError(null);394 if (!jsQRFn) {395 jsQRFn = ((await import('jsqr')) as { default: JsQRFn }).default;396 }397 try {398 const stream = await navigator.mediaDevices.getUserMedia({399 video: { facingMode: 'environment' },400 audio: false,401 });402 if (!videoRef) { for (const t of stream.getTracks()) t.stop(); return; }403 videoRef.srcObject = stream;404 await videoRef.play();405 scanLoop();406 } catch {407 setShowError('Camera unavailable — check camera permissions.');408 }409 }410411 function stopCamera() {412 if (videoRef) {413 const stream = videoRef.srcObject as MediaStream | null;414 if (stream) {415 for (const track of stream.getTracks()) track.stop();416 videoRef.srcObject = null;417 }418 }419 if (animRef !== null) { cancelAnimationFrame(animRef); animRef = null; }420 }421422 function scanLoop() {423 if (tab() !== 'scan' || !videoRef || !canvasRef || !jsQRFn) return;424 if (videoRef.readyState >= videoRef.HAVE_ENOUGH_DATA) {425 const ctx = canvasRef.getContext('2d');426 if (ctx) {427 canvasRef.width = videoRef.videoWidth;428 canvasRef.height = videoRef.videoHeight;429 ctx.drawImage(videoRef, 0, 0);430 const imageData = ctx.getImageData(0, 0, canvasRef.width, canvasRef.height);431 const code = jsQRFn(imageData.data, imageData.width, imageData.height);432 if (code?.data?.startsWith('peach-pair:')) {433 stopCamera();434 const token = code.data.slice('peach-pair:'.length);435 void handleScannedToken(token);436 return;437 }438 }439 }440 animRef = requestAnimationFrame(scanLoop);441 }442443 async function handleScannedToken(token: string) {444 setShowError(null);445 const result = await sendMessage({ type: 'SCAN_PAIRING_QR', payload: { token } });446 const r = result as { ok: boolean; error?: string };447 if (!r?.ok) {448 setShowError('Could not connect. Try again.');449 void startCamera();450 }451 }452453 onMount(() => {454 chrome.runtime.onMessage.addListener(handleRuntimeMessage);455 if (isNativePlatform()) {456 void startCamera();457 } else {458 void startShow();459 }460 });461462 onCleanup(() => {463 chrome.runtime.onMessage.removeListener(handleRuntimeMessage);464 clearExpiryTimer();465 stopCamera();466 467 });468469 void expiresAt;470471 return (472 <div class="peach-overlay-view ov-enter-right">473 <div class="peach-overlay-header">474 <button475 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"476 onClick={() => { stopCamera(); void sendMessage({ type: 'STOP_PAIRING_SERVER' }); props.onClose(); }}477 aria-label="Close"478 >479 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">480 <path d="M6 1L1 6L6 11" />481 </svg>482 </button>483 <span class="peach-overlay-title">add a device</span>484 <div class="peach-overlay-spacer" />485 </div>486487 <div class="peach-pairing-body">488 <Show when={pairedDevice()}>489 {(device) => (490 <div class="peach-pairing-content">491 <div class="peach-pairing-success-icon">492 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">493 <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />494 <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />495 </svg>496 </div>497 <p class="peach-pairing-headline">Paired!</p>498 <p class="peach-pairing-subline peach-pairing-subline--center">499 {device().displayName}500 </p>501 <button502 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"503 onClick={props.onClose}504 >505 Done506 </button>507 </div>508 )}509 </Show>510511 <Show when={!pairedDevice()}>512 <div class="peach-pairing-content peach-pairing-code-screen">513 <p class="peach-pairing-headline">Add a device</p>514515 {}516 <Show when={isNativePlatform()}>517 <div class="peach-pairing-tabs">518 <button519 class={`peach-pairing-tab${tab() === 'scan' ? ' is-active' : ''}`}520 onClick={() => {521 if (tab() === 'scan') return;522 setTab('scan');523 void stopShow();524 void startCamera();525 }}526 >527 Scan a code528 </button>529 <button530 class={`peach-pairing-tab${tab() === 'show' ? ' is-active' : ''}`}531 onClick={() => {532 if (tab() === 'show') return;533 setTab('show');534 stopCamera();535 void startShow();536 }}537 >538 Show a code539 </button>540 </div>541 </Show>542543 {}544 <Show when={isNativePlatform() && tab() === 'scan'}>545 <div class="peach-pairing-camera-container">546 <video547 ref={(el) => { videoRef = el; }}548 class="peach-pairing-video"549 muted550 playsinline551 />552 <canvas ref={(el) => { canvasRef = el; }} style={{ display: 'none' }} />553 <div class="peach-pairing-scan-frame" />554 </div>555 <p class="peach-pairing-hint">556 Point your camera at the code on your computer.557 </p>558 </Show>559560 {}561 <Show when={!isNativePlatform() || tab() === 'show'}>562 <Show when={qrDataUrl()} fallback={563 <Show when={!showError()} fallback={564 <button565 class="peach-pairing-code peach-pairing-code--expired"566 onClick={() => void generateCode()}567 >568 refresh569 </button>570 }>571 <div class="peach-pairing-code peach-pairing-code--loading">572 <Spinner />573 </div>574 </Show>575 }>576 {(url) => (577 <div class="peach-pairing-qr-container">578 <img class="peach-pairing-qr" src={url()} alt="Pairing QR" />579 <p class="peach-pairing-instruction">580 {isNativePlatform()581 ? 'Open Peach on the other phone and scan this code.'582 : 'Open Peach on your phone → Add a device → Scan this code.'}583 </p>584 <Show when={timeLeft() > 0}>585 <p class="peach-pairing-expiry">586 {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}587 </p>588 </Show>589 <Show when={timeLeft() === 0}>590 <button class="peach-pairing-regen" onClick={() => void generateCode()}>591 Generate new code592 </button>593 </Show>594 </div>595 )}596 </Show>597 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>598 </Show>599600 <Show when={showError()}>601 <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>602 </Show>603 </div>604 </Show>605 </div>606 </div>607 );608}