PairDevice.tsx

13.6 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  // Two accepted forms: the pairing code a phone displays, and the legacy99  // 6-digit desktop-helper code. `parsePairingCode` returning null just means100  // "not that form", so the check falls through to the digit shape.101  const codeReady = () =>102    parsePairingCode(code()) !== null || /^\d{6}$/.test(code());103104  function startFlow(): void {105    setCode("");106    setSas(null);107    setErrorKind("unknown");108    setPairedDevice(undefined);109    setBusy(false);110    setPhase("enter");111  }112113  function onCodeInput(value: string): void {114    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);115    // Group once the input outgrows a 6-digit helper code, so a pairing code116    // regains its dashes as it is typed without mangling the short form. Length117    // rather than character class, so an all-digit pairing code still groups.118    setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);119  }120121  async function handleConnect(): Promise<void> {122    if (!codeReady() || busy()) return;123    setBusy(true);124    setPhase("connecting");125    try {126      const res = (await sendMessage({127        type: "PAIR_WITH_CODE",128        payload: { code: code() },129      })) as { ok: boolean; error?: string };130      // A synchronous start failure (e.g. vault_locked) returns here; the SAS131      // and the terminal result arrive later as pushes.132      if (!res.ok) {133        setErrorKind((res.error as CodePairingError) ?? "unknown");134        setPhase("error");135      }136    } catch {137      setErrorKind("unknown");138      setPhase("error");139    } finally {140      setBusy(false);141    }142  }143144  async function respondConfirm(confirmed: boolean): Promise<void> {145    if (busy()) return;146    setBusy(true);147    // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the148    // final phase (paired vs sas_mismatch error).149    if (confirmed) setPhase("connecting");150    else { setErrorKind("sas_mismatch"); setPhase("error"); }151    try {152      await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });153    } finally {154      setBusy(false);155    }156  }157158  // Auto-dismiss after the paired state settles — back to the Settings hub,159  // which reloads the paired-device list on mount.160  createEffect(() => {161    if (phase() !== "paired") return;162    const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);163    onCleanup(() => clearTimeout(t));164  });165166  onMount(() => {167    // Opened straight into a pairing path from the Settings sync card. With no168    // method there's nothing to do here — return to Settings (the hub).169    if (props.initialPair === "code") startFlow();170    else props.onBack();171172    // Pairing pushes from the background (bg → popup). The whole NK173    // pairing runs in the service worker; the SAS to confirm and the terminal174    // result arrive here as runtime messages.175    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {176      const handler = (msg: { type?: string; payload?: unknown }) => {177        if (msg?.type === "CODE_PAIR_SAS") {178          const p = msg.payload as { sas?: string } | undefined;179          if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }180        } else if (msg?.type === "CODE_PAIR_RESULT") {181          const p = msg.payload as182            | { status: "complete"; device: PairedDevice }183            | { status: "error"; error: CodePairingError }184            | undefined;185          if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }186          else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }187        }188      };189      chrome.runtime.onMessage.addListener(handler);190      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));191    }192    // If the popup closes while the user is on the SAS screen, resolve the193    // pending background confirm as false so the service worker clears194    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".195    onCleanup(() => {196      if (phase() === "sas") {197        void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });198      }199    });200  });201202  // --- Sub-views --------------------------------------------------203204  const backArrow = (205    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">206      <path d="M10 3 L5 8 L10 13" />207    </svg>208  );209210  // The bird stage — always animating — shared by every pairing screen.211  const birdStage = (active: boolean) => (212    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">213      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">214        <DesktopBird pose="chirping" />215      </div>216      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">217        <MobileBird pose="attentive" />218      </div>219    </div>220  );221222  function liveText(): string {223    switch (phase()) {224      case "enter": return "Enter the pairing code from your phone";225      case "connecting": return "Connecting to your phone";226      case "sas": return "Confirm the code matches on your phone";227      case "paired": return "Paired";228      case "error": return errorCopy(errorKind()).headline;229    }230  }231232  // Code pairing — birds animate on every screen below.233  const pairingScreen = () => (234    <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">235      <header class="peach-pair-head">236        <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>237        <div class="peach-pair-title">Enter a code</div>238        <div class="peach-pair-head-spacer" />239      </header>240241      {birdStage(phase() !== "enter")}242243      <Show when={phase() === "enter"}>244        <div class="peach-desktop-enter">245          <div class="peach-pair-headline">Enter the code from your phone</div>246          <div class="peach-pair-sub">247            Open Peach on your phone, tap “Pair a device”, and type the code it shows.248            Both devices need to be on the same Wi-Fi.249          </div>250          <label class="peach-label" for="pair-code">pairing code</label>251          <input252            id="pair-code"253            class="peach-underline-input peach-unlock-input peach-code-input"254            type="text"255            autocapitalize="characters"256            autocomplete="one-time-code"257            spellcheck={false}258            maxlength={32}259            placeholder="A74H-48J3-KI32-OEP4"260            aria-label="Pairing code"261            value={code()}262            ref={(el) => setTimeout(() => el.focus(), 80)}263            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}264            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}265          />266          <div class="peach-pair-action">267            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>268            <button269              class="peach-btn peach-btn-primary peach-desktop-connect"270              disabled={!codeReady() || busy()}271              onClick={() => void handleConnect()}272            >273              Connect274            </button>275          </div>276        </div>277      </Show>278279      <Show when={phase() === "connecting"}>280        <div class="peach-desktop-searching">281          <div class="peach-pair-headline">Connecting…</div>282          <div class="peach-pair-sub">Reaching your phone over the local network.</div>283        </div>284      </Show>285286      <Show when={phase() === "sas"}>287        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">288          <div class="peach-pair-headline">Confirm this code matches on your phone:</div>289          <div290            class="peach-desktop-sas"291            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;"292          >293            {sas()}294          </div>295          <div class="peach-pair-sub" style="max-width:240px;">296            Only continue if this 6-digit code is identical on both screens. This is297            what proves you're talking to your own device.298          </div>299          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">300            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>301              Confirm302            </button>303            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>304              Cancel305            </button>306          </div>307        </div>308      </Show>309310      <Show when={phase() === "paired"}>311        <div class="peach-desktop-paired">312          <div class="peach-pair-headline">Paired!</div>313          <div class="peach-pair-sub">314            {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}315          </div>316          <div class="peach-pair-action">317            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>318          </div>319        </div>320      </Show>321322      <Show when={phase() === "error"}>323        <div class="peach-desktop-error">324          <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>325          <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>326          <div class="peach-pair-action">327            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>328            <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>329          </div>330        </div>331      </Show>332333      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>334    </div>335  );336337  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can338  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.339  return pairingScreen();340};