PairDevice.tsx

13.7 KB · raw

1// ChirpSync — 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. The audio-chirp flow this file is named11// after has been removed.12//13// The pairing itself runs in the background (code-pairing.ts) and reaches14// this view via the PAIR_WITH_CODE / DESKTOP_PAIR_* messages.1516import {17  Component,18  Show,19  createEffect,20  createSignal,21  onCleanup,22  onMount,23} from "solid-js";24import { sendMessage } from "../../shared/bridge";25import type { PairedDevice } from "../../shared/sync-types";26import { DesktopBird, MobileBird } from "../components/PairingBirds";27import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";2829export interface PairDeviceViewProps {30  isPro: boolean;31  onBack(): void;32  onLearnPro(): void;33  /**34   * Set when the user taps the pairing option on the Settings sync card. With35   * no method there is nothing to do here, so the view returns to Settings.36   * Read once on mount.37   */38  initialPair?: "code" | null;39  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */40  onProChanged?: () => void;41}4243// Code-pairing phases. Mirrors the background CodePairingError union.44type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";45type CodePairingError =46  | "invalid_code"47  | "no_host_found"48  | "handshake_failed"49  | "sas_mismatch"50  | "vault_locked";5152const PAIRED_DISMISS_MS = 2_000;5354function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {55  switch (error) {56    case "invalid_code":57      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —58      // the listener closes the socket either way, with no error frame.59      return {60        headline: "Code not found or expired",61        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",62      };63    case "no_host_found":64      return {65        headline: "Couldn't reach your phone",66        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",67      };68    case "sas_mismatch":69      return {70        headline: "Codes don't match",71        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",72      };73    case "vault_locked":74      return {75        headline: "Unlock Peach first",76        sub: "Your vault needs to be unlocked before you can pair a device.",77      };78    case "handshake_failed":79    default:80      return {81        headline: "Pairing failed",82        sub: "Something went wrong while connecting. Please try again.",83      };84  }85}8687export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {88  // The whole NK pairing runs in the background; this view drives the UI and89  // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE /90  // DESKTOP_PAIR_* messages. The SAS gate is what authenticates the peer91  // (audit HIGH — pairing initiator authentication), not the pairing token.92  const [phase, setPhase] = createSignal<PairPhase>("enter");93  const [code, setCode] = createSignal("");94  const [sas, setSas] = createSignal<string | null>(null);95  const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");96  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);97  const [busy, setBusy] = createSignal(false);9899  // Two accepted forms: the pairing code a phone displays, and the legacy100  // 6-digit desktop-helper code. `parsePairingCode` returning null just means101  // "not that form", so the check falls through to the digit shape.102  const codeReady = () =>103    parsePairingCode(code()) !== null || /^\d{6}$/.test(code());104105  function startFlow(): void {106    setCode("");107    setSas(null);108    setErrorKind("unknown");109    setPairedDevice(undefined);110    setBusy(false);111    setPhase("enter");112  }113114  function onCodeInput(value: string): void {115    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);116    // Group once the input outgrows a 6-digit helper code, so a pairing code117    // regains its dashes as it is typed without mangling the short form. Length118    // rather than character class, so an all-digit pairing code still groups.119    setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);120  }121122  async function handleConnect(): Promise<void> {123    if (!codeReady() || busy()) return;124    setBusy(true);125    setPhase("connecting");126    try {127      const res = (await sendMessage({128        type: "PAIR_WITH_CODE",129        payload: { code: code() },130      })) as { ok: boolean; error?: string };131      // A synchronous start failure (e.g. vault_locked) returns here; the SAS132      // and the terminal result arrive later as pushes.133      if (!res.ok) {134        setErrorKind((res.error as CodePairingError) ?? "unknown");135        setPhase("error");136      }137    } catch {138      setErrorKind("unknown");139      setPhase("error");140    } finally {141      setBusy(false);142    }143  }144145  async function respondConfirm(confirmed: boolean): Promise<void> {146    if (busy()) return;147    setBusy(true);148    // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the149    // final phase (paired vs sas_mismatch error).150    if (confirmed) setPhase("connecting");151    else { setErrorKind("sas_mismatch"); setPhase("error"); }152    try {153      await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });154    } finally {155      setBusy(false);156    }157  }158159  // Auto-dismiss after the paired state settles — back to the Settings hub,160  // which reloads the paired-device list on mount.161  createEffect(() => {162    if (phase() !== "paired") return;163    const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);164    onCleanup(() => clearTimeout(t));165  });166167  onMount(() => {168    // Opened straight into a pairing path from the Settings sync card. With no169    // method there's nothing to do here — return to Settings (the hub).170    if (props.initialPair === "code") startFlow();171    else props.onBack();172173    // Pairing pushes from the background (bg → popup). The whole NK174    // pairing runs in the service worker; the SAS to confirm and the terminal175    // result arrive here as runtime messages.176    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {177      const handler = (msg: { type?: string; payload?: unknown }) => {178        if (msg?.type === "CODE_PAIR_SAS") {179          const p = msg.payload as { sas?: string } | undefined;180          if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }181        } else if (msg?.type === "CODE_PAIR_RESULT") {182          const p = msg.payload as183            | { status: "complete"; device: PairedDevice }184            | { status: "error"; error: CodePairingError }185            | undefined;186          if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }187          else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }188        }189      };190      chrome.runtime.onMessage.addListener(handler);191      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));192    }193    // If the popup closes while the user is on the SAS screen, resolve the194    // pending background confirm as false so the service worker clears195    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".196    onCleanup(() => {197      if (phase() === "sas") {198        void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });199      }200    });201  });202203  // --- Sub-views --------------------------------------------------204205  const backArrow = (206    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">207      <path d="M10 3 L5 8 L10 13" />208    </svg>209  );210211  // The bird stage — always animating — shared by every pairing screen.212  const birdStage = (active: boolean) => (213    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">214      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">215        <DesktopBird pose="chirping" />216      </div>217      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">218        <MobileBird pose="attentive" />219      </div>220    </div>221  );222223  function liveText(): string {224    switch (phase()) {225      case "enter": return "Enter the pairing code from your phone";226      case "connecting": return "Connecting to your phone";227      case "sas": return "Confirm the code matches on your phone";228      case "paired": return "Paired";229      case "error": return errorCopy(errorKind()).headline;230    }231  }232233  // Code pairing — birds animate on every screen below.234  const pairingScreen = () => (235    <div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">236      <header class="peach-chirp-head">237        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>238        <div class="peach-chirp-title">Enter a code</div>239        <div class="peach-chirp-head-spacer" />240      </header>241242      {birdStage(phase() !== "enter")}243244      <Show when={phase() === "enter"}>245        <div class="peach-desktop-enter">246          <div class="peach-chirp-headline">Enter the code from your phone</div>247          <div class="peach-chirp-sub">248            Open Peach on your phone, tap “Pair a device”, and type the code it shows.249            Both devices need to be on the same Wi-Fi.250          </div>251          <label class="peach-label" for="pair-code">pairing code</label>252          <input253            id="pair-code"254            class="peach-underline-input peach-unlock-input peach-code-input"255            type="text"256            autocapitalize="characters"257            autocomplete="one-time-code"258            spellcheck={false}259            maxlength={32}260            placeholder="A74H-48J3-KI32-OEP4"261            aria-label="Pairing code"262            value={code()}263            ref={(el) => setTimeout(() => el.focus(), 80)}264            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}265            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}266          />267          <div class="peach-chirp-action">268            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>269            <button270              class="peach-btn peach-btn-primary peach-desktop-connect"271              disabled={!codeReady() || busy()}272              onClick={() => void handleConnect()}273            >274              Connect275            </button>276          </div>277        </div>278      </Show>279280      <Show when={phase() === "connecting"}>281        <div class="peach-desktop-searching">282          <div class="peach-chirp-headline">Connecting…</div>283          <div class="peach-chirp-sub">Reaching your phone over the local network.</div>284        </div>285      </Show>286287      <Show when={phase() === "sas"}>288        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">289          <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>290          <div291            class="peach-desktop-sas"292            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;"293          >294            {sas()}295          </div>296          <div class="peach-chirp-sub" style="max-width:240px;">297            Only continue if this 6-digit code is identical on both screens. This is298            what proves you're talking to your own device.299          </div>300          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">301            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>302              Confirm303            </button>304            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>305              Cancel306            </button>307          </div>308        </div>309      </Show>310311      <Show when={phase() === "paired"}>312        <div class="peach-desktop-paired">313          <div class="peach-chirp-headline">Paired!</div>314          <div class="peach-chirp-sub">315            {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}316          </div>317          <div class="peach-chirp-action">318            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>319          </div>320        </div>321      </Show>322323      <Show when={phase() === "error"}>324        <div class="peach-desktop-error">325          <div class="peach-chirp-headline">{errorCopy(errorKind()).headline}</div>326          <div class="peach-chirp-sub">{errorCopy(errorKind()).sub}</div>327          <div class="peach-chirp-action">328            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>329            <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>330          </div>331        </div>332      </Show>333334      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>335    </div>336  );337338  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can339  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.340  return pairingScreen();341};