SyncPairing.tsx

21.2 KB · raw

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';89// ── Shared ──────────────────────────────────────────────────────────────────1011function 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}2223// ── Sync Hub — States A / B / C ──────────────────────────────────────────────2425interface 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        {/* ── State A: Never paired ── */}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        {/* ── States B + C: Has paired ── */}141        <Show when={hasPaired()}>142143          {/* Sync status — Pro only */}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          {/* Paired devices */}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          {/* Action buttons */}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          {/* State B: upgrade prompt */}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          {/* State C: network note */}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        {/* ── Advanced (all states, collapsed) ── */}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};274275// ── Add Device — QR pairing flow ─────────────────────────────────────────────276277interface 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  // Android: default to scan tab; desktop never shows tabs.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    // Pairing server keeps running — peer can complete after popup closes.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            {/* Android: two tabs — Scan a code (default) | Show a code */}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            {/* Scan tab — Android only */}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            {/* Show tab (Android) or full content (desktop) */}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}