SyncPairing.tsx

10.2 KB · raw

1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import type { PairedDevice } from "../../shared/sync-types";45const SHOW_MODE_AVAILABLE = true;67interface SyncPairingProps {8  onClose: () => void;9  onPaired: (device: PairedDevice) => void;10}1112type Mode = "show" | "enter";1314function Spinner() {15  return <span class="peach-pairing-spinner" />;16}1718function formatCode(code: string): string {19  if (code.length <= 3) return code;20  return `${code.slice(0, 3)} ${code.slice(3)}`;21}2223export function SyncPairingView(props: SyncPairingProps) {24  const [mode, setMode] = createSignal<Mode>(SHOW_MODE_AVAILABLE ? "show" : "enter");25  const [code, setCode] = createSignal<string | null>(null);26  const [expiresAt, setExpiresAt] = createSignal<number | null>(null);27  const [showError, setShowError] = createSignal<string | null>(null);2829  const [enteredCode, setEnteredCode] = createSignal("");30  const [connecting, setConnecting] = createSignal(false);31  const [enterError, setEnterError] = createSignal<string | null>(null);3233  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);3435  let expiryTimer: ReturnType<typeof setTimeout> | null = null;3637  function clearExpiryTimer() {38    if (expiryTimer !== null) {39      clearTimeout(expiryTimer);40      expiryTimer = null;41    }42  }4344  function handleRuntimeMessage(msg: unknown) {45    const m = msg as { type?: string; payload?: { deviceName?: string } };46    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {47      // Background completed an auto-accept while we were on the show screen.48      const device: PairedDevice = {49        deviceId: m.payload.deviceName,50        displayName: m.payload.deviceName,51        x25519PublicKey: "",52        pairedAt: Date.now(),53      };54      clearExpiryTimer();55      setCode(null);56      setExpiresAt(null);57      setPairedDevice(device);58      props.onPaired(device);59    }60  }6162  async function startShow() {63    setShowError(null);64    setCode(null);65    setExpiresAt(null);66    clearExpiryTimer();67    const res = await sendMessage({ type: "START_PAIRING_SERVER" });68    const response = res as {69      ok: boolean;70      error?: string;71      data?: { token: string; expiresAt: number };72    };73    if (!response.ok || !response.data) {74      setShowError(75        response.error === "pro_required"76          ? "Sync requires Peach Pro."77          : "Couldn't start the pairing server.",78      );79      return;80    }81    setCode(response.data.token);82    setExpiresAt(response.data.expiresAt);83    const remaining = Math.max(0, response.data.expiresAt - Date.now());84    expiryTimer = setTimeout(() => {85      setCode(null);86      setExpiresAt(null);87      setShowError("Code expired — tap to refresh.");88    }, remaining);89  }9091  async function stopShow() {92    clearExpiryTimer();93    setCode(null);94    setExpiresAt(null);95    await sendMessage({ type: "STOP_PAIRING_SERVER" });96  }9798  async function refreshCode() {99    await stopShow();100    await startShow();101  }102103  async function switchTo(target: Mode) {104    if (mode() === target) return;105    if (target === "enter") {106      await stopShow();107      setEnterError(null);108      setEnteredCode("");109      setMode("enter");110    } else {111      setEnterError(null);112      setMode("show");113      await startShow();114    }115  }116117  function onEnterInput(e: InputEvent) {118    const target = e.currentTarget as HTMLInputElement;119    const raw = target.value.replace(/[^0-9]/g, "").slice(0, 6);120    setEnteredCode(formatCode(raw));121    if (enterError()) setEnterError(null);122  }123124  function digitsEntered(): string {125    return enteredCode().replace(/\D/g, "");126  }127128  async function connect() {129    const token = digitsEntered();130    if (token.length !== 6) return;131    setConnecting(true);132    setEnterError(null);133    const res = await sendMessage({134      type: "CONNECT_WITH_CODE",135      payload: { token },136    });137    setConnecting(false);138    const response = res as { ok: boolean; error?: string; data?: { deviceName?: string } };139    if (!response.ok) {140      const msg =141        response.error === "no peer responded to pairing code" ||142        (response.error ?? "").includes("resolve failed")143          ? "Couldn't find that device. Make sure both are on the same WiFi."144          : response.error === "pair_rejected"145            ? "The other device declined."146            : response.error === "invalid_code"147              ? "Enter all six digits."148              : "Couldn't connect. Try again.";149      setEnterError(msg);150      return;151    }152    const name = response.data?.deviceName ?? "Peach";153    const device: PairedDevice = {154      deviceId: name,155      displayName: name,156      x25519PublicKey: "",157      pairedAt: Date.now(),158    };159    setPairedDevice(device);160    props.onPaired(device);161  }162163  onMount(() => {164    chrome.runtime.onMessage.addListener(handleRuntimeMessage);165    void startShow();166  });167168  onCleanup(() => {169    chrome.runtime.onMessage.removeListener(handleRuntimeMessage);170    clearExpiryTimer();171    // Pairing server keeps running in the background per spec — the user172    // showed the code, so a peer can still complete pairing after the popup173    // closes. The background clears it on success or vault-lock.174  });175176  return (177    <div class="peach-overlay-view ov-enter-right">178      <div class="peach-overlay-header">179        <button180          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"181          onClick={() => {182            // Closing the popup is fine; if user explicitly hits back, also183            // tear down the active pairing server so they don't leave a token184            // active without realising.185            void sendMessage({ type: "STOP_PAIRING_SERVER" });186            props.onClose();187          }}188          aria-label="Close"189        >190          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">191            <path d="M6 1L1 6L6 11" />192          </svg>193        </button>194        <span class="peach-overlay-title">add a device</span>195        <div class="peach-overlay-spacer" />196      </div>197198      <div class="peach-pairing-body">199        <Show when={pairedDevice()}>200          {(device) => (201            <div class="peach-pairing-content">202              <div class="peach-pairing-success-icon">203                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">204                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />205                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />206                </svg>207              </div>208              <p class="peach-pairing-headline">Paired!</p>209              <p class="peach-pairing-subline peach-pairing-subline--center">210                {device().displayName}211              </p>212              <button213                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"214                onClick={props.onClose}215              >216                Done217              </button>218            </div>219          )}220        </Show>221222        <Show when={!pairedDevice() && mode() === "show"}>223          <div class="peach-pairing-content peach-pairing-code-screen">224            <p class="peach-pairing-headline">Add a device</p>225226            <Show when={code()} fallback={227              <Show when={!showError()} fallback={228                <button229                  class="peach-pairing-code peach-pairing-code--expired"230                  onClick={() => void refreshCode()}231                >232                  refresh233                </button>234              }>235                <div class="peach-pairing-code peach-pairing-code--loading">236                  <Spinner />237                </div>238              </Show>239            }>240              {(c) => (241                <div class="peach-pairing-code" aria-label="pairing code">242                  {formatCode(c())}243                </div>244              )}245            </Show>246247            <p class="peach-pairing-subline peach-pairing-subline--center">248              Open Peach on your other device, tap249              <br />250              Add a device, and enter this code.251            </p>252            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>253254            <Show when={showError()}>255              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>256            </Show>257258            <Show when={SHOW_MODE_AVAILABLE}>259              <button260                class="peach-pairing-toggle"261                onClick={() => void switchTo("enter")}262              >263                I have a code to enter →264              </button>265            </Show>266          </div>267        </Show>268269        <Show when={!pairedDevice() && mode() === "enter"}>270          <div class="peach-pairing-content peach-pairing-code-screen">271            <p class="peach-pairing-headline">Add a device</p>272273            <input274              class="peach-pairing-code-input"275              type="text"276              inputmode="numeric"277              autocomplete="off"278              autocorrect="off"279              spellcheck={false}280              placeholder="000 000"281              value={enteredCode()}282              onInput={onEnterInput}283              disabled={connecting()}284              aria-label="enter pairing code"285            />286287            <button288              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"289              onClick={() => void connect()}290              disabled={digitsEntered().length !== 6 || connecting()}291            >292              {connecting() ? "Connecting…" : "Connect →"}293            </button>294295            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>296297            <Show when={enterError()}>298              <p class="peach-pairing-error peach-pairing-error--muted">{enterError()}</p>299            </Show>300301            <Show when={SHOW_MODE_AVAILABLE}>302              <button303                class="peach-pairing-toggle"304                onClick={() => void switchTo("show")}305                disabled={connecting()}306              >307                ← Show my code instead308              </button>309            </Show>310          </div>311        </Show>312      </div>313    </div>314  );315}