ChirpSync.tsx

33.5 KB · raw

1// ChirpSync — the v3 Settings → Sync surface.2//3// Three modes the user can be in:4//5//   1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6//      to the License section.7//   2. Idle — list of paired devices + "Pair a device" hero button. Stale8//      devices (>7 days since last sync) show a soft warning indicator;9//      vouched devices (received via device manifest, v2 brief §4.2) show10//      an "Added automatically" badge.11//   3. Pairing — ChirpPairingFlow takes over the full surface and drives12//      the three-act bird animation in lockstep with the audio protocol.13//14// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15// the binding between that orchestrator, the background-message16// CHIRP_* handlers, and the Settings navigation surrounding it.1718import {19  Component,20  Show,21  createEffect,22  createMemo,23  createSignal,24  For,25  onCleanup,26  onMount,27} from "solid-js";28import { sendMessage } from "../../shared/bridge";29import type {30  DeviceType,31  PairedDevice,32  PairingProgress,33} from "../../shared/sync-types";34import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";35import {36  ChirpPairingFailure,37  runChirpPairing,38  type ChirpPairingDeps,39  type PairingRole,40} from "../chirp-pair";41import { getLocalSubnet } from "../../shared/local-network";42import { isNativePlatform } from "../../shared/native";43import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4445export interface ChirpSyncViewProps {46  isPro: boolean;47  onBack(): void;48  onLearnPro(): void;49}5051const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5253type Mode = "rest" | "pairing";5455// Which pairing path the user picked on the method-select screen. `null` =56// still choosing. The birds animate the same way on both paths.57type PairKind = "chirp" | "desktop" | null;5859// Desktop helper (peach-sync) code-pairing phases. The SAS and success60// screens are shared with the chirp path; "enter"/"connecting" are unique to61// the typed-code flow. Mirrors the background DesktopPairingError union.62type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";63type DesktopPairingError =64  | "invalid_code"65  | "no_host_found"66  | "handshake_failed"67  | "sas_mismatch"68  | "vault_locked";6970const DESKTOP_PAIRED_DISMISS_MS = 2_000;7172function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {73  switch (error) {74    case "invalid_code":75      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.76      return {77        headline: "Code not found or expired",78        sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",79      };80    case "no_host_found":81      return {82        headline: "Couldn't find your computer",83        sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi.",84      };85    case "sas_mismatch":86      return {87        headline: "Codes don't match",88        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",89      };90    case "vault_locked":91      return {92        headline: "Unlock Peach first",93        sub: "Your vault needs to be unlocked before you can pair a device.",94      };95    case "handshake_failed":96    default:97      return {98        headline: "Pairing failed",99        sub: "Something went wrong while connecting. Please try again.",100      };101  }102}103104export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {105  const [devices, setDevices] = createSignal<PairedDevice[]>([]);106  const [mode, setMode] = createSignal<Mode>("rest");107  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });108  const [error, setError] = createSignal<string | null>(null);109  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);110  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");111  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);112  // Out-of-band pairing verification: the 6-digit code both devices derive113  // from the shared handshake; the user confirms it matches before the pair114  // is persisted (audit HIGH — pairing initiator authentication).115  const [sasCode, setSasCode] = createSignal<string | null>(null);116  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);117118  function resolveSAS(ok: boolean) {119    const r = sasResolve();120    setSasCode(null);121    setSasResolve(null);122    r?.(ok);123  }124125  // --- Desktop helper (peach-sync) code pairing -------------------------126  // The whole NK pairing runs in the background; this view drives the UI and127  // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.128  const [pairKind, setPairKind] = createSignal<PairKind>(null);129  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");130  const [desktopCode, setDesktopCode] = createSignal("");131  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);132  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");133  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);134  const [desktopBusy, setDesktopBusy] = createSignal(false);135136  const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());137138  function openPairMethodSelect(): void {139    setError(null);140    setPairKind(null);141    setMode("pairing");142  }143144  function startDesktopFlow(): void {145    setDesktopCode("");146    setDesktopSas(null);147    setDesktopError("unknown");148    setDesktopDevice(undefined);149    setDesktopBusy(false);150    setDesktopPhase("enter");151    setPairKind("desktop");152  }153154  function startChirpFlow(): void {155    setPairKind("chirp");156    void handleStartPairing();157  }158159  function onDesktopCodeInput(value: string): void {160    setDesktopCode(value.replace(/\D/g, "").slice(0, 6));161  }162163  async function handleDesktopConnect(): Promise<void> {164    if (!desktopCodeReady() || desktopBusy()) return;165    setDesktopBusy(true);166    setDesktopPhase("connecting");167    try {168      const res = (await sendMessage({169        type: "PAIR_WITH_DESKTOP",170        payload: { code: desktopCode() },171      })) as { ok: boolean; error?: string };172      // A synchronous start failure (e.g. vault_locked) returns here; the SAS173      // and the terminal result arrive later as pushes.174      if (!res.ok) {175        setDesktopError((res.error as DesktopPairingError) ?? "unknown");176        setDesktopPhase("error");177      }178    } catch {179      setDesktopError("unknown");180      setDesktopPhase("error");181    } finally {182      setDesktopBusy(false);183    }184  }185186  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {187    if (desktopBusy()) return;188    setDesktopBusy(true);189    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the190    // final phase (paired vs sas_mismatch error).191    if (confirmed) setDesktopPhase("connecting");192    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }193    try {194      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });195    } finally {196      setDesktopBusy(false);197    }198  }199200  // Auto-dismiss after the paired state settles, like the chirp flow.201  createEffect(() => {202    if (desktopPhase() !== "paired") return;203    const t = setTimeout(() => {204      setMode("rest");205      setPairKind(null);206      void refresh();207    }, DESKTOP_PAIRED_DISMISS_MS);208    onCleanup(() => clearTimeout(t));209  });210211  async function handleSyncNow(): Promise<void> {212    setSyncStatus("syncing");213    setSyncFlash(null);214    const res = await sendMessage({215      type: "TRIGGER_SYNC",216      payload: { reason: "manual", force: true },217    }) as { ok: boolean; error?: string };218    // Poll briefly for the new state — the dispatcher broadcasts but the219    // popup doesn't yet listen, so a short re-fetch covers it.220    setTimeout(async () => {221      const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {222        ok: boolean;223        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };224      };225      const s = state.data;226      if (!s) return;227      setSyncStatus(s.status);228      if (s.status === "idle") {229        setSyncFlash(230          (s.entriesReceived ?? 0) > 0231            ? `Synced — ${s.entriesReceived} entries updated`232            : "Vault up to date",233        );234        setTimeout(() => setSyncFlash(null), 3000);235        await refresh();236      } else if (s.status === "error") {237        setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);238        setTimeout(() => setSyncFlash(null), 4000);239      }240    }, 250);241    if (!res.ok) {242      setSyncStatus("error");243      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);244      setTimeout(() => setSyncFlash(null), 4000);245    }246  }247248  onMount(() => {249    void refresh();250251    // Desktop pairing pushes from the background (bg → popup). The whole NK252    // pairing runs in the service worker; the SAS to confirm and the terminal253    // result arrive here as runtime messages.254    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {255      const handler = (msg: { type?: string; payload?: unknown }) => {256        if (pairKind() !== "desktop") return;257        if (msg?.type === "DESKTOP_PAIR_SAS") {258          const p = msg.payload as { sas?: string } | undefined;259          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }260        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {261          const p = msg.payload as262            | { status: "complete"; device: PairedDevice }263            | { status: "error"; error: DesktopPairingError }264            | undefined;265          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }266          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }267        }268      };269      chrome.runtime.onMessage.addListener(handler);270      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));271    }272  });273274  async function refresh(): Promise<void> {275    if (!props.isPro) return;276    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });277    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;278    setDevices(data?.devices ?? []);279  }280281  async function handleStartPairing(): Promise<void> {282    setError(null);283284    // audioCapture is declared as optional_permissions in the manifest.285    // Chrome will silently deny getUserMedia unless we request it here,286    // during a user gesture. Firefox ignores this API but getUserMedia works287    // under its existing host permission grant.288    if (typeof chrome !== "undefined" && chrome.permissions?.request) {289      const granted = await chrome.permissions.request({290        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],291      });292      if (!granted) {293        setPairing({ phase: "mic_denied" });294        setMode("pairing");295        return;296      }297    }298299    setPairing({ phase: "idle" });300    setMode("pairing");301302    const deps: ChirpPairingDeps = {303      onProgress: (p) => setPairing(p),304      fetchLocalIdentity: async () => {305        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });306        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };307        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");308        // The handler returns a LocalIdentity; we trust the background's shape.309        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;310      },311      completePairing: async (paired) => {312        const res = await sendMessage({313          type: "CHIRP_COMPLETE_PAIRING",314          payload: { paired },315        });316        const r = res as { ok: boolean; error?: string };317        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");318      },319      confirmSAS: (sas) =>320        new Promise<boolean>((resolve) => {321          setSasResolve(() => resolve);322          setSasCode(sas);323        }),324      getLocalSubnet,325      openPairingTransport: async (endpoint) => {326        // Native WebSocket transport for the pairing-mode peer scan. The327        // chirp-pair orchestrator only uses send/receive/close, so a thin328        // adapter over the platform WebSocket suffices.329        return new Promise((resolve, reject) => {330          let ws: WebSocket;331          try {332            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);333          } catch (err) {334            reject(err);335            return;336          }337          const inbox: Uint8Array[] = [];338          const waiters: Array<(b: Uint8Array) => void> = [];339          let closed = false;340          ws.binaryType = "arraybuffer";341          const timer = setTimeout(() => {342            try { ws.close(); } catch { /* ignore */ }343            reject(new Error("pairing_transport_timeout"));344          }, 2_000);345          ws.onopen = () => {346            clearTimeout(timer);347            resolve({348              async send(data: Uint8Array) {349                ws.send(data as Uint8Array<ArrayBuffer>);350              },351              receive(): Promise<Uint8Array> {352                if (closed) return Promise.reject(new Error("closed"));353                const v = inbox.shift();354                if (v) return Promise.resolve(v);355                return new Promise<Uint8Array>((res) => waiters.push(res));356              },357              async close() {358                try { ws.close(); } catch { /* ignore */ }359              },360            });361          };362          ws.onmessage = (ev) => {363            if (!(ev.data instanceof ArrayBuffer)) return;364            const bytes = new Uint8Array(ev.data);365            const w = waiters.shift();366            if (w) w(bytes); else inbox.push(bytes);367          };368          ws.onerror = () => {369            clearTimeout(timer);370            reject(new Error("pairing_transport_error"));371          };372          ws.onclose = () => {373            closed = true;374            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));375          };376        });377      },378    };379380    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";381    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;382383    try {384      await runChirpPairing(deps, role);385      await refresh();386    } catch (err) {387      if (err instanceof ChirpPairingFailure) {388        // The orchestrator already pushed the terminal phase into389        // `pairing()`; the inline error copy will render. No further390        // action needed — the user can tap "Try again" or "Cancel".391        return;392      }393      setError((err as Error).message);394      setMode("rest");395    }396  }397398  async function handleUnpair(deviceId: string): Promise<void> {399    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });400    setConfirmUnpair(null);401    await refresh();402  }403404  function relativeTime(when: number | undefined): string {405    if (!when) return "Not synced yet";406    const elapsed = Date.now() - when;407    if (elapsed < 60_000) return "Just now";408    const minutes = Math.floor(elapsed / 60_000);409    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;410    const hours = Math.floor(minutes / 60);411    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;412    const days = Math.floor(hours / 24);413    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;414    return "Not seen recently";415  }416417  function isStale(d: PairedDevice): boolean {418    if (d.lastSyncedAt === undefined) return false;419    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;420  }421422  // --- Sub-views --------------------------------------------------423424  const proGateView = () => (425    <div class="peach-chirp-settings peach-chirp-settings--gated">426      <header class="peach-chirp-settings-header">427        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>428          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">429            <path d="M11 4 L5 9 L11 14" />430          </svg>431        </button>432        <h1 class="peach-chirp-settings-title">Sync</h1>433      </header>434      <div class="peach-chirp-pro-card">435        <div class="peach-chirp-pro-stage" aria-hidden="true">436          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"437               stroke-linecap="round" stroke-linejoin="round">438            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />439            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />440            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />441          </svg>442          <div class="peach-chirp-pro-bird-slot">443            <MobileBird pose="idle" />444          </div>445        </div>446        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>447        <p class="peach-chirp-pro-body">448          Pairs over an inaudible chirp. Syncs locally across your devices.449          No codes, no servers, no cloud.450        </p>451        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>452          Unlock with Peach Pro453        </button>454        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>455      </div>456    </div>457  );458459  const restView = () => (460    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">461      <header class="peach-chirp-head">462        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>463          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">464            <path d="M10 3 L5 8 L10 13" />465          </svg>466        </button>467        <div class="peach-chirp-title">Sync</div>468        <div class="peach-chirp-head-spacer" />469      </header>470471      <div class="peach-chirp-chip-row">472        <div class="peach-chirp-chip" data-phase="idle">473          <span class="peach-chirp-chip-dot" />474          <span class="peach-chirp-chip-text">ready</span>475        </div>476      </div>477478      <Show when={error()}>479        <div class="peach-chirp-settings-error" role="alert">{error()}</div>480      </Show>481482      <div class="peach-chirp-stage act-idle" aria-hidden="true">483        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">484          <DesktopBird pose="idle" />485        </div>486        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">487          <MobileBird pose="hidden" />488        </div>489      </div>490491      <div class="peach-chirp-copy-host">492        <div class="peach-chirp-copy peach-chirp-copy-rest">493          <div class="peach-chirp-headline">494            {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}495          </div>496          <div class="peach-chirp-sub">497            {devices().length === 0498              ? "Peach pairs over a short, inaudible chirp — no codes, no servers."499              : "Pair another device, or manage the ones already connected below."}500          </div>501        </div>502      </div>503504      <Show when={devices().length > 0}>505        <section class="peach-chirp-paired-section">506          <h2 class="peach-chirp-paired-heading">Paired devices</h2>507          <ul class="peach-chirp-device-list">508            <For each={devices()}>509              {(d) => (510                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>511                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />512                  <div class="peach-chirp-device-info">513                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>514                    <span class="peach-chirp-device-meta">515                      {relativeTime(d.lastSyncedAt)}516                      <Show when={d.trustSource === "vouched"}>517                        <span class="peach-chirp-device-badge">Added automatically</span>518                      </Show>519                      <Show when={isStale(d)}>520                        <span class="peach-chirp-device-stale">Not seen recently</span>521                      </Show>522                    </span>523                  </div>524                  <Show525                    when={confirmUnpair() !== d.deviceId}526                    fallback={527                      <div class="peach-chirp-unpair-confirm">528                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>529                          Cancel530                        </button>531                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>532                          Remove533                        </button>534                      </div>535                    }536                  >537                    <button538                      class="peach-icon-btn peach-chirp-device-remove"539                      aria-label={`Unpair ${d.deviceName}`}540                      onClick={() => setConfirmUnpair(d.deviceId)}541                    >542                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">543                        <path d="M3 3 L11 11 M11 3 L3 11" />544                      </svg>545                    </button>546                  </Show>547                </li>548              )}549            </For>550          </ul>551        </section>552      </Show>553554      <Show when={devices().length > 0}>555        <div class="peach-chirp-sync-now-row">556          <button557            class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"558            disabled={syncStatus() === "syncing"}559            onClick={() => void handleSyncNow()}560          >561            {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}562          </button>563          <Show when={syncFlash()}>564            <span class="peach-chirp-sync-flash" aria-live="polite">565              {syncFlash()}566            </span>567          </Show>568        </div>569      </Show>570571      <div class="peach-chirp-action">572        <p class="peach-chirp-mic-notice">573          Pair over a chirp in the same room, or with the desktop helper by code.574        </p>575        <button class="peach-chirp-pair-start" onClick={() => openPairMethodSelect()}>576          <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">577            <path d="M3 9 L9 3 L15 9" />578            <path d="M3 13 L9 7 L15 13" opacity="0.5" />579          </svg>580          <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>581        </button>582      </div>583584    </div>585  );586587  const backArrow = (588    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">589      <path d="M10 3 L5 8 L10 13" />590    </svg>591  );592593  // The bird stage — always animating — shared by every desktop pairing screen594  // so the bird animation is present regardless of sync method.595  const desktopBirdStage = (active: boolean) => (596    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">597      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">598        <DesktopBird pose="chirping" />599      </div>600      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">601        <MobileBird pose="attentive" />602      </div>603    </div>604  );605606  function desktopLiveText(): string {607    switch (desktopPhase()) {608      case "enter": return "Enter the pairing code from your computer";609      case "connecting": return "Searching for the helper";610      case "sas": return "Confirm the code matches on your computer";611      case "paired": return "Paired";612      case "error": return desktopErrorCopy(desktopError()).headline;613    }614  }615616  // Method-select — the first screen, before any birds. Two paths: a same-room617  // audio chirp, or a typed code from the peach-sync desktop helper.618  const methodSelectView = () => (619    <div class="peach-chirp-pair peach-desktop-pair" data-phase="method" role="region" aria-label="Choose a pairing method">620      <header class="peach-chirp-head">621        <button class="peach-chirp-back" aria-label="Back" onClick={() => setMode("rest")}>{backArrow}</button>622        <div class="peach-chirp-title">Pair a device</div>623        <div class="peach-chirp-head-spacer" />624      </header>625      <div class="peach-chirp-copy-host">626        <div class="peach-chirp-copy">627          <div class="peach-chirp-headline">How do you want to pair?</div>628          <div class="peach-chirp-sub">629            In the same room? Use a chirp. Pairing with the Peach desktop helper? Enter its code.630          </div>631        </div>632      </div>633      <div class="peach-pair-method-list">634        <button class="peach-pair-method peach-chirp-pair-start" onClick={() => startChirpFlow()}>635          <span class="peach-pair-method-title">Same room</span>636          <span class="peach-pair-method-sub">Chirp — audio</span>637        </button>638        <button class="peach-pair-method peach-chirp-pair-start peach-chirp-pair-start--desktop" onClick={() => startDesktopFlow()}>639          <span class="peach-pair-method-title">Desktop helper</span>640          <span class="peach-pair-method-sub">enter code</span>641        </button>642      </div>643      <div class="peach-chirp-action">644        <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>645      </div>646    </div>647  );648649  // Desktop helper code pairing — birds animate on every screen below.650  const desktopView = () => (651    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">652      <header class="peach-chirp-head">653        <button class="peach-chirp-back" aria-label="Back" onClick={() => setPairKind(null)}>{backArrow}</button>654        <div class="peach-chirp-title">Desktop Helper</div>655        <div class="peach-chirp-head-spacer" />656      </header>657658      {desktopBirdStage(desktopPhase() !== "enter")}659660      <Show when={desktopPhase() === "enter"}>661        <div class="peach-desktop-enter">662          <div class="peach-chirp-headline">Enter the code from your computer</div>663          <div class="peach-chirp-sub">664            Open the Peach helper on your computer and type the 6-digit code it shows.665          </div>666          <label class="peach-label" for="desktop-pair-code">pairing code</label>667          <input668            id="desktop-pair-code"669            class="peach-underline-input peach-unlock-input peach-code-input"670            type="text"671            inputmode="numeric"672            autocomplete="one-time-code"673            maxlength={6}674            placeholder="000000"675            aria-label="6-digit pairing code"676            value={desktopCode()}677            ref={(el) => setTimeout(() => el.focus(), 80)}678            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}679            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}680          />681          <div class="peach-chirp-action">682            <button class="peach-btn peach-btn-ghost" onClick={() => setPairKind(null)}>Cancel</button>683            <button684              class="peach-btn peach-btn-primary peach-desktop-connect"685              disabled={!desktopCodeReady() || desktopBusy()}686              onClick={() => void handleDesktopConnect()}687            >688              Connect689            </button>690          </div>691        </div>692      </Show>693694      <Show when={desktopPhase() === "connecting"}>695        <div class="peach-desktop-searching">696          <div class="peach-chirp-headline">Searching for helper…</div>697          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>698        </div>699      </Show>700701      <Show when={desktopPhase() === "sas"}>702        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">703          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>704          <div705            class="peach-desktop-sas"706            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;"707          >708            {desktopSas()}709          </div>710          <div class="peach-chirp-sub" style="max-width:240px;">711            Only continue if this 6-digit code is identical on both screens.712          </div>713          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">714            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>715              Confirm716            </button>717            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>718              Cancel719            </button>720          </div>721        </div>722      </Show>723724      <Show when={desktopPhase() === "paired"}>725        <div class="peach-desktop-paired">726          <div class="peach-chirp-headline">Paired!</div>727          <div class="peach-chirp-sub">728            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}729          </div>730          <div class="peach-chirp-action">731            <button class="peach-btn peach-btn-primary" onClick={() => { setMode("rest"); setPairKind(null); void refresh(); }}>Done</button>732          </div>733        </div>734      </Show>735736      <Show when={desktopPhase() === "error"}>737        <div class="peach-desktop-error">738          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>739          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>740          <div class="peach-chirp-action">741            <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>742            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>743          </div>744        </div>745      </Show>746747      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>748    </div>749  );750751  const chirpPairingView = () => (752    <Show753      when={sasCode()}754      fallback={755        <ChirpPairingFlow756          progress={pairing()}757          onCancel={() => setMode("rest")}758          onRetry={() => void handleStartPairing()}759          onDismiss={() => {760            setMode("rest");761            void refresh();762          }}763        />764      }765    >766      {/* Out-of-band verification gate — pairing is NOT persisted until the767          user confirms this code matches the one on the other device. */}768      <div769        class="chirp-sas-verify"770        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"771      >772        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>773        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">774          Confirm this code matches the one shown on your other device before pairing.775        </div>776        <div 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;">777          {sasCode()}778        </div>779        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">780          <button781            class="peach-btn peach-btn--accent peach-btn--md"782            type="button"783            onClick={() => resolveSAS(true)}784          >785            codes match — pair786          </button>787          <button788            class="peach-btn peach-btn--ghost peach-btn--md"789            type="button"790            onClick={() => resolveSAS(false)}791          >792            they don't match793          </button>794        </div>795      </div>796    </Show>797  );798799  // Pairing surface dispatch: method-select first (no birds yet), then either800  // the chirp flow or the desktop-helper flow depending on the chosen method.801  const pairingView = () => (802    <Show when={pairKind() !== null} fallback={methodSelectView()}>803      <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>804        {desktopView()}805      </Show>806    </Show>807  );808809  const currentMode = createMemo(() => {810    if (!props.isPro) return "gated";811    if (mode() === "pairing") return "pairing";812    return "rest";813  });814815  return (816    <Show817      when={currentMode() === "gated"}818      fallback={819        <Show when={currentMode() === "pairing"} fallback={restView()}>820          {pairingView()}821        </Show>822      }823    >824      {proGateView()}825    </Show>826  );827};828829const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {830  return (831    <div class="peach-chirp-device-icon" data-type={props.type}>832      <Show when={props.type === "android" || props.type === "ios"}>833        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">834          <rect x="6" y="2" width="8" height="16" rx="1.5" />835          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />836        </svg>837      </Show>838      <Show when={props.type === "extension" || props.type === "desktop"}>839        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">840          <rect x="2" y="4" width="16" height="10" rx="1.4" />841          <path d="M7 17 L13 17" />842          <path d="M9 14 L9 17 M11 14 L11 17" />843        </svg>844      </Show>845    </div>846  );847};