SyncPairing.tsx

7.4 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("Couldn't start the pairing server.");69      return;70    }7172    const { token, expiresAt: expAt } = response.data;73    const url = await QRCode.toDataURL('peach-pair:' + token, {74      width: 240,75      margin: 2,76      color: {77        dark: '#E8E0D0',78        light: '#00000000',79      },80    });81    setQrDataUrl(url);82    setExpiresAt(expAt);8384    const remaining = Math.max(0, expAt - Date.now());85    setTimeLeft(Math.floor(remaining / 1000));8687    countdownInterval = setInterval(() => {88      const secs = Math.max(0, Math.floor((expAt - Date.now()) / 1000));89      setTimeLeft(secs);90      if (secs === 0 && countdownInterval !== null) {91        clearInterval(countdownInterval);92        countdownInterval = null;93      }94    }, 1000);9596    expiryTimer = setTimeout(() => {97      setQrDataUrl(null);98      setExpiresAt(null);99      setTimeLeft(0);100      setShowError("Code expired — tap to refresh.");101    }, remaining);102  }103104  async function stopShow() {105    clearExpiryTimer();106    setQrDataUrl(null);107    setExpiresAt(null);108    setTimeLeft(0);109    await sendMessage({ type: "STOP_PAIRING_SERVER" });110  }111112  async function generateCode() {113    await stopShow();114    await startShow();115  }116117  onMount(() => {118    chrome.runtime.onMessage.addListener(handleRuntimeMessage);119    void startShow();120  });121122  onCleanup(() => {123    chrome.runtime.onMessage.removeListener(handleRuntimeMessage);124    clearExpiryTimer();125    // Pairing server keeps running in the background per spec — the user126    // showed the code, so a peer can still complete pairing after the popup127    // closes. The background clears it on success or vault-lock.128  });129130  // expiresAt is stored for completeness; clearExpiryTimer is the primary131  // expiry mechanism.132  void expiresAt;133134  return (135    <div class="peach-overlay-view ov-enter-right">136      <div class="peach-overlay-header">137        <button138          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"139          onClick={() => {140            void sendMessage({ type: "STOP_PAIRING_SERVER" });141            props.onClose();142          }}143          aria-label="Close"144        >145          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">146            <path d="M6 1L1 6L6 11" />147          </svg>148        </button>149        <span class="peach-overlay-title">add a device</span>150        <div class="peach-overlay-spacer" />151      </div>152153      <div class="peach-pairing-body">154        <Show when={pairedDevice()}>155          {(device) => (156            <div class="peach-pairing-content">157              <div class="peach-pairing-success-icon">158                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">159                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />160                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />161                </svg>162              </div>163              <p class="peach-pairing-headline">Paired!</p>164              <p class="peach-pairing-subline peach-pairing-subline--center">165                {device().displayName}166              </p>167              <button168                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"169                onClick={props.onClose}170              >171                Done172              </button>173            </div>174          )}175        </Show>176177        <Show when={!pairedDevice()}>178          <div class="peach-pairing-content peach-pairing-code-screen">179            <p class="peach-pairing-headline">Add a device</p>180181            <Show when={qrDataUrl()} fallback={182              <Show when={!showError()} fallback={183                <button184                  class="peach-pairing-code peach-pairing-code--expired"185                  onClick={() => void generateCode()}186                >187                  refresh188                </button>189              }>190                <div class="peach-pairing-code peach-pairing-code--loading">191                  <Spinner />192                </div>193              </Show>194            }>195              {(url) => (196                <div class="peach-pairing-qr-container">197                  <img198                    class="peach-pairing-qr"199                    src={url()}200                    alt="Pairing QR"201                  />202                  <p class="peach-pairing-instruction">203                    Open Peach on your phone → Add a device → Scan this code.204                  </p>205                  <Show when={timeLeft() > 0}>206                    <p class="peach-pairing-expiry">207                      {Math.floor(timeLeft() / 60)}:{String(timeLeft() % 60).padStart(2, '0')}208                    </p>209                  </Show>210                  <Show when={timeLeft() === 0}>211                    <button class="peach-pairing-regen" onClick={() => void generateCode()}>212                      Generate new code213                    </button>214                  </Show>215                </div>216              )}217            </Show>218219            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>220221            <Show when={showError()}>222              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>223            </Show>224          </div>225        </Show>226      </div>227    </div>228  );229}