ChirpSync.tsx

24.7 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//   - Code — the primary path: the user types the pairing code their phone10//     displays, the browser dials out to the address inside it, and the NK11//     handshake + SAS run from there (enter code → SAS → paired).12//   - Chirp — the superseded audio-pairing flow. No entry point reaches it any13//     more; the code below is still here pending the removal decision.14//15// The NK pairing runs in the background (desktop-pairing.ts) and reaches this16// view via the PAIR_*/DESKTOP_PAIR_* messages.1718import {19  Component,20  Show,21  createEffect,22  createSignal,23  onCleanup,24  onMount,25} from "solid-js";26import { sendMessage } from "../../shared/bridge";27import type {28  PairedDevice,29  PairingProgress,30} from "../../shared/sync-types";31import type { AdoptRequest } from "../../shared/chirp-adopt";32import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";33import { AdoptionConsentModal } from "../components/AdoptionConsentModal";34import {35  ChirpPairingFailure,36  runChirpPairing,37  type ChirpPairingDeps,38  type PairingRole,39} from "../chirp-pair";40import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";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  /**50   * Open straight into a pairing path, skipping the rest screen — set when the51   * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount.52   */53  initialPair?: "chirp" | "desktop" | null;54  /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */55  onProChanged?: () => void;56}5758// Which pairing path the user picked, passed in via `initialPair`. The birds59// animate the same way on both paths.60type PairKind = "chirp" | "desktop" | null;6162// Desktop helper (peach-sync) code-pairing phases. The SAS and success63// screens are shared with the chirp path; "enter"/"connecting" are unique to64// the typed-code flow. Mirrors the background DesktopPairingError union.65type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";66type DesktopPairingError =67  | "invalid_code"68  | "no_host_found"69  | "handshake_failed"70  | "sas_mismatch"71  | "vault_locked";7273const DESKTOP_PAIRED_DISMISS_MS = 2_000;7475function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {76  switch (error) {77    case "invalid_code":78      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —79      // the listener closes the socket either way, with no error frame.80      return {81        headline: "Code not found or expired",82        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",83      };84    case "no_host_found":85      return {86        headline: "Couldn't reach your phone",87        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",88      };89    case "sas_mismatch":90      return {91        headline: "Codes don't match",92        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",93      };94    case "vault_locked":95      return {96        headline: "Unlock Peach first",97        sub: "Your vault needs to be unlocked before you can pair a device.",98      };99    case "handshake_failed":100    default:101      return {102        headline: "Pairing failed",103        sub: "Something went wrong while connecting. Please try again.",104      };105  }106}107108export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {109  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });110  // Out-of-band pairing verification: the 6-digit code both devices derive111  // from the shared handshake; the user confirms it matches before the pair112  // is persisted (audit HIGH — pairing initiator authentication).113  const [sasCode, setSasCode] = createSignal<string | null>(null);114  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);115  const [adoptionConsent, setAdoptionConsent] = createSignal<{116    request: AdoptRequest;117    sas: string;118    resolve: (ok: boolean) => void;119  } | null>(null);120121  function resolveSAS(ok: boolean) {122    const r = sasResolve();123    setSasCode(null);124    setSasResolve(null);125    r?.(ok);126  }127128  // --- Desktop helper (peach-sync) code pairing -------------------------129  // The whole NK pairing runs in the background; this view drives the UI and130  // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.131  const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);132  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");133  const [desktopCode, setDesktopCode] = createSignal("");134  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);135  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");136  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);137  const [desktopBusy, setDesktopBusy] = createSignal(false);138139  // Two accepted forms: the pairing code a phone displays, and the legacy140  // 6-digit desktop-helper code. `parsePairingCode` returning null just means141  // "not that form", so the check falls through to the digit shape.142  const desktopCodeReady = () =>143    parsePairingCode(desktopCode()) !== null || /^\d{6}$/.test(desktopCode());144145  function startDesktopFlow(): void {146    setDesktopCode("");147    setDesktopSas(null);148    setDesktopError("unknown");149    setDesktopDevice(undefined);150    setDesktopBusy(false);151    setDesktopPhase("enter");152    setPairKind("desktop");153  }154155  function startChirpFlow(): void {156    setPairKind("chirp");157    void handleStartPairing();158  }159160  function onDesktopCodeInput(value: string): void {161    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);162    // Group once the input outgrows a 6-digit helper code, so a pairing code163    // regains its dashes as it is typed without mangling the short form. Length164    // rather than character class, so an all-digit pairing code still groups.165    setDesktopCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);166  }167168  async function handleDesktopConnect(): Promise<void> {169    if (!desktopCodeReady() || desktopBusy()) return;170    setDesktopBusy(true);171    setDesktopPhase("connecting");172    try {173      const res = (await sendMessage({174        type: "PAIR_WITH_DESKTOP",175        payload: { code: desktopCode() },176      })) as { ok: boolean; error?: string };177      // A synchronous start failure (e.g. vault_locked) returns here; the SAS178      // and the terminal result arrive later as pushes.179      if (!res.ok) {180        setDesktopError((res.error as DesktopPairingError) ?? "unknown");181        setDesktopPhase("error");182      }183    } catch {184      setDesktopError("unknown");185      setDesktopPhase("error");186    } finally {187      setDesktopBusy(false);188    }189  }190191  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {192    if (desktopBusy()) return;193    setDesktopBusy(true);194    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the195    // final phase (paired vs sas_mismatch error).196    if (confirmed) setDesktopPhase("connecting");197    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }198    try {199      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });200    } finally {201      setDesktopBusy(false);202    }203  }204205  // Auto-dismiss after the paired state settles — back to the Settings hub,206  // which reloads the paired-device list on mount.207  createEffect(() => {208    if (desktopPhase() !== "paired") return;209    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);210    onCleanup(() => clearTimeout(t));211  });212213  onMount(() => {214    // Opened straight into a pairing path from the Settings sync card. With no215    // method there's nothing to do here — return to Settings (the hub).216    if (props.initialPair === "chirp") startChirpFlow();217    else if (props.initialPair === "desktop") startDesktopFlow();218    else props.onBack();219220    // Desktop pairing pushes from the background (bg → popup). The whole NK221    // pairing runs in the service worker; the SAS to confirm and the terminal222    // result arrive here as runtime messages.223    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {224      const handler = (msg: { type?: string; payload?: unknown }) => {225        if (pairKind() !== "desktop") return;226        if (msg?.type === "DESKTOP_PAIR_SAS") {227          const p = msg.payload as { sas?: string } | undefined;228          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }229        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {230          const p = msg.payload as231            | { status: "complete"; device: PairedDevice }232            | { status: "error"; error: DesktopPairingError }233            | undefined;234          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }235          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }236        }237      };238      chrome.runtime.onMessage.addListener(handler);239      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));240    }241    // If the popup closes while the user is on the SAS screen, resolve the242    // pending background confirm as false so the service worker clears243    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".244    onCleanup(() => {245      if (desktopPhase() === "sas") {246        void sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: false } });247      }248    });249  });250251  async function handleStartPairing(): Promise<void> {252    // audioCapture is declared as optional_permissions in the manifest.253    // Chrome will silently deny getUserMedia unless we request it here,254    // during a user gesture. Firefox ignores this API but getUserMedia works255    // under its existing host permission grant.256    if (typeof chrome !== "undefined" && chrome.permissions?.request) {257      const granted = await chrome.permissions.request({258        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],259      });260      if (!granted) {261        setPairing({ phase: "mic_denied" });262        return;263      }264    }265266    setPairing({ phase: "idle" });267268    const deps: ChirpPairingDeps = {269      onProgress: (p) => setPairing(p),270      fetchLocalIdentity: async () => {271        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });272        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };273        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");274        // The handler returns a LocalIdentity; we trust the background's shape.275        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;276      },277      completePairing: async (paired) => {278        const res = await sendMessage({279          type: "CHIRP_COMPLETE_PAIRING",280          payload: { paired },281        });282        const r = res as { ok: boolean; error?: string };283        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");284      },285      confirmSAS: (sas) =>286        new Promise<boolean>((resolve) => {287          setSasResolve(() => resolve);288          setSasCode(sas);289        }),290      // Receive Pro from an already-Pro peer (signature-verified in the bg).291      // Skip if we're already Pro so we keep our own license.292      activatePeerLicense: async (license) => {293        if (props.isPro) return;294        const res = (await sendMessage({295          type: "ACTIVATE_LICENSE",296          payload: { key: license },297        })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };298        if (res.ok && res.data?.valid && res.data?.plan === "pro") props.onProChanged?.();299      },300      grantAdoptionConsent: (request, sas) =>301        new Promise<boolean>((resolve) => {302          setAdoptionConsent({ request, sas, resolve });303        }),304      fetchAdoptionMaterial: async () => {305        const res = await sendMessage({ type: "CHIRP_GET_ADOPTION_MATERIAL" });306        const r = res as {307          ok: boolean;308          data?: { rootKeyHex?: string; vaultId?: string; license?: string };309          error?: string;310        };311        if (!r.ok || typeof r.data?.rootKeyHex !== "string" || typeof r.data?.vaultId !== "string") {312          throw new Error(r.error ?? "adoption_material_unavailable");313        }314        return {315          rootKeyHex: r.data.rootKeyHex,316          vaultId: r.data.vaultId,317          license: r.data.license,318        };319      },320      getLocalSubnet,321      openPairingTransport: async (endpoint) => {322        // Native WebSocket transport for the pairing-mode peer scan. The323        // chirp-pair orchestrator only uses send/receive/close, so a thin324        // adapter over the platform WebSocket suffices.325        return new Promise((resolve, reject) => {326          let ws: WebSocket;327          try {328            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);329          } catch (err) {330            reject(err);331            return;332          }333          const inbox: Uint8Array[] = [];334          const waiters: Array<(b: Uint8Array) => void> = [];335          let closed = false;336          ws.binaryType = "arraybuffer";337          const timer = setTimeout(() => {338            try { ws.close(); } catch { /* ignore */ }339            reject(new Error("pairing_transport_timeout"));340          }, 2_000);341          ws.onopen = () => {342            clearTimeout(timer);343            resolve({344              async send(data: Uint8Array) {345                ws.send(data as Uint8Array<ArrayBuffer>);346              },347              receive(): Promise<Uint8Array> {348                if (closed) return Promise.reject(new Error("closed"));349                const v = inbox.shift();350                if (v) return Promise.resolve(v);351                return new Promise<Uint8Array>((res) => waiters.push(res));352              },353              async close() {354                try { ws.close(); } catch { /* ignore */ }355              },356            });357          };358          ws.onmessage = (ev) => {359            if (!(ev.data instanceof ArrayBuffer)) return;360            const bytes = new Uint8Array(ev.data);361            const w = waiters.shift();362            if (w) w(bytes); else inbox.push(bytes);363          };364          ws.onerror = () => {365            clearTimeout(timer);366            reject(new Error("pairing_transport_error"));367          };368          ws.onclose = () => {369            closed = true;370            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));371          };372        });373      },374    };375376    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";377    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;378379    try {380      await runChirpPairing(deps, role);381      // Success surfaces in `pairing()` as the "paired" phase; ChirpPairingFlow382      // shows it and onDismiss returns to Settings.383    } catch (err) {384      if (err instanceof ChirpPairingFailure) {385        // The orchestrator already pushed the terminal phase into `pairing()`;386        // the inline error copy renders. The user can tap "Try again"/"Cancel".387        return;388      }389      setPairing({ phase: "handshake_failed", errorMessage: (err as Error).message });390    }391  }392393  // --- Sub-views --------------------------------------------------394395  const backArrow = (396    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">397      <path d="M10 3 L5 8 L10 13" />398    </svg>399  );400401  // The bird stage — always animating — shared by every desktop pairing screen402  // so the bird animation is present regardless of sync method.403  const desktopBirdStage = (active: boolean) => (404    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">405      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">406        <DesktopBird pose="chirping" />407      </div>408      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">409        <MobileBird pose="attentive" />410      </div>411    </div>412  );413414  function desktopLiveText(): string {415    switch (desktopPhase()) {416      case "enter": return "Enter the pairing code from your phone";417      case "connecting": return "Connecting to your phone";418      case "sas": return "Confirm the code matches on your phone";419      case "paired": return "Paired";420      case "error": return desktopErrorCopy(desktopError()).headline;421    }422  }423424  // Desktop helper code pairing — birds animate on every screen below.425  const desktopView = () => (426    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a device with a code">427      <header class="peach-chirp-head">428        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>429        <div class="peach-chirp-title">Enter a code</div>430        <div class="peach-chirp-head-spacer" />431      </header>432433      {desktopBirdStage(desktopPhase() !== "enter")}434435      <Show when={desktopPhase() === "enter"}>436        <div class="peach-desktop-enter">437          <div class="peach-chirp-headline">Enter the code from your phone</div>438          <div class="peach-chirp-sub">439            Open Peach on your phone, tap “Pair a device”, and type the code it shows.440            Both devices need to be on the same Wi-Fi.441          </div>442          <label class="peach-label" for="desktop-pair-code">pairing code</label>443          <input444            id="desktop-pair-code"445            class="peach-underline-input peach-unlock-input peach-code-input"446            type="text"447            autocapitalize="characters"448            autocomplete="one-time-code"449            spellcheck={false}450            maxlength={32}451            placeholder="A74H-48J3-KI32-OEP4"452            aria-label="Pairing code"453            value={desktopCode()}454            ref={(el) => setTimeout(() => el.focus(), 80)}455            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}456            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}457          />458          <div class="peach-chirp-action">459            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>460            <button461              class="peach-btn peach-btn-primary peach-desktop-connect"462              disabled={!desktopCodeReady() || desktopBusy()}463              onClick={() => void handleDesktopConnect()}464            >465              Connect466            </button>467          </div>468        </div>469      </Show>470471      <Show when={desktopPhase() === "connecting"}>472        <div class="peach-desktop-searching">473          <div class="peach-chirp-headline">Connecting…</div>474          <div class="peach-chirp-sub">Reaching your phone over the local network.</div>475        </div>476      </Show>477478      <Show when={desktopPhase() === "sas"}>479        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">480          <div class="peach-chirp-headline">Confirm this code matches on your phone:</div>481          <div482            class="peach-desktop-sas"483            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;"484          >485            {desktopSas()}486          </div>487          <div class="peach-chirp-sub" style="max-width:240px;">488            Only continue if this 6-digit code is identical on both screens. This is489            what proves you're talking to your own device.490          </div>491          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">492            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>493              Confirm494            </button>495            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>496              Cancel497            </button>498          </div>499        </div>500      </Show>501502      <Show when={desktopPhase() === "paired"}>503        <div class="peach-desktop-paired">504          <div class="peach-chirp-headline">Paired!</div>505          <div class="peach-chirp-sub">506            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your phone"}507          </div>508          <div class="peach-chirp-action">509            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>510          </div>511        </div>512      </Show>513514      <Show when={desktopPhase() === "error"}>515        <div class="peach-desktop-error">516          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>517          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>518          <div class="peach-chirp-action">519            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>520            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>521          </div>522        </div>523      </Show>524525      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>526    </div>527  );528529  const chirpPairingView = () => (530    <Show531      when={sasCode()}532      fallback={533        <ChirpPairingFlow534          progress={pairing()}535          onCancel={() => props.onBack()}536          onRetry={() => void handleStartPairing()}537          onDismiss={() => props.onBack()}538        />539      }540    >541      {/* Out-of-band verification gate — pairing is NOT persisted until the542          user confirms this code matches the one on the other device. */}543      <div544        class="chirp-sas-verify"545        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"546      >547        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>548        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">549          Confirm this code matches the one shown on your other device before pairing.550        </div>551        <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;">552          {sasCode()}553        </div>554        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">555          <button556            class="peach-btn peach-btn--accent peach-btn--md"557            type="button"558            onClick={() => resolveSAS(true)}559          >560            codes match — pair561          </button>562          <button563            class="peach-btn peach-btn--ghost peach-btn--md"564            type="button"565            onClick={() => resolveSAS(false)}566          >567            they don't match568          </button>569        </div>570      </div>571    </Show>572  );573574  // Pairing surface dispatch: the user already chose chirp vs. code on the rest575  // screen (or the sync card), so there's no method-select step.576  const pairingView = () => (577    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>578      {desktopView()}579    </Show>580  );581582  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can583  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.584  return (585    <>586      {pairingView()}587      <Show when={adoptionConsent()}>588        {(pending) => (589          <AdoptionConsentModal590            receiverDeviceName={pending().request.device_name}591            sas={pending().sas}592            verifyMasterPassword={async (password) => {593              const res = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password } });594              return Boolean((res as { ok: boolean; data?: boolean }).ok && (res as { data?: boolean }).data);595            }}596            onCancel={() => {597              pending().resolve(false);598              setAdoptionConsent(null);599            }}600            onApprove={async () => {601              pending().resolve(true);602              setAdoptionConsent(null);603            }}604          />605        )}606      </Show>607    </>608  );609};