SyncPairing.tsx

10.5 KB · raw

1import { createSignal, onCleanup, onMount, Show } from "solid-js";2import { Capacitor } from "@capacitor/core";3import { sendMessage } from "../App";4import type { PairedDevice } from "../../shared/sync-types";56// Both desktop and Android can now host or enter a 6-digit code session.7// Android's host path uses a Kotlin TCP server + UDP beacon plus a JS8// Noise NK responder (`capacitor-pairing-server.ts`).9const SHOW_MODE_AVAILABLE = true;1011interface SyncPairingProps {12  onClose: () => void;13  onPaired: (device: PairedDevice) => void;14}1516type Mode = "show" | "enter";1718function Spinner() {19  return <span class="peach-pairing-spinner" />;20}2122function formatCode(code: string): string {23  if (code.length <= 3) return code;24  return `${code.slice(0, 3)} ${code.slice(3)}`;25}2627export function SyncPairingView(props: SyncPairingProps) {28  const [mode, setMode] = createSignal<Mode>(SHOW_MODE_AVAILABLE ? "show" : "enter");29  const [code, setCode] = createSignal<string | null>(null);30  const [expiresAt, setExpiresAt] = createSignal<number | null>(null);31  const [showError, setShowError] = createSignal<string | null>(null);3233  const [enteredCode, setEnteredCode] = createSignal("");34  const [connecting, setConnecting] = createSignal(false);35  const [enterError, setEnterError] = createSignal<string | null>(null);3637  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);3839  let expiryTimer: ReturnType<typeof setTimeout> | null = null;4041  function clearExpiryTimer() {42    if (expiryTimer !== null) {43      clearTimeout(expiryTimer);44      expiryTimer = null;45    }46  }4748  function handleRuntimeMessage(msg: unknown) {49    const m = msg as { type?: string; payload?: { deviceName?: string } };50    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {51      // Background completed an auto-accept while we were on the show screen.52      const device: PairedDevice = {53        deviceId: m.payload.deviceName,54        displayName: m.payload.deviceName,55        x25519PublicKey: "",56        pairedAt: Date.now(),57      };58      clearExpiryTimer();59      setCode(null);60      setExpiresAt(null);61      setPairedDevice(device);62      props.onPaired(device);63    }64  }6566  async function startShow() {67    setShowError(null);68    setCode(null);69    setExpiresAt(null);70    clearExpiryTimer();71    const res = await sendMessage({ type: "START_PAIRING_SERVER" });72    const response = res as {73      ok: boolean;74      error?: string;75      data?: { token: string; expiresAt: number };76    };77    if (!response.ok || !response.data) {78      setShowError(79        response.error === "pro_required"80          ? "Sync requires Peach Pro."81          : "Couldn't start the pairing server.",82      );83      return;84    }85    setCode(response.data.token);86    setExpiresAt(response.data.expiresAt);87    const remaining = Math.max(0, response.data.expiresAt - Date.now());88    expiryTimer = setTimeout(() => {89      setCode(null);90      setExpiresAt(null);91      setShowError("Code expired — tap to refresh.");92    }, remaining);93  }9495  async function stopShow() {96    clearExpiryTimer();97    setCode(null);98    setExpiresAt(null);99    await sendMessage({ type: "STOP_PAIRING_SERVER" });100  }101102  async function refreshCode() {103    await stopShow();104    await startShow();105  }106107  async function switchTo(target: Mode) {108    if (mode() === target) return;109    if (target === "enter") {110      await stopShow();111      setEnterError(null);112      setEnteredCode("");113      setMode("enter");114    } else {115      setEnterError(null);116      setMode("show");117      await startShow();118    }119  }120121  function onEnterInput(e: InputEvent) {122    const target = e.currentTarget as HTMLInputElement;123    const raw = target.value.replace(/[^0-9]/g, "").slice(0, 6);124    setEnteredCode(formatCode(raw));125    if (enterError()) setEnterError(null);126  }127128  function digitsEntered(): string {129    return enteredCode().replace(/\D/g, "");130  }131132  async function connect() {133    const token = digitsEntered();134    if (token.length !== 6) return;135    setConnecting(true);136    setEnterError(null);137    const res = await sendMessage({138      type: "CONNECT_WITH_CODE",139      payload: { token },140    });141    setConnecting(false);142    const response = res as { ok: boolean; error?: string; data?: { deviceName?: string } };143    if (!response.ok) {144      const msg =145        response.error === "no peer responded to pairing code" ||146        (response.error ?? "").includes("resolve failed")147          ? "Couldn't find that device. Make sure both are on the same WiFi."148          : response.error === "pair_rejected"149            ? "The other device declined."150            : response.error === "invalid_code"151              ? "Enter all six digits."152              : "Couldn't connect. Try again.";153      setEnterError(msg);154      return;155    }156    const name = response.data?.deviceName ?? "Peach";157    const device: PairedDevice = {158      deviceId: name,159      displayName: name,160      x25519PublicKey: "",161      pairedAt: Date.now(),162    };163    setPairedDevice(device);164    props.onPaired(device);165  }166167  onMount(() => {168    if (!Capacitor.isNativePlatform()) {169      chrome.runtime.onMessage.addListener(handleRuntimeMessage);170    }171    void startShow();172  });173174  onCleanup(() => {175    if (!Capacitor.isNativePlatform()) {176      chrome.runtime.onMessage.removeListener(handleRuntimeMessage);177    }178    clearExpiryTimer();179    // Pairing server keeps running in the background per spec — the user180    // showed the code, so a peer can still complete pairing after the popup181    // closes. The background clears it on success or vault-lock.182  });183184  return (185    <div class="peach-overlay-view ov-enter-right">186      <div class="peach-overlay-header">187        <button188          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"189          onClick={() => {190            // Closing the popup is fine; if user explicitly hits back, also191            // tear down the active pairing server so they don't leave a token192            // active without realising.193            void sendMessage({ type: "STOP_PAIRING_SERVER" });194            props.onClose();195          }}196          aria-label="Close"197        >198          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">199            <path d="M6 1L1 6L6 11" />200          </svg>201        </button>202        <span class="peach-overlay-title">add a device</span>203        <div class="peach-overlay-spacer" />204      </div>205206      <div class="peach-pairing-body">207        <Show when={pairedDevice()}>208          {(device) => (209            <div class="peach-pairing-content">210              <div class="peach-pairing-success-icon">211                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">212                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />213                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />214                </svg>215              </div>216              <p class="peach-pairing-headline">Paired!</p>217              <p class="peach-pairing-subline peach-pairing-subline--center">218                {device().displayName}219              </p>220              <button221                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"222                onClick={props.onClose}223              >224                Done225              </button>226            </div>227          )}228        </Show>229230        <Show when={!pairedDevice() && mode() === "show"}>231          <div class="peach-pairing-content peach-pairing-code-screen">232            <p class="peach-pairing-headline">Add a device</p>233234            <Show when={code()} fallback={235              <Show when={!showError()} fallback={236                <button237                  class="peach-pairing-code peach-pairing-code--expired"238                  onClick={() => void refreshCode()}239                >240                  refresh241                </button>242              }>243                <div class="peach-pairing-code peach-pairing-code--loading">244                  <Spinner />245                </div>246              </Show>247            }>248              {(c) => (249                <div class="peach-pairing-code" aria-label="pairing code">250                  {formatCode(c())}251                </div>252              )}253            </Show>254255            <p class="peach-pairing-subline peach-pairing-subline--center">256              Open Peach on your other device, tap257              <br />258              Add a device, and enter this code.259            </p>260            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>261262            <Show when={showError()}>263              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>264            </Show>265266            <Show when={SHOW_MODE_AVAILABLE}>267              <button268                class="peach-pairing-toggle"269                onClick={() => void switchTo("enter")}270              >271                I have a code to enter →272              </button>273            </Show>274          </div>275        </Show>276277        <Show when={!pairedDevice() && mode() === "enter"}>278          <div class="peach-pairing-content peach-pairing-code-screen">279            <p class="peach-pairing-headline">Add a device</p>280281            <input282              class="peach-pairing-code-input"283              type="text"284              inputmode="numeric"285              autocomplete="off"286              autocorrect="off"287              spellcheck={false}288              placeholder="000 000"289              value={enteredCode()}290              onInput={onEnterInput}291              disabled={connecting()}292              aria-label="enter pairing code"293            />294295            <button296              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"297              onClick={() => void connect()}298              disabled={digitsEntered().length !== 6 || connecting()}299            >300              {connecting() ? "Connecting…" : "Connect →"}301            </button>302303            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>304305            <Show when={enterError()}>306              <p class="peach-pairing-error peach-pairing-error--muted">{enterError()}</p>307            </Show>308309            <Show when={SHOW_MODE_AVAILABLE}>310              <button311                class="peach-pairing-toggle"312                onClick={() => void switchTo("show")}313                disabled={connecting()}314              >315                ← Show my code instead316              </button>317            </Show>318          </div>319        </Show>320      </div>321    </div>322  );323}