PairDevice.tsx

13.1 KB · raw

1// PairDevice — the pairing surface, opened from the Settings → Sync card.2//3// Settings owns the sync "hub" (paired-device list, revoke, sync-now); this4// view is purely the pairing flow, returning to Settings (`onBack`) when done5// or cancelled: the user types the pairing code their phone displays, the6// browser dials out to the address inside it, and the NK handshake + SAS run7// from there (enter code → connecting → SAS → paired).8//9// There is one path because there can only be one: the browser cannot listen,10// so it is always the side that dials.11//12// The pairing itself runs in the background (code-pairing.ts) and reaches13// this view via the PAIR_WITH_CODE / CODE_PAIR_* messages.1415import {16  Component,17  Show,18  createEffect,19  createSignal,20  onCleanup,21  onMount,22} from "solid-js";23import { sendMessage } from "../../shared/bridge";24import type { PairedDevice } from "../../shared/sync-types";25import { DesktopBird, MobileBird } from "../components/PairingBirds";26import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";2728export interface PairDeviceViewProps {29  isPro: boolean;30  onBack(): void;31  onLearnPro(): void;32  /**33   * Set when the user taps the pairing option on the Settings sync card. With34   * no method there is nothing to do here, so the view returns to Settings.35   * Read once on mount.36   */37  initialPair?: "code" | null;38  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */39  onProChanged?: () => void;40}4142// Code-pairing phases. Mirrors the background CodePairingError union.43type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";44type CodePairingError =45  | "invalid_code"46  | "no_host_found"47  | "handshake_failed"48  | "sas_mismatch"49  | "vault_locked";5051const PAIRED_DISMISS_MS = 2_000;5253function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {54  switch (error) {55    case "invalid_code":56      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —57      // the listener closes the socket either way, with no error frame.58      return {59        headline: "Code not found or expired",60        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",61      };62    case "no_host_found":63      return {64        headline: "Couldn't reach your phone",65        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",66      };67    case "sas_mismatch":68      return {69        headline: "Codes don't match",70        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",71      };72    case "vault_locked":73      return {74        headline: "Unlock Peach first",75        sub: "Your vault needs to be unlocked before you can pair a device.",76      };77    case "handshake_failed":78    default:79      return {80        headline: "Pairing failed",81        sub: "Something went wrong while connecting. Please try again.",82      };83  }84}8586export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {87  // The whole NK pairing runs in the background; this view drives the UI and88  // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE /89  // CODE_PAIR_* messages. The SAS gate is what authenticates the peer90  // (audit HIGH — pairing initiator authentication), not the pairing token.91  const [phase, setPhase] = createSignal<PairPhase>("enter");92  const [code, setCode] = createSignal("");93  const [sas, setSas] = createSignal<string | null>(null);94  const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");95  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);96  const [busy, setBusy] = createSignal(false);9798  const codeReady = () => parsePairingCode(code()) !== null;99100  function startFlow(): void {101    setCode("");102    setSas(null);103    setErrorKind("unknown");104    setPairedDevice(undefined);105    setBusy(false);106    setPhase("enter");107  }108109  function onCodeInput(value: string): void {110    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);111    setCode(formatPairingCode(cleaned));112  }113114  async function handleConnect(): Promise<void> {115    if (!codeReady() || busy()) return;116    setBusy(true);117    setPhase("connecting");118    try {119      const res = (await sendMessage({120        type: "PAIR_WITH_CODE",121        payload: { code: code() },122      })) as { ok: boolean; error?: string };123      // A synchronous start failure (e.g. vault_locked) returns here; the SAS124      // and the terminal result arrive later as pushes.125      if (!res.ok) {126        setErrorKind((res.error as CodePairingError) ?? "unknown");127        setPhase("error");128      }129    } catch {130      setErrorKind("unknown");131      setPhase("error");132    } finally {133      setBusy(false);134    }135  }136137  async function respondConfirm(confirmed: boolean): Promise<void> {138    if (busy()) return;139    setBusy(true);140    // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the141    // final phase (paired vs sas_mismatch error).142    if (confirmed) setPhase("connecting");143    else { setErrorKind("sas_mismatch"); setPhase("error"); }144    try {145      await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });146    } finally {147      setBusy(false);148    }149  }150151  // Auto-dismiss after the paired state settles — back to the Settings hub,152  // which reloads the paired-device list on mount.153  createEffect(() => {154    if (phase() !== "paired") return;155    const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);156    onCleanup(() => clearTimeout(t));157  });158159  onMount(() => {160    // Opened straight into a pairing path from the Settings sync card. With no161    // method there's nothing to do here — return to Settings (the hub).162    if (props.initialPair === "code") startFlow();163    else props.onBack();164165    // Pairing pushes from the background (bg → popup). The whole NK166    // pairing runs in the service worker; the SAS to confirm and the terminal167    // result arrive here as runtime messages.168    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {169      const handler = (msg: { type?: string; payload?: unknown }) => {170        if (msg?.type === "CODE_PAIR_SAS") {171          const p = msg.payload as { sas?: string } | undefined;172          if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }173        } else if (msg?.type === "CODE_PAIR_RESULT") {174          const p = msg.payload as175            | { status: "complete"; device: PairedDevice }176            | { status: "error"; error: CodePairingError }177            | undefined;178          if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }179          else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }180        }181      };182      chrome.runtime.onMessage.addListener(handler);183      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));184    }185    // If the popup closes while the user is on the SAS screen, resolve the186    // pending background confirm as false so the service worker clears187    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".188    onCleanup(() => {189      if (phase() === "sas") {190        void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });191      }192    });193  });194195  // --- Sub-views --------------------------------------------------196197  const backArrow = (198    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">199      <path d="M10 3 L5 8 L10 13" />200    </svg>201  );202203  // The bird stage — always animating — shared by every pairing screen.204  const birdStage = (active: boolean) => (205    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">206      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">207        <DesktopBird pose="chirping" />208      </div>209      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">210        <MobileBird pose="attentive" />211      </div>212    </div>213  );214215  function liveText(): string {216    switch (phase()) {217      case "enter": return "Enter the pairing code from your phone";218      case "connecting": return "Connecting to your phone";219      case "sas": return "Confirm the code matches on your phone";220      case "paired": return "Paired";221      case "error": return errorCopy(errorKind()).headline;222    }223  }224225  // Code pairing — birds animate on every screen below.226  const pairingScreen = () => (227    <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">228      <header class="peach-pair-head">229        <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>230        <div class="peach-pair-title">Enter a code</div>231        <div class="peach-pair-head-spacer" />232      </header>233234      {birdStage(phase() !== "enter")}235236      <Show when={phase() === "enter"}>237        <div class="peach-desktop-enter">238          <div class="peach-pair-headline">Enter the code from your phone</div>239          <div class="peach-pair-sub">240            Open Peach on your phone, tap “Pair a device”, and type the code it shows.241            Both devices need to be on the same Wi-Fi.242          </div>243          <label class="peach-label" for="pair-code">pairing code</label>244          <input245            id="pair-code"246            class="peach-underline-input peach-unlock-input peach-code-input"247            type="text"248            autocapitalize="characters"249            autocomplete="one-time-code"250            spellcheck={false}251            maxlength={32}252            placeholder="A74H-48J3-KI32-OEP4"253            aria-label="Pairing code"254            value={code()}255            ref={(el) => setTimeout(() => el.focus(), 80)}256            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}257            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}258          />259          <div class="peach-pair-action">260            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>261            <button262              class="peach-btn peach-btn-primary peach-desktop-connect"263              disabled={!codeReady() || busy()}264              onClick={() => void handleConnect()}265            >266              Connect267            </button>268          </div>269        </div>270      </Show>271272      <Show when={phase() === "connecting"}>273        <div class="peach-desktop-searching">274          <div class="peach-pair-headline">Connecting…</div>275          <div class="peach-pair-sub">Reaching your phone over the local network.</div>276        </div>277      </Show>278279      <Show when={phase() === "sas"}>280        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">281          <div class="peach-pair-headline">Confirm this code matches on your phone:</div>282          <div283            class="peach-desktop-sas"284            style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"285          >286            {sas()}287          </div>288          <div class="peach-pair-sub" style="max-width:240px;">289            Only continue if this 6-digit code is identical on both screens. This is290            what proves you're talking to your own device.291          </div>292          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">293            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>294              Confirm295            </button>296            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>297              Cancel298            </button>299          </div>300        </div>301      </Show>302303      <Show when={phase() === "paired"}>304        <div class="peach-desktop-paired">305          <div class="peach-pair-headline">Paired!</div>306          <div class="peach-pair-sub">307            {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}308          </div>309          <div class="peach-pair-action">310            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>311          </div>312        </div>313      </Show>314315      <Show when={phase() === "error"}>316        <div class="peach-desktop-error">317          <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>318          <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>319          <div class="peach-pair-action">320            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>321            <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>322          </div>323        </div>324      </Show>325326      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>327    </div>328  );329330  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can331  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.332  return pairingScreen();333};