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";5import { setHasPaired } from "../../background/sync-storage";67interface SyncPairingProps {8  onClose: () => void;9  onPaired: (device: PairedDevice) => void;10}1112function Spinner() {13  return <span class="peach-pairing-spinner" />;14}1516export function SyncPairingView(props: SyncPairingProps) {17  const [qrDataUrl, setQrDataUrl] = createSignal<string | null>(null);18  const [timeLeft, setTimeLeft] = createSignal<number>(0);19  const [expiresAt, setExpiresAt] = createSignal<number | null>(null);20  const [showError, setShowError] = createSignal<string | null>(null);2122  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);2324  let expiryTimer: ReturnType<typeof setTimeout> | null = null;25  let countdownInterval: ReturnType<typeof setInterval> | null = null;2627  function clearExpiryTimer() {28    if (expiryTimer !== null) {29      clearTimeout(expiryTimer);30      expiryTimer = null;31    }32    if (countdownInterval !== null) {33      clearInterval(countdownInterval);34      countdownInterval = null;35    }36  }3738  function handleRuntimeMessage(msg: unknown) {39    const m = msg as { type?: string; payload?: { deviceName?: string } };40    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {41      const device: PairedDevice = {42        deviceId: m.payload.deviceName,43        displayName: m.payload.deviceName,44        x25519PublicKey: "",45        pairedAt: Date.now(),46      };47      clearExpiryTimer();48      setQrDataUrl(null);49      setExpiresAt(null);50      setTimeLeft(0);51      setPairedDevice(device);52      void setHasPaired();53      props.onPaired(device);54    }55  }5657  async function startShow() {58    setShowError(null);59    setQrDataUrl(null);60    setExpiresAt(null);61    setTimeLeft(0);62    clearExpiryTimer();63    const res = await sendMessage({ type: "START_PAIRING_SERVER" });64    const response = res as {65      ok: boolean;66      error?: string;67      data?: { token: string; expiresAt: number };68    };69    if (!response.ok || !response.data) {70      setShowError("Couldn't start the pairing server.");71      return;72    }7374    const { token, expiresAt: expAt } = response.data;75    const url = await QRCode.toDataURL('peach-pair:' + token, {76      width: 240,77      margin: 2,78      color: {79        dark: '#E8E0D0',80        light: '#00000000',81      },82    });83    setQrDataUrl(url);84    setExpiresAt(expAt);8586    const remaining = Math.max(0, expAt - Date.now());87    setTimeLeft(Math.floor(remaining / 1000));8889    countdownInterval = setInterval(() => {90      const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));91      setTimeLeft(secs);92      if (secs === 0 && countdownInterval !== null) {93        clearInterval(countdownInterval);94        countdownInterval = null;95      }96    }, 1000);9798    expiryTimer = setTimeout(() => {99      setQrDataUrl(null);100      setExpiresAt(null);101      setTimeLeft(0);102      setShowError("Code expired — tap to refresh.");103    }, remaining);104  }105106  async function stopShow() {107    clearExpiryTimer();108    setQrDataUrl(null);109    setExpiresAt(null);110    setTimeLeft(0);111    await sendMessage({ type: "STOP_PAIRING_SERVER" });112  }113114  async function generateCode() {115    await stopShow();116    await startShow();117  }118119  onMount(() => {120    chrome.runtime.onMessage.addListener(handleRuntimeMessage);121    void startShow();122  });123124  onCleanup(() => {125    chrome.runtime.onMessage.removeListener(handleRuntimeMessage);126    clearExpiryTimer();127    // Pairing server keeps running in the background per spec — the user128    // showed the code, so a peer can still complete pairing after the popup129    // closes. The background clears it on success or vault-lock.130  });131132  // expiresAt is stored for completeness; clearExpiryTimer is the primary133  // expiry mechanism.134  void expiresAt;135136  return (137    <div class="peach-overlay-view ov-enter-right">138      <div class="peach-overlay-header">139        <button140          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"141          onClick={() => {142            void sendMessage({ type: "STOP_PAIRING_SERVER" });143            props.onClose();144          }}145          aria-label="Close"146        >147          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">148            <path d="M6 1L1 6L6 11" />149          </svg>150        </button>151        <span class="peach-overlay-title">add a device</span>152        <div class="peach-overlay-spacer" />153      </div>154155      <div class="peach-pairing-body">156        <Show when={pairedDevice()}>157          {(device) => (158            <div class="peach-pairing-content">159              <div class="peach-pairing-success-icon">160                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">161                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />162                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />163                </svg>164              </div>165              <p class="peach-pairing-headline">Paired!</p>166              <p class="peach-pairing-subline peach-pairing-subline--center">167                {device().displayName}168              </p>169              <button170                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"171                onClick={props.onClose}172              >173                Done174              </button>175            </div>176          )}177        </Show>178179        <Show when={!pairedDevice()}>180          <div class="peach-pairing-content peach-pairing-code-screen">181            <p class="peach-pairing-headline">Add a device</p>182183            <Show when={qrDataUrl()} fallback={184              <Show when={!showError()} fallback={185                <button186                  class="peach-pairing-code peach-pairing-code--expired"187                  onClick={() => void generateCode()}188                >189                  refresh190                </button>191              }>192                <div class="peach-pairing-code peach-pairing-code--loading">193                  <Spinner />194                </div>195              </Show>196            }>197              {(url) => (198                <div class="peach-pairing-qr-container">199                  <img200                    class="peach-pairing-qr"201                    src={url()}202                    alt="Pairing QR"203                  />204                  <p class="peach-pairing-instruction">205                    Open Peach on your phone → Add a device → Scan this code.206                  </p>207                  <Show when={timeLeft() > 0}>208                    <p class="peach-pairing-expiry">209                      {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}210                    </p>211                  </Show>212                  <Show when={timeLeft() === 0}>213                    <button class="peach-pairing-regen" onClick={() => void generateCode()}>214                      Generate new code215                    </button>216                  </Show>217                </div>218              )}219            </Show>220221            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>222223            <Show when={showError()}>224              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>225            </Show>226          </div>227        </Show>228      </div>229    </div>230  );231}