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';78910function formatRelativeTime(ts: number): string {11 const diff = Date.now() - ts;12 const mins = Math.floor(diff / 60_000);13 if (mins < 1) return 'just now';14 if (mins < 60) return `${mins} ${mins === 1 ? 'minute' : 'minutes'} ago`;15 const hrs = Math.floor(mins / 60);16 if (hrs < 24) return `${hrs} ${hrs === 1 ? 'hour' : 'hours'} ago`;17 const days = Math.floor(hrs / 24);18 if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'} ago`;19 return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });20}21222324interface SyncPairingProps {25 isPro: boolean;26 onAddDevice: () => void;27 onLearnPro: () => void;28 onBack: () => void;29}3031export const SyncPairingView: Component<SyncPairingProps> = (props) => {32 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);33 const [hasPaired, setHasPairedSignal] = createSignal(false);34 const [syncStatus, setSyncStatus] = createSignal<'up_to_date' | 'unreachable' | 'syncing' | 'never'>('never');35 const [lastSyncAt, setLastSyncAt] = createSignal<number | null>(null);36 const [syncing, setSyncing] = createSignal(false);37 const [showAdvanced, setShowAdvanced] = createSignal(false);38 const [resetting, setResetting] = createSignal(false);39 const [showInfoSheet, setShowInfoSheet] = createSignal(false);4041 onMount(async () => {42 const [paired, devices, syncInfo] = await Promise.all([43 getHasPaired(),44 sendMessage({ type: 'GET_PAIRED_DEVICES' }),45 sendMessage({ type: 'GET_SYNC_STATUS' }),46 ]);4748 setHasPairedSignal(paired);49 if (!paired && !props.isPro) setShowInfoSheet(true);5051 const d = devices as { ok: boolean; data?: { devices?: PairedDevice[] } };52 if (d?.ok) setPairedDevices(d.data?.devices ?? []);5354 const s = syncInfo as { ok: boolean; data?: { status?: 'up_to_date' | 'unreachable' | 'never'; lastSyncAt?: number | null } };55 if (s?.ok) {56 setSyncStatus(s.data?.status ?? 'never');57 setLastSyncAt(s.data?.lastSyncAt ?? null);58 }59 });6061 async function handleSyncNow() {62 setSyncing(true);63 const result = await sendMessage({ type: 'TRIGGER_SYNC' });64 setSyncing(false);65 const r = result as { ok: boolean; syncedCount?: number };66 if (r?.ok && (r.syncedCount ?? 0) > 0) {67 setLastSyncAt(Date.now());68 setSyncStatus('up_to_date');69 const devices = await sendMessage({ type: 'GET_PAIRED_DEVICES' });70 const d = devices as { ok: boolean; devices?: PairedDevice[] };71 if (d?.ok) setPairedDevices(d.devices ?? []);72 }73 }7475 async function handleResetIdentity() {76 if (!confirm('This will unpair all devices. Are you sure?')) return;77 setResetting(true);78 await sendMessage({ type: 'RESET_SYNC_IDENTITY' });79 setResetting(false);80 setPairedDevices([]);81 setHasPairedSignal(false);82 }8384 return (85 <div class="peach-sync-screen peach-overlay-view ov-enter-right">86 <Show when={showInfoSheet()}>87 <SyncInfoSheet88 onGotIt={() => setShowInfoSheet(false)}89 onLearnPro={() => { setShowInfoSheet(false); props.onLearnPro(); }}90 />91 </Show>9293 <div class="peach-overlay-header">94 <button95 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"96 onClick={props.onBack}97 aria-label="Back"98 >99 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">100 <path d="M6 1L1 6L6 11" />101 </svg>102 </button>103 <span class="peach-overlay-title">SYNC</span>104 <div class="peach-overlay-spacer" />105 </div>106107 <div class="peach-sync-content">108109 {}110 <Show when={!hasPaired()}>111 <div class="peach-sync-intro">112 <h2 class="peach-sync-headline">sync your vault.</h2>113 <p class="peach-sync-intro-body">114 Peach can transfer your vault to another device instantly115 — just scan a code.116 </p>117 </div>118119 <button120 class="peach-btn peach-btn--primary peach-btn--full"121 onClick={props.onAddDevice}122 >123 Add a device124 </button>125126 <div class="peach-sync-pro-note">127 <p class="peach-sync-pro-note-text">128 Continuous sync across multiple devices requires Peach Pro.129 </p>130 <button131 class="peach-sync-pro-link"132 onClick={props.onLearnPro}133 >134 Learn about Pro →135 </button>136 </div>137 </Show>138139 {}140 <Show when={hasPaired()}>141142 {}143 <Show when={props.isPro}>144 <div class="peach-sync-status">145 <Show146 when={syncStatus() !== 'unreachable'}147 fallback={148 <div class="peach-sync-status-row">149 <span class="peach-sync-dot peach-sync-dot--amber" />150 <span class="peach-sync-status-text">151 Not on a compatible network152 </span>153 </div>154 }155 >156 <div class="peach-sync-status-row">157 <span class="peach-sync-dot peach-sync-dot--green" />158 <span class="peach-sync-status-text">159 <Show when={lastSyncAt()} fallback="Never synced">160 Last synced {formatRelativeTime(lastSyncAt()!)}161 </Show>162 </span>163 </div>164 </Show>165 </div>166 </Show>167168 {}169 <Show when={pairedDevices().length > 0}>170 <div class="peach-sync-section">171 <p class="peach-sync-section-label">PAIRED DEVICES</p>172 <For each={pairedDevices()}>173 {(device) => (174 <div class="peach-sync-device-row">175 <span class="peach-sync-device-name">176 {device.displayName}177 </span>178 <span class="peach-sync-device-meta">179 {device.lastSyncAt180 ? formatRelativeTime(device.lastSyncAt)181 : 'Paired — never synced'}182 </span>183 </div>184 )}185 </For>186 </div>187 </Show>188189 {}190 <div class="peach-sync-actions">191 <button192 class="peach-btn peach-btn--sm"193 onClick={props.onAddDevice}194 >195 Add a device196 </button>197 <Show when={props.isPro}>198 <button199 class="peach-btn peach-btn--sm"200 onClick={handleSyncNow}201 disabled={syncing()}202 >203 {syncing() ? 'Syncing…' : 'Sync now'}204 </button>205 </Show>206 </div>207208 {}209 <Show when={!props.isPro}>210 <div class="peach-sync-upgrade">211 <p class="peach-sync-upgrade-text">212 Want automatic sync? Upgrade to Peach Pro to keep213 all your devices up to date in the background.214 </p>215 <button216 class="peach-btn peach-btn--sm"217 onClick={props.onLearnPro}218 >219 Upgrade to Pro220 </button>221 </div>222 </Show>223224 {}225 <Show when={props.isPro}>226 <p class="peach-sync-network-note">227 Sync works on your home and trusted networks. Some228 public or corporate networks block device connections.229 </p>230 </Show>231232 </Show>233234 {}235 <div class="peach-sync-advanced">236 <button237 class="peach-sync-advanced-toggle"238 onClick={() => setShowAdvanced(v => !v)}239 >240 <span class={`peach-sync-advanced-chevron${showAdvanced() ? ' is-open' : ''}`}>241 ▸242 </span>243 Advanced244 </button>245246 <Show when={showAdvanced()}>247 <div class="peach-sync-advanced-content">248 <div class="peach-sync-advanced-row">249 <div>250 <p class="peach-sync-advanced-title">251 Reset sync identity252 </p>253 <p class="peach-sync-advanced-subtitle">254 Generates a new keypair and unpairs every device.255 </p>256 </div>257 <button258 class="peach-btn peach-btn--danger peach-btn--sm"259 onClick={handleResetIdentity}260 disabled={resetting() || !props.isPro}261 >262 {resetting() ? 'Resetting…' : 'Reset'}263 </button>264 </div>265 </div>266 </Show>267 </div>268269 </div>270 </div>271 );272};273274275276interface AddDeviceProps {277 onClose: () => void;278 onPaired: (device: PairedDevice) => void;279}280281function Spinner() {282 return <span class="peach-pairing-spinner" />;283}284285export function AddDeviceView(props: AddDeviceProps) {286 const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);287 const [timeLeft, setTimeLeft] = createSignal<number>(0);288 const [expiresAt, setExpiresAt] = createSignal<number | null>(null);289 const [showError, setShowError] = createSignal<string | null>(null);290 const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);291292 let expiryTimer: ReturnType<typeof setTimeout> | null = null;293 let countdownInterval: ReturnType<typeof setInterval> | null = null;294295 function clearExpiryTimer() {296 if (expiryTimer !== null) { clearTimeout(expiryTimer); expiryTimer = null; }297 if (countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }298 }299300 function handleRuntimeMessage(msg: unknown) {301 const m = msg as { type?: string; payload?: { deviceName?: string } };302 if (m?.type === 'PAIRING_COMPLETED' && m.payload?.deviceName) {303 const device: PairedDevice = {304 deviceId: m.payload.deviceName,305 displayName: m.payload.deviceName,306 x25519PublicKey: '',307 pairedAt: Date.now(),308 };309 clearExpiryTimer();310 setQrDataUrl(null);311 setExpiresAt(null);312 setTimeLeft(0);313 setPairedDevice(device);314 void setHasPaired();315 props.onPaired(device);316 }317 }318319 async function startShow() {320 setShowError(null);321 setQrDataUrl(null);322 setExpiresAt(null);323 setTimeLeft(0);324 clearExpiryTimer();325 const res = await sendMessage({ type: 'START_PAIRING_SERVER' });326 const response = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };327 if (!response.ok || !response.data) {328 setShowError("Couldn't start the pairing server.");329 return;330 }331 const { token, expiresAt: expAt } = response.data;332 const url = await QRCode.toDataURL('peach-pair:' + token, {333 width: 240,334 margin: 2,335 color: { dark: '#E8E0D0', light: '#00000000' },336 });337 setQrDataUrl(url);338 setExpiresAt(expAt);339 const remaining = Math.max(0, expAt - Date.now());340 setTimeLeft(Math.floor(remaining / 1000));341 countdownInterval = setInterval(() => {342 const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));343 setTimeLeft(secs);344 if (secs === 0 && countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; }345 }, 1000);346 expiryTimer = setTimeout(() => {347 setQrDataUrl(null);348 setExpiresAt(null);349 setTimeLeft(0);350 setShowError('Code expired — tap to refresh.');351 }, remaining);352 }353354 async function stopShow() {355 clearExpiryTimer();356 setQrDataUrl(null);357 setExpiresAt(null);358 setTimeLeft(0);359 await sendMessage({ type: 'STOP_PAIRING_SERVER' });360 }361362 async function generateCode() {363 await stopShow();364 await startShow();365 }366367 onMount(() => {368 chrome.runtime.onMessage.addListener(handleRuntimeMessage);369 void startShow();370 });371372 onCleanup(() => {373 chrome.runtime.onMessage.removeListener(handleRuntimeMessage);374 clearExpiryTimer();375 376 });377378 void expiresAt;379380 return (381 <div class="peach-overlay-view ov-enter-right">382 <div class="peach-overlay-header">383 <button384 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"385 onClick={() => { void sendMessage({ type: 'STOP_PAIRING_SERVER' }); props.onClose(); }}386 aria-label="Close"387 >388 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">389 <path d="M6 1L1 6L6 11" />390 </svg>391 </button>392 <span class="peach-overlay-title">add a device</span>393 <div class="peach-overlay-spacer" />394 </div>395396 <div class="peach-pairing-body">397 <Show when={pairedDevice()}>398 {(device) => (399 <div class="peach-pairing-content">400 <div class="peach-pairing-success-icon">401 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">402 <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />403 <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />404 </svg>405 </div>406 <p class="peach-pairing-headline">Paired!</p>407 <p class="peach-pairing-subline peach-pairing-subline--center">408 {device().displayName}409 </p>410 <button411 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"412 onClick={props.onClose}413 >414 Done415 </button>416 </div>417 )}418 </Show>419420 <Show when={!pairedDevice()}>421 <div class="peach-pairing-content peach-pairing-code-screen">422 <p class="peach-pairing-headline">Add a device</p>423424 <Show when={qrDataUrl()} fallback={425 <Show when={!showError()} fallback={426 <button427 class="peach-pairing-code peach-pairing-code--expired"428 onClick={() => void generateCode()}429 >430 refresh431 </button>432 }>433 <div class="peach-pairing-code peach-pairing-code--loading">434 <Spinner />435 </div>436 </Show>437 }>438 {(url) => (439 <div class="peach-pairing-qr-container">440 <img class="peach-pairing-qr" src={url()} alt="Pairing QR" />441 <p class="peach-pairing-instruction">442 Open Peach on your phone → Add a device → Scan this code.443 </p>444 <Show when={timeLeft() > 0}>445 <p class="peach-pairing-expiry">446 {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}447 </p>448 </Show>449 <Show when={timeLeft() === 0}>450 <button class="peach-pairing-regen" onClick={() => void generateCode()}>451 Generate new code452 </button>453 </Show>454 </div>455 )}456 </Show>457458 <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>459460 <Show when={showError()}>461 <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>462 </Show>463 </div>464 </Show>465 </div>466 </div>467 );468}