SyncPairing.tsx

10.6 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// Show-mode on Android requires a Kotlin Noise NK responder + WebSocket7// server, which isn't built yet. Until that lands, Android only offers the8// entering-side flow ("type the code shown on the other device").9const SHOW_MODE_AVAILABLE = !Capacitor.isNativePlatform();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    if (SHOW_MODE_AVAILABLE) {172      void startShow();173    }174  });175176  onCleanup(() => {177    if (!Capacitor.isNativePlatform()) {178      chrome.runtime.onMessage.removeListener(handleRuntimeMessage);179    }180    clearExpiryTimer();181    // Pairing server keeps running in the background per spec — the user182    // showed the code, so a peer can still complete pairing after the popup183    // closes. The background clears it on success or vault-lock.184  });185186  return (187    <div class="peach-overlay-view ov-enter-right">188      <div class="peach-overlay-header">189        <button190          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"191          onClick={() => {192            // Closing the popup is fine; if user explicitly hits back, also193            // tear down the active pairing server so they don't leave a token194            // active without realising.195            void sendMessage({ type: "STOP_PAIRING_SERVER" });196            props.onClose();197          }}198          aria-label="Close"199        >200          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">201            <path d="M6 1L1 6L6 11" />202          </svg>203        </button>204        <span class="peach-overlay-title">add a device</span>205        <div class="peach-overlay-spacer" />206      </div>207208      <div class="peach-pairing-body">209        <Show when={pairedDevice()}>210          {(device) => (211            <div class="peach-pairing-content">212              <div class="peach-pairing-success-icon">213                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">214                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />215                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />216                </svg>217              </div>218              <p class="peach-pairing-headline">Paired!</p>219              <p class="peach-pairing-subline peach-pairing-subline--center">220                {device().displayName}221              </p>222              <button223                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"224                onClick={props.onClose}225              >226                Done227              </button>228            </div>229          )}230        </Show>231232        <Show when={!pairedDevice() && mode() === "show"}>233          <div class="peach-pairing-content peach-pairing-code-screen">234            <p class="peach-pairing-headline">Add a device</p>235236            <Show when={code()} fallback={237              <Show when={!showError()} fallback={238                <button239                  class="peach-pairing-code peach-pairing-code--expired"240                  onClick={() => void refreshCode()}241                >242                  refresh243                </button>244              }>245                <div class="peach-pairing-code peach-pairing-code--loading">246                  <Spinner />247                </div>248              </Show>249            }>250              {(c) => (251                <div class="peach-pairing-code" aria-label="pairing code">252                  {formatCode(c())}253                </div>254              )}255            </Show>256257            <p class="peach-pairing-subline peach-pairing-subline--center">258              Open Peach on your other device, tap259              <br />260              Add a device, and enter this code.261            </p>262            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>263264            <Show when={showError()}>265              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>266            </Show>267268            <Show when={SHOW_MODE_AVAILABLE}>269              <button270                class="peach-pairing-toggle"271                onClick={() => void switchTo("enter")}272              >273                I have a code to enter →274              </button>275            </Show>276          </div>277        </Show>278279        <Show when={!pairedDevice() && mode() === "enter"}>280          <div class="peach-pairing-content peach-pairing-code-screen">281            <p class="peach-pairing-headline">Add a device</p>282283            <input284              class="peach-pairing-code-input"285              type="text"286              inputmode="numeric"287              autocomplete="off"288              autocorrect="off"289              spellcheck={false}290              placeholder="000 000"291              value={enteredCode()}292              onInput={onEnterInput}293              disabled={connecting()}294              aria-label="enter pairing code"295            />296297            <button298              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"299              onClick={() => void connect()}300              disabled={digitsEntered().length !== 6 || connecting()}301            >302              {connecting() ? "Connecting…" : "Connect →"}303            </button>304305            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>306307            <Show when={enterError()}>308              <p class="peach-pairing-error peach-pairing-error--muted">{enterError()}</p>309            </Show>310311            <Show when={SHOW_MODE_AVAILABLE}>312              <button313                class="peach-pairing-toggle"314                onClick={() => void switchTo("show")}315                disabled={connecting()}316              >317                ← Show my code instead318              </button>319            </Show>320          </div>321        </Show>322      </div>323    </div>324  );325}