SyncPairing.tsx

21.9 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 { SyncComingSoonBanner } from '../components/SyncComingSoonBanner';7import { SyncInfoSheet } from '../components/SyncInfoSheet';8import { isNativePlatform } from '../../shared/native';910// ── Shared ──────────────────────────────────────────────────────────────────1112function 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}2324// ── Sync Hub — States A / B / C ──────────────────────────────────────────────2526interface 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        {/* ── State A: Never paired ── */}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        {/* ── States B + C: Has paired ── */}144        <Show when={hasPaired()}>145146          {/* Sync status — Pro only */}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          {/* Paired devices */}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          {/* Action buttons */}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          {/* State B: upgrade prompt */}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          {/* State C: network note */}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        {/* ── Advanced (all states, collapsed) ── */}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};288289// ── Add Device — QR pairing flow ─────────────────────────────────────────────290291interface 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  // Android: default to scan tab; desktop never shows tabs.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    // Pairing server keeps running — peer can complete after popup closes.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            {/* Android: two tabs — Scan a code (default) | Show a code */}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            {/* Scan tab — Android only */}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            {/* Show tab (Android) or full content (desktop) */}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}