SyncPairing.tsx

16.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';78// ── Shared ──────────────────────────────────────────────────────────────────910function 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}2122// ── Sync Hub — States A / B / C ──────────────────────────────────────────────2324interface 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        {/* ── State A: Never paired ── */}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        {/* ── States B + C: Has paired ── */}140        <Show when={hasPaired()}>141142          {/* Sync status — Pro only */}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          {/* Paired devices */}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          {/* Action buttons */}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          {/* State B: upgrade prompt */}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          {/* State C: network note */}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        {/* ── Advanced (all states, collapsed) ── */}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};273274// ── Add Device — QR pairing flow ─────────────────────────────────────────────275276interface 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    // Pairing server keeps running — peer can complete after popup closes.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}