ChirpSync.tsx

14.4 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 (desktop-pairing.ts) and reaches14// this view via the PAIR_WITH_DESKTOP / 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/ChirpPairingFlow";27import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";2829export interface ChirpSyncViewProps {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?: "desktop" | 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 DesktopPairingError union.44type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";45type DesktopPairingError =46  | "invalid_code"47  | "no_host_found"48  | "handshake_failed"49  | "sas_mismatch"50  | "vault_locked";5152const DESKTOP_PAIRED_DISMISS_MS = 2_000;5354function desktopErrorCopy(error: DesktopPairingError | "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 ChirpSyncView: Component<ChirpSyncViewProps> = (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_DESKTOP /90  // DESKTOP_PAIR_* messages. The SAS gate is what authenticates the peer91  // (audit HIGH — pairing initiator authentication), not the pairing token.92  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");93  const [desktopCode, setDesktopCode] = createSignal("");94  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);95  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");96  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);97  const [desktopBusy, setDesktopBusy] = 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 desktopCodeReady = () =>103    parsePairingCode(desktopCode()) !== null || /^\d{6}$/.test(desktopCode());104105  function startDesktopFlow(): void {106    setDesktopCode("");107    setDesktopSas(null);108    setDesktopError("unknown");109    setDesktopDevice(undefined);110    setDesktopBusy(false);111    setDesktopPhase("enter");112  }113114  function onDesktopCodeInput(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    setDesktopCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);120  }121122  async function handleDesktopConnect(): Promise<void> {123    if (!desktopCodeReady() || desktopBusy()) return;124    setDesktopBusy(true);125    setDesktopPhase("connecting");126    try {127      const res = (await sendMessage({128        type: "PAIR_WITH_DESKTOP",129        payload: { code: desktopCode() },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        setDesktopError((res.error as DesktopPairingError) ?? "unknown");135        setDesktopPhase("error");136      }137    } catch {138      setDesktopError("unknown");139      setDesktopPhase("error");140    } finally {141      setDesktopBusy(false);142    }143  }144145  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {146    if (desktopBusy()) return;147    setDesktopBusy(true);148    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the149    // final phase (paired vs sas_mismatch error).150    if (confirmed) setDesktopPhase("connecting");151    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }152    try {153      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });154    } finally {155      setDesktopBusy(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 (desktopPhase() !== "paired") return;163    const t = setTimeout(() => props.onBack(), DESKTOP_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 === "desktop") startDesktopFlow();171    else props.onBack();172173    // Desktop 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 === "DESKTOP_PAIR_SAS") {179          const p = msg.payload as { sas?: string } | undefined;180          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }181        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {182          const p = msg.payload as183            | { status: "complete"; device: PairedDevice }184            | { status: "error"; error: DesktopPairingError }185            | undefined;186          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }187          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("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 (desktopPhase() === "sas") {198        void sendMessage({ type: "DESKTOP_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 desktop pairing screen212  // so the bird animation is present regardless of sync method.213  const desktopBirdStage = (active: boolean) => (214    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">215      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">216        <DesktopBird pose="chirping" />217      </div>218      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">219        <MobileBird pose="attentive" />220      </div>221    </div>222  );223224  function desktopLiveText(): string {225    switch (desktopPhase()) {226      case "enter": return "Enter the pairing code from your phone";227      case "connecting": return "Connecting to your phone";228      case "sas": return "Confirm the code matches on your phone";229      case "paired": return "Paired";230      case "error": return desktopErrorCopy(desktopError()).headline;231    }232  }233234  // Desktop helper code pairing — birds animate on every screen below.235  const desktopView = () => (236    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a device with a code">237      <header class="peach-chirp-head">238        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>239        <div class="peach-chirp-title">Enter a code</div>240        <div class="peach-chirp-head-spacer" />241      </header>242243      {desktopBirdStage(desktopPhase() !== "enter")}244245      <Show when={desktopPhase() === "enter"}>246        <div class="peach-desktop-enter">247          <div class="peach-chirp-headline">Enter the code from your phone</div>248          <div class="peach-chirp-sub">249            Open Peach on your phone, tap “Pair a device”, and type the code it shows.250            Both devices need to be on the same Wi-Fi.251          </div>252          <label class="peach-label" for="desktop-pair-code">pairing code</label>253          <input254            id="desktop-pair-code"255            class="peach-underline-input peach-unlock-input peach-code-input"256            type="text"257            autocapitalize="characters"258            autocomplete="one-time-code"259            spellcheck={false}260            maxlength={32}261            placeholder="A74H-48J3-KI32-OEP4"262            aria-label="Pairing code"263            value={desktopCode()}264            ref={(el) => setTimeout(() => el.focus(), 80)}265            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}266            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}267          />268          <div class="peach-chirp-action">269            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>270            <button271              class="peach-btn peach-btn-primary peach-desktop-connect"272              disabled={!desktopCodeReady() || desktopBusy()}273              onClick={() => void handleDesktopConnect()}274            >275              Connect276            </button>277          </div>278        </div>279      </Show>280281      <Show when={desktopPhase() === "connecting"}>282        <div class="peach-desktop-searching">283          <div class="peach-chirp-headline">Connecting…</div>284          <div class="peach-chirp-sub">Reaching your phone over the local network.</div>285        </div>286      </Show>287288      <Show when={desktopPhase() === "sas"}>289        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">290          <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>291          <div292            class="peach-desktop-sas"293            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;"294          >295            {desktopSas()}296          </div>297          <div class="peach-chirp-sub" style="max-width:240px;">298            Only continue if this 6-digit code is identical on both screens. This is299            what proves you're talking to your own device.300          </div>301          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">302            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>303              Confirm304            </button>305            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>306              Cancel307            </button>308          </div>309        </div>310      </Show>311312      <Show when={desktopPhase() === "paired"}>313        <div class="peach-desktop-paired">314          <div class="peach-chirp-headline">Paired!</div>315          <div class="peach-chirp-sub">316            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your phone"}317          </div>318          <div class="peach-chirp-action">319            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>320          </div>321        </div>322      </Show>323324      <Show when={desktopPhase() === "error"}>325        <div class="peach-desktop-error">326          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>327          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>328          <div class="peach-chirp-action">329            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>330            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>331          </div>332        </div>333      </Show>334335      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>336    </div>337  );338339  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can340  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.341  return desktopView();342};