import QRCode from 'qrcode'; import { Component, createSignal, For, onCleanup, onMount, Show } from 'solid-js'; import { sendMessage } from '../App'; import type { PairedDevice } from '../../shared/sync-types'; import { getHasPaired, setHasPaired } from '../../background/sync-storage'; import { SyncComingSoonBanner } from '../components/SyncComingSoonBanner'; import { SyncInfoSheet } from '../components/SyncInfoSheet'; import { isNativePlatform } from '../../shared/native'; // ── Shared ────────────────────────────────────────────────────────────────── function formatRelativeTime(ts: number): string { const diff = Date.now() - ts; const mins = Math.floor(diff / 60_000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins} ${mins === 1 ? 'minute' : 'minutes'} ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs} ${hrs === 1 ? 'hour' : 'hours'} ago`; const days = Math.floor(hrs / 24); if (days < 7) return `${days} ${days === 1 ? 'day' : 'days'} ago`; return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } // ── Sync Hub — States A / B / C ────────────────────────────────────────────── interface SyncPairingProps { isPro: boolean; onAddDevice: () => void; onLearnPro: () => void; onBack: () => void; } export const SyncPairingView: Component = (props) => { const [pairedDevices, setPairedDevices] = createSignal([]); const [hasPaired, setHasPairedSignal] = createSignal(false); const [syncStatus, setSyncStatus] = createSignal<'up_to_date' | 'unreachable' | 'syncing' | 'never'>('never'); const [lastSyncAt, setLastSyncAt] = createSignal(null); const [syncing, setSyncing] = createSignal(false); const [showAdvanced, setShowAdvanced] = createSignal(false); const [resetting, setResetting] = createSignal(false); const [showInfoSheet, setShowInfoSheet] = createSignal(false); onMount(async () => { const [paired, devices, syncInfo] = await Promise.all([ getHasPaired(), sendMessage({ type: 'GET_PAIRED_DEVICES' }), sendMessage({ type: 'GET_SYNC_STATUS' }), ]); setHasPairedSignal(paired); if (!paired && !props.isPro) setShowInfoSheet(true); const d = devices as { ok: boolean; data?: { devices?: PairedDevice[] } }; if (d?.ok) setPairedDevices(d.data?.devices ?? []); const s = syncInfo as { ok: boolean; data?: { status?: 'up_to_date' | 'unreachable' | 'never'; lastSyncAt?: number | null } }; if (s?.ok) { setSyncStatus(s.data?.status ?? 'never'); setLastSyncAt(s.data?.lastSyncAt ?? null); } }); async function handleSyncNow() { setSyncing(true); const result = await sendMessage({ type: 'TRIGGER_SYNC' }); setSyncing(false); const r = result as { ok: boolean; syncedCount?: number }; if (r?.ok && (r.syncedCount ?? 0) > 0) { setLastSyncAt(Date.now()); setSyncStatus('up_to_date'); const devices = await sendMessage({ type: 'GET_PAIRED_DEVICES' }); const d = devices as { ok: boolean; devices?: PairedDevice[] }; if (d?.ok) setPairedDevices(d.devices ?? []); } } async function handleResetIdentity() { if (!confirm('This will unpair all devices. Are you sure?')) return; setResetting(true); await sendMessage({ type: 'RESET_SYNC_IDENTITY' }); setResetting(false); setPairedDevices([]); setHasPairedSignal(false); } return (
setShowInfoSheet(false)} onLearnPro={() => { setShowInfoSheet(false); props.onLearnPro(); }} />
SYNC
{/* ── State A: Never paired ── */}

sync your vault.

Peach can transfer your vault to another device instantly — just scan a code.

Continuous sync across multiple devices requires Peach Pro.

{/* ── States B + C: Has paired ── */} {/* Sync status — Pro only */}
Not on a compatible network
} >
Last synced {formatRelativeTime(lastSyncAt()!)}
{/* Paired devices */} 0}>
{(device) => (
{device.displayName} {device.lastSyncAt ? formatRelativeTime(device.lastSyncAt) : 'Paired — never synced'}
)}
= 2}>

Your phone keeps everything in sync

Peach has no servers — your phone acts as the hub between your other devices. Desktop-to-desktop sync happens automatically through your phone when it's on the same network.

{/* Action buttons */}
{/* State B: upgrade prompt */}

Want automatic sync? Upgrade to Peach Pro to keep all your devices up to date in the background.

{/* State C: network note */}

Sync works on your home and trusted networks. Some public or corporate networks block device connections.

{/* ── Advanced (all states, collapsed) ── */}

Reset sync identity

