PairDevice.tsx

20.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";27import { AdoptionConsentModal } from "../components/AdoptionConsentModal";28import { PairingQrCode } from "../components/PairingQrCode";2930export interface PairDeviceViewProps {31  isPro: boolean;32  onBack(): void;33  onLearnPro(): void;34  /**35   * Set when the user taps the pairing option on the Settings sync card. With36   * no method there is nothing to do here, so the view returns to Settings.37   * Read once on mount.38   */39  initialPair?: "code" | null;40  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */41  onProChanged?: () => void;42}4344// Code-pairing phases. Mirrors the background CodePairingError union.45type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";46type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";47type CodePairingError =48  | "invalid_code"49  | "no_host_found"50  | "handshake_failed"51  | "sas_mismatch"52  | "vault_locked";5354const PAIRED_DISMISS_MS = 2_000;5556function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {57  switch (error) {58    case "invalid_code":59      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —60      // the listener closes the socket either way, with no error frame.61      return {62        headline: "Code not found or expired",63        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",64      };65    case "no_host_found":66      return {67        headline: "Couldn't reach your phone",68        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",69      };70    case "sas_mismatch":71      return {72        headline: "Codes don't match",73        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",74      };75    case "vault_locked":76      return {77        headline: "Unlock Peach first",78        sub: "Your vault needs to be unlocked before you can pair a device.",79      };80    case "handshake_failed":81    default:82      return {83        headline: "Pairing failed",84        sub: "Something went wrong while connecting. Please try again.",85      };86  }87}8889export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {90  // The whole NK pairing runs in the background; this view drives the UI and91  // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE /92  // CODE_PAIR_* messages. The SAS gate is what authenticates the peer93  // (audit HIGH — pairing initiator authentication), not the pairing token.94  const [phase, setPhase] = createSignal<PairPhase>("enter");95  const [code, setCode] = createSignal("");96  const [sas, setSas] = createSignal<string | null>(null);97  const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");98  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);99  const [busy, setBusy] = createSignal(false);100  const [mode, setMode] = createSignal<"share" | "connect">("share");101  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");102  const [invitation, setInvitation] = createSignal("");103  const [sourceSas, setSourceSas] = createSignal("");104  const [receiverName, setReceiverName] = createSignal("New device");105  const [shareError, setShareError] = createSignal("");106107  const codeReady = () => parsePairingCode(code()) !== null;108109  function startFlow(): void {110    setMode("connect");111    setCode("");112    setSas(null);113    setErrorKind("unknown");114    setPairedDevice(undefined);115    setBusy(false);116    setPhase("enter");117  }118119  async function startShareFlow(): Promise<void> {120    setMode("share");121    setSharePhase("opening");122    setShareError("");123    setInvitation("");124    setSourceSas("");125    try {126      const response = (await sendMessage({127        type: "START_ADOPTION_SOURCE",128      })) as {129        ok: boolean;130        data?: { invitation?: string };131        error?: string;132      };133      if (!response.ok || typeof response.data?.invitation !== "string") {134        throw new Error(response.error ?? "adoption_source_failed");135      }136      setInvitation(response.data.invitation);137      setSharePhase("invitation");138    } catch (error) {139      setShareError(error instanceof Error ? error.message : "adoption_source_failed");140      setSharePhase("error");141    }142  }143144  async function stopShareFlow(): Promise<void> {145    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);146  }147148  async function useCodeInstead(): Promise<void> {149    await stopShareFlow();150    startFlow();151  }152153  async function verifySourcePassword(password: string): Promise<boolean> {154    const response = (await sendMessage({155      type: "ADOPTION_SOURCE_CONFIRM",156      payload: { password },157    })) as { ok: boolean; data?: { valid?: boolean } };158    return response.ok && response.data?.valid === true;159  }160161  function onCodeInput(value: string): void {162    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);163    setCode(formatPairingCode(cleaned));164  }165166  async function handleConnect(): Promise<void> {167    if (!codeReady() || busy()) return;168    setBusy(true);169    setPhase("connecting");170    try {171      const res = (await sendMessage({172        type: "PAIR_WITH_CODE",173        payload: { code: code() },174      })) as { ok: boolean; error?: string };175      // A synchronous start failure (e.g. vault_locked) returns here; the SAS176      // and the terminal result arrive later as pushes.177      if (!res.ok) {178        setErrorKind((res.error as CodePairingError) ?? "unknown");179        setPhase("error");180      }181    } catch {182      setErrorKind("unknown");183      setPhase("error");184    } finally {185      setBusy(false);186    }187  }188189  async function respondConfirm(confirmed: boolean): Promise<void> {190    if (busy()) return;191    setBusy(true);192    // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the193    // final phase (paired vs sas_mismatch error).194    if (confirmed) setPhase("connecting");195    else { setErrorKind("sas_mismatch"); setPhase("error"); }196    try {197      await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });198    } finally {199      setBusy(false);200    }201  }202203  // Auto-dismiss after the paired state settles — back to the Settings hub,204  // which reloads the paired-device list on mount.205  createEffect(() => {206    if (phase() !== "paired") return;207    const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);208    onCleanup(() => clearTimeout(t));209  });210211  onMount(() => {212    // Opened straight into a pairing path from the Settings sync card. With no213    // method there's nothing to do here — return to Settings (the hub).214    if (props.initialPair === "code") {215      void sendMessage({ type: "GET_RELAY_SETTINGS" })216        .then((response) => {217          const settings = response as {218            ok: boolean;219            data?: { lanOnly?: boolean };220          };221          if (settings.ok && settings.data?.lanOnly !== true) {222            return startShareFlow();223          }224          startFlow();225        })226        .catch(() => startFlow());227    } else props.onBack();228229    // Pairing pushes from the background (bg → popup). The whole NK230    // pairing runs in the service worker; the SAS to confirm and the terminal231    // result arrive here as runtime messages.232    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {233      const handler = (msg: { type?: string; payload?: unknown }) => {234        if (msg?.type === "CODE_PAIR_SAS") {235          const p = msg.payload as { sas?: string } | undefined;236          if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }237        } else if (msg?.type === "CODE_PAIR_RESULT") {238          const p = msg.payload as239            | { status: "complete"; device: PairedDevice }240            | { status: "error"; error: CodePairingError }241            | undefined;242          if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }243          else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }244        } else if (msg?.type === "ADOPTION_SOURCE_SAS") {245          const p = msg.payload as {246            sas?: string;247            receiverDeviceName?: string;248          } | undefined;249          if (typeof p?.sas === "string") {250            setSourceSas(p.sas);251            setReceiverName(252              typeof p.receiverDeviceName === "string" && p.receiverDeviceName253                ? p.receiverDeviceName254                : "New device",255            );256            setSharePhase("sas");257          }258        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {259          const p = msg.payload as {260            status?: "complete" | "error";261            error?: string;262          } | undefined;263          if (p?.status === "complete") setSharePhase("paired");264          else if (p?.status === "error") {265            setShareError(p.error ?? "adoption_failed");266            setSharePhase("error");267          }268        }269      };270      chrome.runtime.onMessage.addListener(handler);271      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));272    }273    // If the popup closes while the user is on the SAS screen, resolve the274    // pending background confirm as false so the service worker clears275    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".276    onCleanup(() => {277      if (phase() === "sas") {278        void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });279      }280      if (mode() === "share" && sharePhase() !== "paired") {281        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });282      }283    });284  });285286  // --- Sub-views --------------------------------------------------287288  const backArrow = (289    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">290      <path d="M10 3 L5 8 L10 13" />291    </svg>292  );293294  // The bird stage — always animating — shared by every pairing screen.295  const birdStage = (active: boolean) => (296    <div class={`peach-pair-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">297      <div class="peach-pair-bird-slot peach-pair-bird-slot-desktop">298        <DesktopBird pose="chirping" />299      </div>300      <div class="peach-pair-bird-slot peach-pair-bird-slot-mobile">301        <MobileBird pose="attentive" />302      </div>303    </div>304  );305306  function liveText(): string {307    switch (phase()) {308      case "enter": return "Enter the pairing code from your phone";309      case "connecting": return "Connecting to your phone";310      case "sas": return "Confirm the code matches on your phone";311      case "paired": return "Paired";312      case "error": return errorCopy(errorKind()).headline;313    }314  }315316  // Code pairing — birds animate on every screen below.317  const pairingScreen = () => (318    <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">319      <header class="peach-pair-head">320        <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>321        <div class="peach-pair-title">Enter a code</div>322        <div class="peach-pair-head-spacer" />323      </header>324325      {birdStage(phase() !== "enter")}326327      <Show when={phase() === "enter"}>328        <div class="peach-desktop-enter">329          <div class="peach-pair-headline">Enter the code from your phone</div>330          <div class="peach-pair-sub">331            Open Peach on your phone, tap “Pair a device”, and type the code it shows.332            Both devices need to be on the same Wi-Fi.333          </div>334          <label class="peach-label" for="pair-code">pairing code</label>335          <input336            id="pair-code"337            class="peach-underline-input peach-unlock-input peach-code-input"338            type="text"339            autocapitalize="characters"340            autocomplete="one-time-code"341            spellcheck={false}342            maxlength={32}343            placeholder="A74H-48J3-KI32-OEP4"344            aria-label="Pairing code"345            value={code()}346            ref={(el) => setTimeout(() => el.focus(), 80)}347            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}348            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}349          />350          <div class="peach-pair-action">351            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>352            <button353              class="peach-btn peach-btn-primary peach-desktop-connect"354              disabled={!codeReady() || busy()}355              onClick={() => void handleConnect()}356            >357              Connect358            </button>359          </div>360        </div>361      </Show>362363      <Show when={phase() === "connecting"}>364        <div class="peach-desktop-searching">365          <div class="peach-pair-headline">Connecting…</div>366          <div class="peach-pair-sub">Reaching your phone over the local network.</div>367        </div>368      </Show>369370      <Show when={phase() === "sas"}>371        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">372          <div class="peach-pair-headline">Confirm this code matches on your phone:</div>373          <div374            class="peach-desktop-sas"375            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;"376          >377            {sas()}378          </div>379          <div class="peach-pair-sub" style="max-width:240px;">380            Only continue if this 8-digit code is identical on both screens. This is381            what proves you're talking to your own device.382          </div>383          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">384            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>385              Confirm386            </button>387            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>388              Cancel389            </button>390          </div>391        </div>392      </Show>393394      <Show when={phase() === "paired"}>395        <div class="peach-desktop-paired">396          <div class="peach-pair-headline">Paired!</div>397          <div class="peach-pair-sub">398            {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}399          </div>400          <div class="peach-pair-action">401            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>402          </div>403        </div>404      </Show>405406      <Show when={phase() === "error"}>407        <div class="peach-desktop-error">408          <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>409          <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>410          <div class="peach-pair-action">411            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>412            <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>413          </div>414        </div>415      </Show>416417      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>418    </div>419  );420421  const shareScreen = () => (422    <div423      class="peach-pair-pair peach-desktop-pair peach-adoption-source"424      data-phase={sharePhase()}425      role="dialog"426      aria-label="Share this vault with a new device"427    >428      <header class="peach-pair-head">429        <button430          class="peach-pair-back"431          aria-label="Back"432          onClick={() => { void stopShareFlow(); props.onBack(); }}433        >434          {backArrow}435        </button>436        <div class="peach-pair-title">Add a new device</div>437        <div class="peach-pair-head-spacer" />438      </header>439440      {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}441442      <Show when={sharePhase() === "opening"}>443        <div class="peach-desktop-searching">444          <div class="peach-pair-headline">Opening a secure pairing room…</div>445          <div class="peach-pair-sub">The QR appears only after this browser is waiting in the relay.</div>446        </div>447      </Show>448449      <Show when={sharePhase() === "invitation"}>450        <div class="peach-adoption-source-invitation">451          <div class="peach-pair-headline">Scan this in Peach</div>452          <div class="peach-pair-sub">453            Connecting via secure relay — works anywhere. The QR contains only454            the relay address and a single-use random token.455          </div>456          <PairingQrCode value={invitation()} size={224} />457          <div class="peach-pair-action">458            <button459              class="peach-btn peach-btn-ghost"460              onClick={() => void useCodeInstead()}461            >462              Enter a code instead463            </button>464          </div>465        </div>466      </Show>467468      <Show when={sharePhase() === "connecting"}>469        <div class="peach-desktop-searching">470          <div class="peach-pair-headline">Waiting for the new device…</div>471          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>472        </div>473      </Show>474475      <Show when={sharePhase() === "paired"}>476        <div class="peach-desktop-paired">477          <div class="peach-pair-headline">Device added</div>478          <div class="peach-pair-sub">The new device can now sync this vault through the secure relay.</div>479          <div class="peach-pair-action">480            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>481          </div>482        </div>483      </Show>484485      <Show when={sharePhase() === "error"}>486        <div class="peach-desktop-error">487          <div class="peach-pair-headline">Could not add the device</div>488          <div class="peach-pair-sub">489            {shareError() === "vault_locked"490              ? "Unlock Peach first, then try again."491              : "The secure pairing session ended. Start again with a fresh QR."}492          </div>493          <div class="peach-pair-action">494            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>495            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>496          </div>497        </div>498      </Show>499500      <Show when={sharePhase() === "sas"}>501        <AdoptionConsentModal502          receiverDeviceName={receiverName()}503          sas={sourceSas()}504          verifyMasterPassword={verifySourcePassword}505          onCancel={() => {506            void sendMessage({507              type: "ADOPTION_SOURCE_CONFIRM",508              payload: { cancel: true },509            });510            setShareError("adoption_rejected");511            setSharePhase("error");512          }}513          onApprove={async () => {514            setSharePhase("connecting");515          }}516        />517      </Show>518    </div>519  );520521  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can522  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.523  return mode() === "share" ? shareScreen() : pairingScreen();524};