SyncPairing.tsx

7.5 KB · raw

1import QRCode from 'qrcode';2import { createSignal, onCleanup, onMount, Show } from "solid-js";3import { sendMessage } from "../App";4import type { PairedDevice } from "../../shared/sync-types";56interface SyncPairingProps {7  onClose: () => void;8  onPaired: (device: PairedDevice) => void;9}1011function Spinner() {12  return <span class="peach-pairing-spinner" />;13}1415export function SyncPairingView(props: SyncPairingProps) {16  const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);17  const [timeLeft, setTimeLeft] = createSignal<number>(0);18  const [expiresAt, setExpiresAt] = createSignal<number | null>(null);19  const [showError, setShowError] = createSignal<string | null>(null);2021  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);2223  let expiryTimer: ReturnType<typeof setTimeout> | null = null;24  let countdownInterval: ReturnType<typeof setInterval> | null = null;2526  function clearExpiryTimer() {27    if (expiryTimer !== null) {28      clearTimeout(expiryTimer);29      expiryTimer = null;30    }31    if (countdownInterval !== null) {32      clearInterval(countdownInterval);33      countdownInterval = null;34    }35  }3637  function handleRuntimeMessage(msg: unknown) {38    const m = msg as { type?: string; payload?: { deviceName?: string } };39    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {40      const device: PairedDevice = {41        deviceId: m.payload.deviceName,42        displayName: m.payload.deviceName,43        x25519PublicKey: "",44        pairedAt: Date.now(),45      };46      clearExpiryTimer();47      setQrDataUrl(null);48      setExpiresAt(null);49      setTimeLeft(0);50      setPairedDevice(device);51      props.onPaired(device);52    }53  }5455  async function startShow() {56    setShowError(null);57    setQrDataUrl(null);58    setExpiresAt(null);59    setTimeLeft(0);60    clearExpiryTimer();61    const res = await sendMessage({ type: "START_PAIRING_SERVER" });62    const response = res as {63      ok: boolean;64      error?: string;65      data?: { token: string; expiresAt: number };66    };67    if (!response.ok || !response.data) {68      setShowError(69        response.error === "pro_required"70          ? "Sync requires Peach Pro."71          : "Couldn't start the pairing server.",72      );73      return;74    }7576    const { token, expiresAt: expAt } = response.data;77    const url = await QRCode.toDataURL('peach-pair:' + token, {78      width: 240,79      margin: 2,80      color: {81        dark: '#E8E0D0',82        light: '#00000000',83      },84    });85    setQrDataUrl(url);86    setExpiresAt(expAt);8788    const remaining = Math.max(0, expAt - Date.now());89    setTimeLeft(Math.floor(remaining / 1000));9091    countdownInterval = setInterval(() => {92      const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));93      setTimeLeft(secs);94      if (secs === 0 && countdownInterval !== null) {95        clearInterval(countdownInterval);96        countdownInterval = null;97      }98    }, 1000);99100    expiryTimer = setTimeout(() => {101      setQrDataUrl(null);102      setExpiresAt(null);103      setTimeLeft(0);104      setShowError("Code expired — tap to refresh.");105    }, remaining);106  }107108  async function stopShow() {109    clearExpiryTimer();110    setQrDataUrl(null);111    setExpiresAt(null);112    setTimeLeft(0);113    await sendMessage({ type: "STOP_PAIRING_SERVER" });114  }115116  async function generateCode() {117    await stopShow();118    await startShow();119  }120121  onMount(() => {122    chrome.runtime.onMessage.addListener(handleRuntimeMessage);123    void startShow();124  });125126  onCleanup(() => {127    chrome.runtime.onMessage.removeListener(handleRuntimeMessage);128    clearExpiryTimer();129    // Pairing server keeps running in the background per spec — the user130    // showed the code, so a peer can still complete pairing after the popup131    // closes. The background clears it on success or vault-lock.132  });133134  // expiresAt is stored for completeness; clearExpiryTimer is the primary135  // expiry mechanism.136  void expiresAt;137138  return (139    <div class="peach-overlay-view ov-enter-right">140      <div class="peach-overlay-header">141        <button142          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"143          onClick={() => {144            void sendMessage({ type: "STOP_PAIRING_SERVER" });145            props.onClose();146          }}147          aria-label="Close"148        >149          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">150            <path d="M6 1L1 6L6 11" />151          </svg>152        </button>153        <span class="peach-overlay-title">add a device</span>154        <div class="peach-overlay-spacer" />155      </div>156157      <div class="peach-pairing-body">158        <Show when={pairedDevice()}>159          {(device) => (160            <div class="peach-pairing-content">161              <div class="peach-pairing-success-icon">162                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">163                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />164                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />165                </svg>166              </div>167              <p class="peach-pairing-headline">Paired!</p>168              <p class="peach-pairing-subline peach-pairing-subline--center">169                {device().displayName}170              </p>171              <button172                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"173                onClick={props.onClose}174              >175                Done176              </button>177            </div>178          )}179        </Show>180181        <Show when={!pairedDevice()}>182          <div class="peach-pairing-content peach-pairing-code-screen">183            <p class="peach-pairing-headline">Add a device</p>184185            <Show when={qrDataUrl()} fallback={186              <Show when={!showError()} fallback={187                <button188                  class="peach-pairing-code peach-pairing-code--expired"189                  onClick={() => void generateCode()}190                >191                  refresh192                </button>193              }>194                <div class="peach-pairing-code peach-pairing-code--loading">195                  <Spinner />196                </div>197              </Show>198            }>199              {(url) => (200                <div class="peach-pairing-qr-container">201                  <img202                    class="peach-pairing-qr"203                    src={url()}204                    alt="Pairing QR"205                  />206                  <p class="peach-pairing-instruction">207                    Open Peach on your phone → Add a device → Scan this code.208                  </p>209                  <Show when={timeLeft() > 0}>210                    <p class="peach-pairing-expiry">211                      {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}212                    </p>213                  </Show>214                  <Show when={timeLeft() === 0}>215                    <button class="peach-pairing-regen" onClick={() => void generateCode()}>216                      Generate new code217                    </button>218                  </Show>219                </div>220              )}221            </Show>222223            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>224225            <Show when={showError()}>226              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>227            </Show>228          </div>229        </Show>230      </div>231    </div>232  );233}