SyncPairing.tsx

10.0 KB · raw

1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import type { PairedDevice } from "../../shared/sync-types";45interface SyncPairingProps {6  onClose: () => void;7  onPaired: (device: PairedDevice) => void;8}910type Mode = "show" | "enter";1112function Spinner() {13  return <span class="peach-pairing-spinner" />;14}1516function formatCode(code: string): string {17  if (code.length <= 3) return code;18  return `${code.slice(0, 3)} ${code.slice(3)}`;19}2021export function SyncPairingView(props: SyncPairingProps) {22  const [mode, setMode] = createSignal<Mode>("show");23  const [code, setCode] = createSignal<string | null>(null);24  const [expiresAt, setExpiresAt] = createSignal<number | null>(null);25  const [showError, setShowError] = createSignal<string | null>(null);2627  const [enteredCode, setEnteredCode] = createSignal("");28  const [connecting, setConnecting] = createSignal(false);29  const [enterError, setEnterError] = createSignal<string | null>(null);3031  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);3233  let expiryTimer: ReturnType<typeof setTimeout> | null = null;3435  function clearExpiryTimer() {36    if (expiryTimer !== null) {37      clearTimeout(expiryTimer);38      expiryTimer = null;39    }40  }4142  function handleRuntimeMessage(msg: unknown) {43    const m = msg as { type?: string; payload?: { deviceName?: string } };44    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {45      // Background completed an auto-accept while we were on the show screen.46      const device: PairedDevice = {47        deviceId: m.payload.deviceName,48        displayName: m.payload.deviceName,49        x25519PublicKey: "",50        pairedAt: Date.now(),51      };52      clearExpiryTimer();53      setCode(null);54      setExpiresAt(null);55      setPairedDevice(device);56      props.onPaired(device);57    }58  }5960  async function startShow() {61    setShowError(null);62    setCode(null);63    setExpiresAt(null);64    clearExpiryTimer();65    const res = await sendMessage({ type: "START_PAIRING_SERVER" });66    const response = res as {67      ok: boolean;68      error?: string;69      data?: { token: string; expiresAt: number };70    };71    if (!response.ok || !response.data) {72      setShowError(73        response.error === "pro_required"74          ? "Sync requires Peach Pro."75          : "Couldn't start the pairing server.",76      );77      return;78    }79    setCode(response.data.token);80    setExpiresAt(response.data.expiresAt);81    const remaining = Math.max(0, response.data.expiresAt - Date.now());82    expiryTimer = setTimeout(() => {83      setCode(null);84      setExpiresAt(null);85      setShowError("Code expired — tap to refresh.");86    }, remaining);87  }8889  async function stopShow() {90    clearExpiryTimer();91    setCode(null);92    setExpiresAt(null);93    await sendMessage({ type: "STOP_PAIRING_SERVER" });94  }9596  async function refreshCode() {97    await stopShow();98    await startShow();99  }100101  async function switchTo(target: Mode) {102    if (mode() === target) return;103    if (target === "enter") {104      await stopShow();105      setEnterError(null);106      setEnteredCode("");107      setMode("enter");108    } else {109      setEnterError(null);110      setMode("show");111      await startShow();112    }113  }114115  function onEnterInput(e: InputEvent) {116    const target = e.currentTarget as HTMLInputElement;117    const raw = target.value.replace(/[^0-9]/g, "").slice(0, 6);118    setEnteredCode(formatCode(raw));119    if (enterError()) setEnterError(null);120  }121122  function digitsEntered(): string {123    return enteredCode().replace(/\D/g, "");124  }125126  async function connect() {127    const token = digitsEntered();128    if (token.length !== 6) return;129    setConnecting(true);130    setEnterError(null);131    const res = await sendMessage({132      type: "CONNECT_WITH_CODE",133      payload: { token },134    });135    setConnecting(false);136    const response = res as { ok: boolean; error?: string; data?: { deviceName?: string } };137    if (!response.ok) {138      const msg =139        response.error === "no peer responded to pairing code" ||140        (response.error ?? "").includes("resolve failed")141          ? "Couldn't find that device. Make sure both are on the same WiFi."142          : response.error === "pair_rejected"143            ? "The other device declined."144            : response.error === "invalid_code"145              ? "Enter all six digits."146              : "Couldn't connect. Try again.";147      setEnterError(msg);148      return;149    }150    const name = response.data?.deviceName ?? "Peach";151    const device: PairedDevice = {152      deviceId: name,153      displayName: name,154      x25519PublicKey: "",155      pairedAt: Date.now(),156    };157    setPairedDevice(device);158    props.onPaired(device);159  }160161  onMount(() => {162    chrome.runtime.onMessage.addListener(handleRuntimeMessage);163    void startShow();164  });165166  onCleanup(() => {167    chrome.runtime.onMessage.removeListener(handleRuntimeMessage);168    clearExpiryTimer();169    // Pairing server keeps running in the background per spec — the user170    // showed the code, so a peer can still complete pairing after the popup171    // closes. The background clears it on success or vault-lock.172  });173174  return (175    <div class="peach-overlay-view ov-enter-right">176      <div class="peach-overlay-header">177        <button178          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"179          onClick={() => {180            // Closing the popup is fine; if user explicitly hits back, also181            // tear down the active pairing server so they don't leave a token182            // active without realising.183            void sendMessage({ type: "STOP_PAIRING_SERVER" });184            props.onClose();185          }}186          aria-label="Close"187        >188          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">189            <path d="M6 1L1 6L6 11" />190          </svg>191        </button>192        <span class="peach-overlay-title">add a device</span>193        <div class="peach-overlay-spacer" />194      </div>195196      <div class="peach-pairing-body">197        <Show when={pairedDevice()}>198          {(device) => (199            <div class="peach-pairing-content">200              <div class="peach-pairing-success-icon">201                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">202                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />203                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />204                </svg>205              </div>206              <p class="peach-pairing-headline">Paired!</p>207              <p class="peach-pairing-subline peach-pairing-subline--center">208                {device().displayName}209              </p>210              <button211                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"212                onClick={props.onClose}213              >214                Done215              </button>216            </div>217          )}218        </Show>219220        <Show when={!pairedDevice() && mode() === "show"}>221          <div class="peach-pairing-content peach-pairing-code-screen">222            <p class="peach-pairing-headline">Add a device</p>223224            <Show when={code()} fallback={225              <Show when={!showError()} fallback={226                <button227                  class="peach-pairing-code peach-pairing-code--expired"228                  onClick={() => void refreshCode()}229                >230                  refresh231                </button>232              }>233                <div class="peach-pairing-code peach-pairing-code--loading">234                  <Spinner />235                </div>236              </Show>237            }>238              {(c) => (239                <div class="peach-pairing-code" aria-label="pairing code">240                  {formatCode(c())}241                </div>242              )}243            </Show>244245            <p class="peach-pairing-subline peach-pairing-subline--center">246              Open Peach on your other device, tap247              <br />248              Add a device, and enter this code.249            </p>250            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>251252            <Show when={showError()}>253              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>254            </Show>255256            <button257              class="peach-pairing-toggle"258              onClick={() => void switchTo("enter")}259            >260              I have a code to enter →261            </button>262          </div>263        </Show>264265        <Show when={!pairedDevice() && mode() === "enter"}>266          <div class="peach-pairing-content peach-pairing-code-screen">267            <p class="peach-pairing-headline">Add a device</p>268269            <input270              class="peach-pairing-code-input"271              type="text"272              inputmode="numeric"273              autocomplete="off"274              autocorrect="off"275              spellcheck={false}276              placeholder="000 000"277              value={enteredCode()}278              onInput={onEnterInput}279              disabled={connecting()}280              aria-label="enter pairing code"281            />282283            <button284              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"285              onClick={() => void connect()}286              disabled={digitsEntered().length !== 6 || connecting()}287            >288              {connecting() ? "Connecting…" : "Connect →"}289            </button>290291            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>292293            <Show when={enterError()}>294              <p class="peach-pairing-error peach-pairing-error--muted">{enterError()}</p>295            </Show>296297            <button298              class="peach-pairing-toggle"299              onClick={() => void switchTo("show")}300              disabled={connecting()}301            >302              ← Show my code instead303            </button>304          </div>305        </Show>306      </div>307    </div>308  );309}