ChirpSync.tsx

22.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}5152// Which pairing path the user picked, passed in via `initialPair`. The birds53// animate the same way on both paths.54type PairKind = "chirp" | "desktop" | null;5556// Desktop helper (peach-sync) code-pairing phases. The SAS and success57// screens are shared with the chirp path; "enter"/"connecting" are unique to58// the typed-code flow. Mirrors the background DesktopPairingError union.59type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";60type DesktopPairingError =61  | "invalid_code"62  | "no_host_found"63  | "handshake_failed"64  | "sas_mismatch"65  | "vault_locked";6667const DESKTOP_PAIRED_DISMISS_MS = 2_000;6869function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {70  switch (error) {71    case "invalid_code":72      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.73      return {74        headline: "Code not found or expired",75        sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",76      };77    case "no_host_found":78      return {79        headline: "Couldn't find your computer",80        sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",81      };82    case "sas_mismatch":83      return {84        headline: "Codes don't match",85        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",86      };87    case "vault_locked":88      return {89        headline: "Unlock Peach first",90        sub: "Your vault needs to be unlocked before you can pair a device.",91      };92    case "handshake_failed":93    default:94      return {95        headline: "Pairing failed",96        sub: "Something went wrong while connecting. Please try again.",97      };98  }99}100101export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {102  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });103  // Out-of-band pairing verification: the 6-digit code both devices derive104  // from the shared handshake; the user confirms it matches before the pair105  // is persisted (audit HIGH — pairing initiator authentication).106  const [sasCode, setSasCode] = createSignal<string | null>(null);107  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);108109  function resolveSAS(ok: boolean) {110    const r = sasResolve();111    setSasCode(null);112    setSasResolve(null);113    r?.(ok);114  }115116  // --- Desktop helper (peach-sync) code pairing -------------------------117  // The whole NK pairing runs in the background; this view drives the UI and118  // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.119  const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);120  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");121  const [desktopCode, setDesktopCode] = createSignal("");122  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);123  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");124  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);125  const [desktopBusy, setDesktopBusy] = createSignal(false);126127  const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());128129  function startDesktopFlow(): void {130    setDesktopCode("");131    setDesktopSas(null);132    setDesktopError("unknown");133    setDesktopDevice(undefined);134    setDesktopBusy(false);135    setDesktopPhase("enter");136    setPairKind("desktop");137  }138139  function startChirpFlow(): void {140    setPairKind("chirp");141    void handleStartPairing();142  }143144  function onDesktopCodeInput(value: string): void {145    setDesktopCode(value.replace(/\D/g, "").slice(0, 6));146  }147148  async function handleDesktopConnect(): Promise<void> {149    if (!desktopCodeReady() || desktopBusy()) return;150    setDesktopBusy(true);151    setDesktopPhase("connecting");152    try {153      const res = (await sendMessage({154        type: "PAIR_WITH_DESKTOP",155        payload: { code: desktopCode() },156      })) as { ok: boolean; error?: string };157      // A synchronous start failure (e.g. vault_locked) returns here; the SAS158      // and the terminal result arrive later as pushes.159      if (!res.ok) {160        setDesktopError((res.error as DesktopPairingError) ?? "unknown");161        setDesktopPhase("error");162      }163    } catch {164      setDesktopError("unknown");165      setDesktopPhase("error");166    } finally {167      setDesktopBusy(false);168    }169  }170171  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {172    if (desktopBusy()) return;173    setDesktopBusy(true);174    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the175    // final phase (paired vs sas_mismatch error).176    if (confirmed) setDesktopPhase("connecting");177    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }178    try {179      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });180    } finally {181      setDesktopBusy(false);182    }183  }184185  // Auto-dismiss after the paired state settles — back to the Settings hub,186  // which reloads the paired-device list on mount.187  createEffect(() => {188    if (desktopPhase() !== "paired") return;189    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);190    onCleanup(() => clearTimeout(t));191  });192193  onMount(() => {194    // Opened straight into a pairing path from the Settings sync card. With no195    // method there's nothing to do here — return to Settings (the hub).196    if (props.initialPair === "chirp") startChirpFlow();197    else if (props.initialPair === "desktop") startDesktopFlow();198    else props.onBack();199200    // Desktop pairing pushes from the background (bg → popup). The whole NK201    // pairing runs in the service worker; the SAS to confirm and the terminal202    // result arrive here as runtime messages.203    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {204      const handler = (msg: { type?: string; payload?: unknown }) => {205        if (pairKind() !== "desktop") return;206        if (msg?.type === "DESKTOP_PAIR_SAS") {207          const p = msg.payload as { sas?: string } | undefined;208          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }209        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {210          const p = msg.payload as211            | { status: "complete"; device: PairedDevice }212            | { status: "error"; error: DesktopPairingError }213            | undefined;214          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }215          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }216        }217      };218      chrome.runtime.onMessage.addListener(handler);219      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));220    }221  });222223  async function handleStartPairing(): Promise<void> {224    // audioCapture is declared as optional_permissions in the manifest.225    // Chrome will silently deny getUserMedia unless we request it here,226    // during a user gesture. Firefox ignores this API but getUserMedia works227    // under its existing host permission grant.228    if (typeof chrome !== "undefined" && chrome.permissions?.request) {229      const granted = await chrome.permissions.request({230        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],231      });232      if (!granted) {233        setPairing({ phase: "mic_denied" });234        return;235      }236    }237238    setPairing({ phase: "idle" });239240    const deps: ChirpPairingDeps = {241      onProgress: (p) => setPairing(p),242      fetchLocalIdentity: async () => {243        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });244        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };245        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");246        // The handler returns a LocalIdentity; we trust the background's shape.247        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;248      },249      completePairing: async (paired) => {250        const res = await sendMessage({251          type: "CHIRP_COMPLETE_PAIRING",252          payload: { paired },253        });254        const r = res as { ok: boolean; error?: string };255        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");256      },257      confirmSAS: (sas) =>258        new Promise<boolean>((resolve) => {259          setSasResolve(() => resolve);260          setSasCode(sas);261        }),262      getLocalSubnet,263      openPairingTransport: async (endpoint) => {264        // Native WebSocket transport for the pairing-mode peer scan. The265        // chirp-pair orchestrator only uses send/receive/close, so a thin266        // adapter over the platform WebSocket suffices.267        return new Promise((resolve, reject) => {268          let ws: WebSocket;269          try {270            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);271          } catch (err) {272            reject(err);273            return;274          }275          const inbox: Uint8Array[] = [];276          const waiters: Array<(b: Uint8Array) => void> = [];277          let closed = false;278          ws.binaryType = "arraybuffer";279          const timer = setTimeout(() => {280            try { ws.close(); } catch { /* ignore */ }281            reject(new Error("pairing_transport_timeout"));282          }, 2_000);283          ws.onopen = () => {284            clearTimeout(timer);285            resolve({286              async send(data: Uint8Array) {287                ws.send(data as Uint8Array<ArrayBuffer>);288              },289              receive(): Promise<Uint8Array> {290                if (closed) return Promise.reject(new Error("closed"));291                const v = inbox.shift();292                if (v) return Promise.resolve(v);293                return new Promise<Uint8Array>((res) => waiters.push(res));294              },295              async close() {296                try { ws.close(); } catch { /* ignore */ }297              },298            });299          };300          ws.onmessage = (ev) => {301            if (!(ev.data instanceof ArrayBuffer)) return;302            const bytes = new Uint8Array(ev.data);303            const w = waiters.shift();304            if (w) w(bytes); else inbox.push(bytes);305          };306          ws.onerror = () => {307            clearTimeout(timer);308            reject(new Error("pairing_transport_error"));309          };310          ws.onclose = () => {311            closed = true;312            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));313          };314        });315      },316    };317318    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";319    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;320321    try {322      await runChirpPairing(deps, role);323      // Success surfaces in `pairing()` as the "paired" phase; ChirpPairingFlow324      // shows it and onDismiss returns to Settings.325    } catch (err) {326      if (err instanceof ChirpPairingFailure) {327        // The orchestrator already pushed the terminal phase into `pairing()`;328        // the inline error copy renders. The user can tap "Try again"/"Cancel".329        return;330      }331      setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });332    }333  }334335  // --- Sub-views --------------------------------------------------336337  const proGateView = () => (338    <div class="peach-chirp-settings peach-chirp-settings--gated">339      <header class="peach-chirp-settings-header">340        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>341          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">342            <path d="M11 4 L5 9 L11 14" />343          </svg>344        </button>345        <h1 class="peach-chirp-settings-title">Sync</h1>346      </header>347      <div class="peach-chirp-pro-card">348        <div class="peach-chirp-pro-stage" aria-hidden="true">349          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"350               stroke-linecap="round" stroke-linejoin="round">351            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />352            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />353            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />354          </svg>355          <div class="peach-chirp-pro-bird-slot">356            <MobileBird pose="idle" />357          </div>358        </div>359        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>360        <p class="peach-chirp-pro-body">361          Pairs over an inaudible chirp. Syncs locally across your devices.362          No codes, no servers, no cloud.363        </p>364        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>365          Unlock with Peach Pro366        </button>367        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>368      </div>369    </div>370  );371372  const backArrow = (373    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">374      <path d="M10 3 L5 8 L10 13" />375    </svg>376  );377378  // The bird stage — always animating — shared by every desktop pairing screen379  // so the bird animation is present regardless of sync method.380  const desktopBirdStage = (active: boolean) => (381    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">382      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">383        <DesktopBird pose="chirping" />384      </div>385      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">386        <MobileBird pose="attentive" />387      </div>388    </div>389  );390391  function desktopLiveText(): string {392    switch (desktopPhase()) {393      case "enter": return "Enter the pairing code from your computer";394      case "connecting": return "Searching for the helper";395      case "sas": return "Confirm the code matches on your computer";396      case "paired": return "Paired";397      case "error": return desktopErrorCopy(desktopError()).headline;398    }399  }400401  // Desktop helper code pairing — birds animate on every screen below.402  const desktopView = () => (403    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">404      <header class="peach-chirp-head">405        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>406        <div class="peach-chirp-title">Enter a code</div>407        <div class="peach-chirp-head-spacer" />408      </header>409410      {desktopBirdStage(desktopPhase() !== "enter")}411412      <Show when={desktopPhase() === "enter"}>413        <div class="peach-desktop-enter">414          <div class="peach-chirp-headline">Enter the code from your computer</div>415          <div class="peach-chirp-sub">416            Open the Peach helper on your computer and type the 6-digit code it shows.417          </div>418          <label class="peach-label" for="desktop-pair-code">pairing code</label>419          <input420            id="desktop-pair-code"421            class="peach-underline-input peach-unlock-input peach-code-input"422            type="text"423            inputmode="numeric"424            autocomplete="one-time-code"425            maxlength={6}426            placeholder="000000"427            aria-label="6-digit pairing code"428            value={desktopCode()}429            ref={(el) => setTimeout(() => el.focus(), 80)}430            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}431            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}432          />433          <div class="peach-chirp-action">434            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>435            <button436              class="peach-btn peach-btn-primary peach-desktop-connect"437              disabled={!desktopCodeReady() || desktopBusy()}438              onClick={() => void handleDesktopConnect()}439            >440              Connect441            </button>442          </div>443        </div>444      </Show>445446      <Show when={desktopPhase() === "connecting"}>447        <div class="peach-desktop-searching">448          <div class="peach-chirp-headline">Searching for helper…</div>449          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>450        </div>451      </Show>452453      <Show when={desktopPhase() === "sas"}>454        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">455          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>456          <div457            class="peach-desktop-sas"458            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;"459          >460            {desktopSas()}461          </div>462          <div class="peach-chirp-sub" style="max-width:240px;">463            Only continue if this 6-digit code is identical on both screens.464          </div>465          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">466            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>467              Confirm468            </button>469            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>470              Cancel471            </button>472          </div>473        </div>474      </Show>475476      <Show when={desktopPhase() === "paired"}>477        <div class="peach-desktop-paired">478          <div class="peach-chirp-headline">Paired!</div>479          <div class="peach-chirp-sub">480            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}481          </div>482          <div class="peach-chirp-action">483            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>484          </div>485        </div>486      </Show>487488      <Show when={desktopPhase() === "error"}>489        <div class="peach-desktop-error">490          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>491          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>492          <div class="peach-chirp-action">493            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>494            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>495          </div>496        </div>497      </Show>498499      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>500    </div>501  );502503  const chirpPairingView = () => (504    <Show505      when={sasCode()}506      fallback={507        <ChirpPairingFlow508          progress={pairing()}509          onCancel={() => props.onBack()}510          onRetry={() => void handleStartPairing()}511          onDismiss={() => props.onBack()}512        />513      }514    >515      {/* Out-of-band verification gate — pairing is NOT persisted until the516          user confirms this code matches the one on the other device. */}517      <div518        class="chirp-sas-verify"519        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"520      >521        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>522        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">523          Confirm this code matches the one shown on your other device before pairing.524        </div>525        <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;">526          {sasCode()}527        </div>528        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">529          <button530            class="peach-btn peach-btn--accent peach-btn--md"531            type="button"532            onClick={() => resolveSAS(true)}533          >534            codes match — pair535          </button>536          <button537            class="peach-btn peach-btn--ghost peach-btn--md"538            type="button"539            onClick={() => resolveSAS(false)}540          >541            they don't match542          </button>543        </div>544      </div>545    </Show>546  );547548  // Pairing surface dispatch: the user already chose chirp vs. code on the rest549  // screen (or the sync card), so there's no method-select step.550  const pairingView = () => (551    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>552      {desktopView()}553    </Show>554  );555556  return (557    <Show when={props.isPro} fallback={proGateView()}>558      {pairingView()}559    </Show>560  );561};