ChirpSync.tsx

21.5 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, entered straight into a method via5// `initialPair` and returning to Settings (`onBack`) when done or cancelled:6//7//   - Locked behind Pro (free user) — soft upsell card, "Learn more" routes8//     to the License section.9//   - Chirp — ChirpPairingFlow takes over the surface and drives the three-act10//     bird animation in lockstep with the audio protocol.11//   - Code — the peach-sync desktop-helper NK flow (enter code → SAS → paired).12//13// The chirp orchestrator lives in `popup/chirp-pair.ts`; the desktop NK pairing14// runs in the background and reaches this view via the PAIR_*/DESKTOP_PAIR_*15// messages.1617import {18  Component,19  Show,20  createEffect,21  createSignal,22  onCleanup,23  onMount,24} from "solid-js";25import { sendMessage } from "../../shared/bridge";26import type {27  PairedDevice,28  PairingProgress,29} from "../../shared/sync-types";30import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";31import {32  ChirpPairingFailure,33  runChirpPairing,34  type ChirpPairingDeps,35  type PairingRole,36} from "../chirp-pair";37import { getLocalSubnet } from "../../shared/local-network";38import { isNativePlatform } from "../../shared/native";39import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4041export interface ChirpSyncViewProps {42  isPro: boolean;43  onBack(): void;44  onLearnPro(): void;45  /**46   * Open straight into a pairing path, skipping the rest screen — set when the47   * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount.48   */49  initialPair?: "chirp" | "desktop" | null;50  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */51  onProChanged?: () => void;52}5354// Which pairing path the user picked, passed in via `initialPair`. The birds55// animate the same way on both paths.56type PairKind = "chirp" | "desktop" | null;5758// Desktop helper (peach-sync) code-pairing phases. The SAS and success59// screens are shared with the chirp path; "enter"/"connecting" are unique to60// the typed-code flow. Mirrors the background DesktopPairingError union.61type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";62type DesktopPairingError =63  | "invalid_code"64  | "no_host_found"65  | "handshake_failed"66  | "sas_mismatch"67  | "vault_locked";6869const DESKTOP_PAIRED_DISMISS_MS = 2_000;7071function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {72  switch (error) {73    case "invalid_code":74      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.75      return {76        headline: "Code not found or expired",77        sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",78      };79    case "no_host_found":80      return {81        headline: "Couldn't find your computer",82        sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",83      };84    case "sas_mismatch":85      return {86        headline: "Codes don't match",87        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",88      };89    case "vault_locked":90      return {91        headline: "Unlock Peach first",92        sub: "Your vault needs to be unlocked before you can pair a device.",93      };94    case "handshake_failed":95    default:96      return {97        headline: "Pairing failed",98        sub: "Something went wrong while connecting. Please try again.",99      };100  }101}102103export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {104  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });105  // Out-of-band pairing verification: the 6-digit code both devices derive106  // from the shared handshake; the user confirms it matches before the pair107  // is persisted (audit HIGH — pairing initiator authentication).108  const [sasCode, setSasCode] = createSignal<string | null>(null);109  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);110111  function resolveSAS(ok: boolean) {112    const r = sasResolve();113    setSasCode(null);114    setSasResolve(null);115    r?.(ok);116  }117118  // --- Desktop helper (peach-sync) code pairing -------------------------119  // The whole NK pairing runs in the background; this view drives the UI and120  // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.121  const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);122  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");123  const [desktopCode, setDesktopCode] = createSignal("");124  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);125  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");126  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);127  const [desktopBusy, setDesktopBusy] = createSignal(false);128129  const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());130131  function startDesktopFlow(): void {132    setDesktopCode("");133    setDesktopSas(null);134    setDesktopError("unknown");135    setDesktopDevice(undefined);136    setDesktopBusy(false);137    setDesktopPhase("enter");138    setPairKind("desktop");139  }140141  function startChirpFlow(): void {142    setPairKind("chirp");143    void handleStartPairing();144  }145146  function onDesktopCodeInput(value: string): void {147    setDesktopCode(value.replace(/\D/g, "").slice(0, 6));148  }149150  async function handleDesktopConnect(): Promise<void> {151    if (!desktopCodeReady() || desktopBusy()) return;152    setDesktopBusy(true);153    setDesktopPhase("connecting");154    try {155      const res = (await sendMessage({156        type: "PAIR_WITH_DESKTOP",157        payload: { code: desktopCode() },158      })) as { ok: boolean; error?: string };159      // A synchronous start failure (e.g. vault_locked) returns here; the SAS160      // and the terminal result arrive later as pushes.161      if (!res.ok) {162        setDesktopError((res.error as DesktopPairingError) ?? "unknown");163        setDesktopPhase("error");164      }165    } catch {166      setDesktopError("unknown");167      setDesktopPhase("error");168    } finally {169      setDesktopBusy(false);170    }171  }172173  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {174    if (desktopBusy()) return;175    setDesktopBusy(true);176    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the177    // final phase (paired vs sas_mismatch error).178    if (confirmed) setDesktopPhase("connecting");179    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }180    try {181      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });182    } finally {183      setDesktopBusy(false);184    }185  }186187  // Auto-dismiss after the paired state settles — back to the Settings hub,188  // which reloads the paired-device list on mount.189  createEffect(() => {190    if (desktopPhase() !== "paired") return;191    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);192    onCleanup(() => clearTimeout(t));193  });194195  onMount(() => {196    // Opened straight into a pairing path from the Settings sync card. With no197    // method there's nothing to do here — return to Settings (the hub).198    if (props.initialPair === "chirp") startChirpFlow();199    else if (props.initialPair === "desktop") startDesktopFlow();200    else props.onBack();201202    // Desktop pairing pushes from the background (bg → popup). The whole NK203    // pairing runs in the service worker; the SAS to confirm and the terminal204    // result arrive here as runtime messages.205    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {206      const handler = (msg: { type?: string; payload?: unknown }) => {207        if (pairKind() !== "desktop") return;208        if (msg?.type === "DESKTOP_PAIR_SAS") {209          const p = msg.payload as { sas?: string } | undefined;210          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }211        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {212          const p = msg.payload as213            | { status: "complete"; device: PairedDevice }214            | { status: "error"; error: DesktopPairingError }215            | undefined;216          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }217          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }218        }219      };220      chrome.runtime.onMessage.addListener(handler);221      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));222    }223  });224225  async function handleStartPairing(): Promise<void> {226    // audioCapture is declared as optional_permissions in the manifest.227    // Chrome will silently deny getUserMedia unless we request it here,228    // during a user gesture. Firefox ignores this API but getUserMedia works229    // under its existing host permission grant.230    if (typeof chrome !== "undefined" && chrome.permissions?.request) {231      const granted = await chrome.permissions.request({232        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],233      });234      if (!granted) {235        setPairing({ phase: "mic_denied" });236        return;237      }238    }239240    setPairing({ phase: "idle" });241242    const deps: ChirpPairingDeps = {243      onProgress: (p) => setPairing(p),244      fetchLocalIdentity: async () => {245        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });246        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };247        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");248        // The handler returns a LocalIdentity; we trust the background's shape.249        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;250      },251      completePairing: async (paired) => {252        const res = await sendMessage({253          type: "CHIRP_COMPLETE_PAIRING",254          payload: { paired },255        });256        const r = res as { ok: boolean; error?: string };257        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");258      },259      confirmSAS: (sas) =>260        new Promise<boolean>((resolve) => {261          setSasResolve(() => resolve);262          setSasCode(sas);263        }),264      // Receive Pro from an already-Pro peer (signature-verified in the bg).265      // Skip if we're already Pro so we keep our own license.266      activatePeerLicense: async (license) => {267        if (props.isPro) return;268        const res = (await sendMessage({269          type: "ACTIVATE_LICENSE",270          payload: { key: license },271        })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };272        if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();273      },274      getLocalSubnet,275      openPairingTransport: async (endpoint) => {276        // Native WebSocket transport for the pairing-mode peer scan. The277        // chirp-pair orchestrator only uses send/receive/close, so a thin278        // adapter over the platform WebSocket suffices.279        return new Promise((resolve, reject) => {280          let ws: WebSocket;281          try {282            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);283          } catch (err) {284            reject(err);285            return;286          }287          const inbox: Uint8Array[] = [];288          const waiters: Array<(b: Uint8Array) => void> = [];289          let closed = false;290          ws.binaryType = "arraybuffer";291          const timer = setTimeout(() => {292            try { ws.close(); } catch { /* ignore */ }293            reject(new Error("pairing_transport_timeout"));294          }, 2_000);295          ws.onopen = () => {296            clearTimeout(timer);297            resolve({298              async send(data: Uint8Array) {299                ws.send(data as Uint8Array<ArrayBuffer>);300              },301              receive(): Promise<Uint8Array> {302                if (closed) return Promise.reject(new Error("closed"));303                const v = inbox.shift();304                if (v) return Promise.resolve(v);305                return new Promise<Uint8Array>((res) => waiters.push(res));306              },307              async close() {308                try { ws.close(); } catch { /* ignore */ }309              },310            });311          };312          ws.onmessage = (ev) => {313            if (!(ev.data instanceof ArrayBuffer)) return;314            const bytes = new Uint8Array(ev.data);315            const w = waiters.shift();316            if (w) w(bytes); else inbox.push(bytes);317          };318          ws.onerror = () => {319            clearTimeout(timer);320            reject(new Error("pairing_transport_error"));321          };322          ws.onclose = () => {323            closed = true;324            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));325          };326        });327      },328    };329330    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";331    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;332333    try {334      await runChirpPairing(deps, role);335      // Success surfaces in `pairing()` as the "paired" phase; ChirpPairingFlow336      // shows it and onDismiss returns to Settings.337    } catch (err) {338      if (err instanceof ChirpPairingFailure) {339        // The orchestrator already pushed the terminal phase into `pairing()`;340        // the inline error copy renders. The user can tap "Try again"/"Cancel".341        return;342      }343      setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });344    }345  }346347  // --- Sub-views --------------------------------------------------348349  const backArrow = (350    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">351      <path d="M10 3 L5 8 L10 13" />352    </svg>353  );354355  // The bird stage — always animating — shared by every desktop pairing screen356  // so the bird animation is present regardless of sync method.357  const desktopBirdStage = (active: boolean) => (358    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">359      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">360        <DesktopBird pose="chirping" />361      </div>362      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">363        <MobileBird pose="attentive" />364      </div>365    </div>366  );367368  function desktopLiveText(): string {369    switch (desktopPhase()) {370      case "enter": return "Enter the pairing code from your computer";371      case "connecting": return "Searching for the helper";372      case "sas": return "Confirm the code matches on your computer";373      case "paired": return "Paired";374      case "error": return desktopErrorCopy(desktopError()).headline;375    }376  }377378  // Desktop helper code pairing — birds animate on every screen below.379  const desktopView = () => (380    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">381      <header class="peach-chirp-head">382        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>383        <div class="peach-chirp-title">Enter a code</div>384        <div class="peach-chirp-head-spacer" />385      </header>386387      {desktopBirdStage(desktopPhase() !== "enter")}388389      <Show when={desktopPhase() === "enter"}>390        <div class="peach-desktop-enter">391          <div class="peach-chirp-headline">Enter the code from your computer</div>392          <div class="peach-chirp-sub">393            Open the Peach helper on your computer and type the 6-digit code it shows.394          </div>395          <label class="peach-label" for="desktop-pair-code">pairing code</label>396          <input397            id="desktop-pair-code"398            class="peach-underline-input peach-unlock-input peach-code-input"399            type="text"400            inputmode="numeric"401            autocomplete="one-time-code"402            maxlength={6}403            placeholder="000000"404            aria-label="6-digit pairing code"405            value={desktopCode()}406            ref={(el) => setTimeout(() => el.focus(), 80)}407            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}408            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}409          />410          <div class="peach-chirp-action">411            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>412            <button413              class="peach-btn peach-btn-primary peach-desktop-connect"414              disabled={!desktopCodeReady() || desktopBusy()}415              onClick={() => void handleDesktopConnect()}416            >417              Connect418            </button>419          </div>420        </div>421      </Show>422423      <Show when={desktopPhase() === "connecting"}>424        <div class="peach-desktop-searching">425          <div class="peach-chirp-headline">Searching for helper…</div>426          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>427        </div>428      </Show>429430      <Show when={desktopPhase() === "sas"}>431        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">432          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>433          <div434            class="peach-desktop-sas"435            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;"436          >437            {desktopSas()}438          </div>439          <div class="peach-chirp-sub" style="max-width:240px;">440            Only continue if this 6-digit code is identical on both screens.441          </div>442          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">443            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>444              Confirm445            </button>446            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>447              Cancel448            </button>449          </div>450        </div>451      </Show>452453      <Show when={desktopPhase() === "paired"}>454        <div class="peach-desktop-paired">455          <div class="peach-chirp-headline">Paired!</div>456          <div class="peach-chirp-sub">457            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}458          </div>459          <div class="peach-chirp-action">460            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>461          </div>462        </div>463      </Show>464465      <Show when={desktopPhase() === "error"}>466        <div class="peach-desktop-error">467          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>468          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>469          <div class="peach-chirp-action">470            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>471            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>472          </div>473        </div>474      </Show>475476      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>477    </div>478  );479480  const chirpPairingView = () => (481    <Show482      when={sasCode()}483      fallback={484        <ChirpPairingFlow485          progress={pairing()}486          onCancel={() => props.onBack()}487          onRetry={() => void handleStartPairing()}488          onDismiss={() => props.onBack()}489        />490      }491    >492      {/* Out-of-band verification gate — pairing is NOT persisted until the493          user confirms this code matches the one on the other device. */}494      <div495        class="chirp-sas-verify"496        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"497      >498        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>499        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">500          Confirm this code matches the one shown on your other device before pairing.501        </div>502        <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;">503          {sasCode()}504        </div>505        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">506          <button507            class="peach-btn peach-btn--accent peach-btn--md"508            type="button"509            onClick={() => resolveSAS(true)}510          >511            codes match — pair512          </button>513          <button514            class="peach-btn peach-btn--ghost peach-btn--md"515            type="button"516            onClick={() => resolveSAS(false)}517          >518            they don't match519          </button>520        </div>521      </div>522    </Show>523  );524525  // Pairing surface dispatch: the user already chose chirp vs. code on the rest526  // screen (or the sync card), so there's no method-select step.527  const pairingView = () => (528    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>529      {desktopView()}530    </Show>531  );532533  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can534  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.535  return pairingView();536};