Generates a new keypair and unpairs every device.

); }; // ── Add Device — QR pairing flow ───────────────────────────────────────────── interface AddDeviceProps { onClose: () => void; onPaired: (device: PairedDevice) => void; } function Spinner() { return ; } type JsQRFn = (data: Uint8ClampedArray, width: number, height: number) => { data: string } | null; export function AddDeviceView(props: AddDeviceProps) { const [qrDataUrl, setQrDataUrl] = createSignal(null); const [timeLeft, setTimeLeft] = createSignal(0); const [expiresAt, setExpiresAt] = createSignal(null); const [showError, setShowError] = createSignal(null); const [pairedDevice, setPairedDevice] = createSignal(null); // Android: default to scan tab; desktop never shows tabs. const [tab, setTab] = createSignal<'scan' | 'show'>(isNativePlatform() ? 'scan' : 'show'); let videoRef: HTMLVideoElement | undefined; let canvasRef: HTMLCanvasElement | undefined; let animRef: number | null = null; let jsQRFn: JsQRFn | null = null; let expiryTimer: ReturnType | null = null; let countdownInterval: ReturnType | null = null; function clearExpiryTimer() { if (expiryTimer !== null) { clearTimeout(expiryTimer); expiryTimer = null; } if (countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; } } function handleRuntimeMessage(msg: unknown) { const m = msg as { type?: string; payload?: { deviceName?: string } }; if (m?.type === 'PAIRING_COMPLETED' && m.payload?.deviceName) { const device: PairedDevice = { deviceId: m.payload.deviceName, displayName: m.payload.deviceName, x25519PublicKey: '', pairedAt: Date.now(), }; clearExpiryTimer(); stopCamera(); setQrDataUrl(null); setExpiresAt(null); setTimeLeft(0); setPairedDevice(device); void setHasPaired(); props.onPaired(device); } } async function startShow() { setShowError(null); setQrDataUrl(null); setExpiresAt(null); setTimeLeft(0); clearExpiryTimer(); const res = await sendMessage({ type: 'START_PAIRING_SERVER' }); const response = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } }; if (!response.ok || !response.data) { setShowError("Couldn't start the pairing server."); return; } const { token, expiresAt: expAt } = response.data; const url = await QRCode.toDataURL('peach-pair:' + token, { width: 240, margin: 2, color: { dark: '#E8E0D0', light: '#00000000' }, }); setQrDataUrl(url); setExpiresAt(expAt); const remaining = Math.max(0, expAt - Date.now()); setTimeLeft(Math.floor(remaining / 1000)); countdownInterval = setInterval(() => { const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000)); setTimeLeft(secs); if (secs === 0 && countdownInterval !== null) { clearInterval(countdownInterval); countdownInterval = null; } }, 1000); expiryTimer = setTimeout(() => { setQrDataUrl(null); setExpiresAt(null); setTimeLeft(0); setShowError('Code expired — tap to refresh.'); }, remaining); } async function stopShow() { clearExpiryTimer(); setQrDataUrl(null); setExpiresAt(null); setTimeLeft(0); await sendMessage({ type: 'STOP_PAIRING_SERVER' }); } async function generateCode() { await stopShow(); await startShow(); } async function startCamera() { setShowError(null); if (!jsQRFn) { jsQRFn = ((await import('jsqr')) as { default: JsQRFn }).default; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false, }); if (!videoRef) { for (const t of stream.getTracks()) t.stop(); return; } videoRef.srcObject = stream; await videoRef.play(); scanLoop(); } catch { setShowError('Camera unavailable — check camera permissions.'); } } function stopCamera() { if (videoRef) { const stream = videoRef.srcObject as MediaStream | null; if (stream) { for (const track of stream.getTracks()) track.stop(); videoRef.srcObject = null; } } if (animRef !== null) { cancelAnimationFrame(animRef); animRef = null; } } function scanLoop() { if (tab() !== 'scan' || !videoRef || !canvasRef || !jsQRFn) return; if (videoRef.readyState >= videoRef.HAVE_ENOUGH_DATA) { const ctx = canvasRef.getContext('2d'); if (ctx) { canvasRef.width = videoRef.videoWidth; canvasRef.height = videoRef.videoHeight; ctx.drawImage(videoRef, 0, 0); const imageData = ctx.getImageData(0, 0, canvasRef.width, canvasRef.height); const code = jsQRFn(imageData.data, imageData.width, imageData.height); if (code?.data?.startsWith('peach-pair:')) { stopCamera(); const token = code.data.slice('peach-pair:'.length); void handleScannedToken(token); return; } } } animRef = requestAnimationFrame(scanLoop); } async function handleScannedToken(token: string) { setShowError(null); const result = await sendMessage({ type: 'SCAN_PAIRING_QR', payload: { token } }); const r = result as { ok: boolean; error?: string }; if (!r?.ok) { setShowError('Could not connect. Try again.'); void startCamera(); } } onMount(() => { chrome.runtime.onMessage.addListener(handleRuntimeMessage); if (isNativePlatform()) { void startCamera(); } else { void startShow(); } }); onCleanup(() => { chrome.runtime.onMessage.removeListener(handleRuntimeMessage); clearExpiryTimer(); stopCamera(); // Pairing server keeps running — peer can complete after popup closes. }); void expiresAt; return (
add a device
{(device) => (

Paired!

{device().displayName}

)}

Add a device

{/* Android: two tabs — Scan a code (default) | Show a code */}
{/* Scan tab — Android only */}
); }