ChirpSync.tsx

23.3 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 type { AdoptRequest } from "../../shared/chirp-adopt";31import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";32import { AdoptionConsentModal } from "../components/AdoptionConsentModal";33import {34  ChirpPairingFailure,35  runChirpPairing,36  type ChirpPairingDeps,37  type PairingRole,38} from "../chirp-pair";39import { getLocalSubnet } from "../../shared/local-network";40import { isNativePlatform } from "../../shared/native";41import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4243export interface ChirpSyncViewProps {44  isPro: boolean;45  onBack(): void;46  onLearnPro(): void;47  /**48   * Open straight into a pairing path, skipping the rest screen — set when the49   * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount.50   */51  initialPair?: "chirp" | "desktop" | null;52  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */53  onProChanged?: () => void;54}5556// Which pairing path the user picked, passed in via `initialPair`. The birds57// animate the same way on both paths.58type PairKind = "chirp" | "desktop" | null;5960// Desktop helper (peach-sync) code-pairing phases. The SAS and success61// screens are shared with the chirp path; "enter"/"connecting" are unique to62// the typed-code flow. Mirrors the background DesktopPairingError union.63type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";64type DesktopPairingError =65  | "invalid_code"66  | "no_host_found"67  | "handshake_failed"68  | "sas_mismatch"69  | "vault_locked";7071const DESKTOP_PAIRED_DISMISS_MS = 2_000;7273function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {74  switch (error) {75    case "invalid_code":76      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.77      return {78        headline: "Code not found or expired",79        sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",80      };81    case "no_host_found":82      return {83        headline: "Couldn't find your computer",84        sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",85      };86    case "sas_mismatch":87      return {88        headline: "Codes don't match",89        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",90      };91    case "vault_locked":92      return {93        headline: "Unlock Peach first",94        sub: "Your vault needs to be unlocked before you can pair a device.",95      };96    case "handshake_failed":97    default:98      return {99        headline: "Pairing failed",100        sub: "Something went wrong while connecting. Please try again.",101      };102  }103}104105export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {106  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });107  // Out-of-band pairing verification: the 6-digit code both devices derive108  // from the shared handshake; the user confirms it matches before the pair109  // is persisted (audit HIGH — pairing initiator authentication).110  const [sasCode, setSasCode] = createSignal<string | null>(null);111  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);112  const [adoptionConsent, setAdoptionConsent] = createSignal<{113    request: AdoptRequest;114    sas: string;115    resolve: (ok: boolean) => void;116  } | 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>(props.initialPair ?? 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 startDesktopFlow(): void {139    setDesktopCode("");140    setDesktopSas(null);141    setDesktopError("unknown");142    setDesktopDevice(undefined);143    setDesktopBusy(false);144    setDesktopPhase("enter");145    setPairKind("desktop");146  }147148  function startChirpFlow(): void {149    setPairKind("chirp");150    void handleStartPairing();151  }152153  function onDesktopCodeInput(value: string): void {154    setDesktopCode(value.replace(/\D/g, "").slice(0, 6));155  }156157  async function handleDesktopConnect(): Promise<void> {158    if (!desktopCodeReady() || desktopBusy()) return;159    setDesktopBusy(true);160    setDesktopPhase("connecting");161    try {162      const res = (await sendMessage({163        type: "PAIR_WITH_DESKTOP",164        payload: { code: desktopCode() },165      })) as { ok: boolean; error?: string };166      // A synchronous start failure (e.g. vault_locked) returns here; the SAS167      // and the terminal result arrive later as pushes.168      if (!res.ok) {169        setDesktopError((res.error as DesktopPairingError) ?? "unknown");170        setDesktopPhase("error");171      }172    } catch {173      setDesktopError("unknown");174      setDesktopPhase("error");175    } finally {176      setDesktopBusy(false);177    }178  }179180  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {181    if (desktopBusy()) return;182    setDesktopBusy(true);183    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the184    // final phase (paired vs sas_mismatch error).185    if (confirmed) setDesktopPhase("connecting");186    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }187    try {188      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });189    } finally {190      setDesktopBusy(false);191    }192  }193194  // Auto-dismiss after the paired state settles — back to the Settings hub,195  // which reloads the paired-device list on mount.196  createEffect(() => {197    if (desktopPhase() !== "paired") return;198    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);199    onCleanup(() => clearTimeout(t));200  });201202  onMount(() => {203    // Opened straight into a pairing path from the Settings sync card. With no204    // method there's nothing to do here — return to Settings (the hub).205    if (props.initialPair === "chirp") startChirpFlow();206    else if (props.initialPair === "desktop") startDesktopFlow();207    else props.onBack();208209    // Desktop pairing pushes from the background (bg → popup). The whole NK210    // pairing runs in the service worker; the SAS to confirm and the terminal211    // result arrive here as runtime messages.212    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {213      const handler = (msg: { type?: string; payload?: unknown }) => {214        if (pairKind() !== "desktop") return;215        if (msg?.type === "DESKTOP_PAIR_SAS") {216          const p = msg.payload as { sas?: string } | undefined;217          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }218        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {219          const p = msg.payload as220            | { status: "complete"; device: PairedDevice }221            | { status: "error"; error: DesktopPairingError }222            | undefined;223          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }224          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }225        }226      };227      chrome.runtime.onMessage.addListener(handler);228      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));229    }230  });231232  async function handleStartPairing(): Promise<void> {233    // audioCapture is declared as optional_permissions in the manifest.234    // Chrome will silently deny getUserMedia unless we request it here,235    // during a user gesture. Firefox ignores this API but getUserMedia works236    // under its existing host permission grant.237    if (typeof chrome !== "undefined" && chrome.permissions?.request) {238      const granted = await chrome.permissions.request({239        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],240      });241      if (!granted) {242        setPairing({ phase: "mic_denied" });243        return;244      }245    }246247    setPairing({ phase: "idle" });248249    const deps: ChirpPairingDeps = {250      onProgress: (p) => setPairing(p),251      fetchLocalIdentity: async () => {252        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });253        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };254        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");255        // The handler returns a LocalIdentity; we trust the background's shape.256        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;257      },258      completePairing: async (paired) => {259        const res = await sendMessage({260          type: "CHIRP_COMPLETE_PAIRING",261          payload: { paired },262        });263        const r = res as { ok: boolean; error?: string };264        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");265      },266      confirmSAS: (sas) =>267        new Promise<boolean>((resolve) => {268          setSasResolve(() => resolve);269          setSasCode(sas);270        }),271      // Receive Pro from an already-Pro peer (signature-verified in the bg).272      // Skip if we're already Pro so we keep our own license.273      activatePeerLicense: async (license) => {274        if (props.isPro) return;275        const res = (await sendMessage({276          type: "ACTIVATE_LICENSE",277          payload: { key: license },278        })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };279        if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();280      },281      grantAdoptionConsent: (request, sas) =>282        new Promise<boolean>((resolve) => {283          setAdoptionConsent({ request, sas, resolve });284        }),285      fetchAdoptionMaterial: async () => {286        const res = await sendMessage({ type: "CHIRP_GET_ADOPTION_MATERIAL" });287        const r = res as {288          ok: boolean;289          data?: { rootKeyHex?: string; vaultId?: string; license?: string };290          error?: string;291        };292        if (!r.ok || typeof r.data?.rootKeyHex !== "string" || typeof r.data?.vaultId !== "string") {293          throw new Error(r.error ?? "adoption_material_unavailable");294        }295        return {296          rootKeyHex: r.data.rootKeyHex,297          vaultId: r.data.vaultId,298          license: r.data.license,299        };300      },301      getLocalSubnet,302      openPairingTransport: async (endpoint) => {303        // Native WebSocket transport for the pairing-mode peer scan. The304        // chirp-pair orchestrator only uses send/receive/close, so a thin305        // adapter over the platform WebSocket suffices.306        return new Promise((resolve, reject) => {307          let ws: WebSocket;308          try {309            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);310          } catch (err) {311            reject(err);312            return;313          }314          const inbox: Uint8Array[] = [];315          const waiters: Array<(b: Uint8Array) => void> = [];316          let closed = false;317          ws.binaryType = "arraybuffer";318          const timer = setTimeout(() => {319            try { ws.close(); } catch { /* ignore */ }320            reject(new Error("pairing_transport_timeout"));321          }, 2_000);322          ws.onopen = () => {323            clearTimeout(timer);324            resolve({325              async send(data: Uint8Array) {326                ws.send(data as Uint8Array<ArrayBuffer>);327              },328              receive(): Promise<Uint8Array> {329                if (closed) return Promise.reject(new Error("closed"));330                const v = inbox.shift();331                if (v) return Promise.resolve(v);332                return new Promise<Uint8Array>((res) => waiters.push(res));333              },334              async close() {335                try { ws.close(); } catch { /* ignore */ }336              },337            });338          };339          ws.onmessage = (ev) => {340            if (!(ev.data instanceof ArrayBuffer)) return;341            const bytes = new Uint8Array(ev.data);342            const w = waiters.shift();343            if (w) w(bytes); else inbox.push(bytes);344          };345          ws.onerror = () => {346            clearTimeout(timer);347            reject(new Error("pairing_transport_error"));348          };349          ws.onclose = () => {350            closed = true;351            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));352          };353        });354      },355    };356357    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";358    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;359360    try {361      await runChirpPairing(deps, role);362      // Success surfaces in `pairing()` as the "paired" phase; ChirpPairingFlow363      // shows it and onDismiss returns to Settings.364    } catch (err) {365      if (err instanceof ChirpPairingFailure) {366        // The orchestrator already pushed the terminal phase into `pairing()`;367        // the inline error copy renders. The user can tap "Try again"/"Cancel".368        return;369      }370      setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });371    }372  }373374  // --- Sub-views --------------------------------------------------375376  const backArrow = (377    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">378      <path d="M10 3 L5 8 L10 13" />379    </svg>380  );381382  // The bird stage — always animating — shared by every desktop pairing screen383  // so the bird animation is present regardless of sync method.384  const desktopBirdStage = (active: boolean) => (385    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">386      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">387        <DesktopBird pose="chirping" />388      </div>389      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">390        <MobileBird pose="attentive" />391      </div>392    </div>393  );394395  function desktopLiveText(): string {396    switch (desktopPhase()) {397      case "enter": return "Enter the pairing code from your computer";398      case "connecting": return "Searching for the helper";399      case "sas": return "Confirm the code matches on your computer";400      case "paired": return "Paired";401      case "error": return desktopErrorCopy(desktopError()).headline;402    }403  }404405  // Desktop helper code pairing — birds animate on every screen below.406  const desktopView = () => (407    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">408      <header class="peach-chirp-head">409        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>410        <div class="peach-chirp-title">Enter a code</div>411        <div class="peach-chirp-head-spacer" />412      </header>413414      {desktopBirdStage(desktopPhase() !== "enter")}415416      <Show when={desktopPhase() === "enter"}>417        <div class="peach-desktop-enter">418          <div class="peach-chirp-headline">Enter the code from your computer</div>419          <div class="peach-chirp-sub">420            Open the Peach helper on your computer and type the 6-digit code it shows.421          </div>422          <label class="peach-label" for="desktop-pair-code">pairing code</label>423          <input424            id="desktop-pair-code"425            class="peach-underline-input peach-unlock-input peach-code-input"426            type="text"427            inputmode="numeric"428            autocomplete="one-time-code"429            maxlength={6}430            placeholder="000000"431            aria-label="6-digit pairing code"432            value={desktopCode()}433            ref={(el) => setTimeout(() => el.focus(), 80)}434            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}435            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}436          />437          <div class="peach-chirp-action">438            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>439            <button440              class="peach-btn peach-btn-primary peach-desktop-connect"441              disabled={!desktopCodeReady() || desktopBusy()}442              onClick={() => void handleDesktopConnect()}443            >444              Connect445            </button>446          </div>447        </div>448      </Show>449450      <Show when={desktopPhase() === "connecting"}>451        <div class="peach-desktop-searching">452          <div class="peach-chirp-headline">Searching for helper…</div>453          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>454        </div>455      </Show>456457      <Show when={desktopPhase() === "sas"}>458        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">459          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>460          <div461            class="peach-desktop-sas"462            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;"463          >464            {desktopSas()}465          </div>466          <div class="peach-chirp-sub" style="max-width:240px;">467            Only continue if this 6-digit code is identical on both screens.468          </div>469          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">470            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>471              Confirm472            </button>473            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>474              Cancel475            </button>476          </div>477        </div>478      </Show>479480      <Show when={desktopPhase() === "paired"}>481        <div class="peach-desktop-paired">482          <div class="peach-chirp-headline">Paired!</div>483          <div class="peach-chirp-sub">484            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}485          </div>486          <div class="peach-chirp-action">487            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>488          </div>489        </div>490      </Show>491492      <Show when={desktopPhase() === "error"}>493        <div class="peach-desktop-error">494          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>495          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>496          <div class="peach-chirp-action">497            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>498            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>499          </div>500        </div>501      </Show>502503      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>504    </div>505  );506507  const chirpPairingView = () => (508    <Show509      when={sasCode()}510      fallback={511        <ChirpPairingFlow512          progress={pairing()}513          onCancel={() => props.onBack()}514          onRetry={() => void handleStartPairing()}515          onDismiss={() => props.onBack()}516        />517      }518    >519      {/* Out-of-band verification gate — pairing is NOT persisted until the520          user confirms this code matches the one on the other device. */}521      <div522        class="chirp-sas-verify"523        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"524      >525        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>526        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">527          Confirm this code matches the one shown on your other device before pairing.528        </div>529        <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;">530          {sasCode()}531        </div>532        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">533          <button534            class="peach-btn peach-btn--accent peach-btn--md"535            type="button"536            onClick={() => resolveSAS(true)}537          >538            codes match — pair539          </button>540          <button541            class="peach-btn peach-btn--ghost peach-btn--md"542            type="button"543            onClick={() => resolveSAS(false)}544          >545            they don't match546          </button>547        </div>548      </div>549    </Show>550  );551552  // Pairing surface dispatch: the user already chose chirp vs. code on the rest553  // screen (or the sync card), so there's no method-select step.554  const pairingView = () => (555    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>556      {desktopView()}557    </Show>558  );559560  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can561  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.562  return (563    <>564      {pairingView()}565      <Show when={adoptionConsent()}>566        {(pending) => (567          <AdoptionConsentModal568            receiverDeviceName={pending().request.device_name}569            sas={pending().sas}570            verifyMasterPassword={async (password) => {571              const res = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password } });572              return Boolean((res as { ok: boolean; data?: boolean }).ok && (res as { data?: boolean }).data);573            }}574            onCancel={() => {575              pending().resolve(false);576              setAdoptionConsent(null);577            }}578            onApprove={async () => {579              pending().resolve(true);580              setAdoptionConsent(null);581            }}582          />583        )}584      </Show>585    </>586  );587};