SyncPairing.tsx

21.7 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          <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          {/* Action buttons */}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          {/* State B: upgrade prompt */}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          {/* State C: network note */}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        {/* ── Advanced (all states, collapsed) ── */}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};285286// ── Add Device — QR pairing flow ─────────────────────────────────────────────287288interface 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  // Android: default to scan tab; desktop never shows tabs.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    // Pairing server keeps running — peer can complete after popup closes.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            {/* Android: two tabs — Scan a code (default) | Show a code */}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            {/* Scan tab — Android only */}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            {/* Show tab (Android) or full content (desktop) */}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}