ChirpSync.tsx

19.8 KB · raw

1// ChirpSync — the v3 Settings → Sync surface.2//3// Three modes the user can be in:4//5//   1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6//      to the License section.7//   2. Idle — list of paired devices + "Pair a device" hero button. Stale8//      devices (>7 days since last sync) show a soft warning indicator;9//      vouched devices (received via device manifest, v2 brief §4.2) show10//      an "Added automatically" badge.11//   3. Pairing — ChirpPairingFlow takes over the full surface and drives12//      the three-act bird animation in lockstep with the audio protocol.13//14// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15// the binding between that orchestrator, the background-message16// CHIRP_* handlers, and the Settings navigation surrounding it.1718import {19  Component,20  Show,21  createMemo,22  createSignal,23  For,24  onMount,25} from "solid-js";26import { sendMessage } from "../../shared/bridge";27import type {28  DeviceType,29  PairedDevice,30  PairingProgress,31} from "../../shared/sync-types";32import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";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}4849const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5051type Mode = "rest" | "pairing";5253export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {54  const [devices, setDevices] = createSignal<PairedDevice[]>([]);55  const [mode, setMode] = createSignal<Mode>("rest");56  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });57  const [error, setError] = createSignal<string | null>(null);58  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);59  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");60  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);61  // Out-of-band pairing verification: the 6-digit code both devices derive62  // from the shared handshake; the user confirms it matches before the pair63  // is persisted (audit HIGH — pairing initiator authentication).64  const [sasCode, setSasCode] = createSignal<string | null>(null);65  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);6667  function resolveSAS(ok: boolean) {68    const r = sasResolve();69    setSasCode(null);70    setSasResolve(null);71    r?.(ok);72  }7374  async function handleSyncNow(): Promise<void> {75    setSyncStatus("syncing");76    setSyncFlash(null);77    const res = await sendMessage({78      type: "TRIGGER_SYNC",79      payload: { reason: "manual", force: true },80    }) as { ok: boolean; error?: string };81    // Poll briefly for the new state — the dispatcher broadcasts but the82    // popup doesn't yet listen, so a short re-fetch covers it.83    setTimeout(async () => {84      const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {85        ok: boolean;86        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };87      };88      const s = state.data;89      if (!s) return;90      setSyncStatus(s.status);91      if (s.status === "idle") {92        setSyncFlash(93          (s.entriesReceived ?? 0) > 094            ? `Synced — ${s.entriesReceived} entries updated`95            : "Vault up to date",96        );97        setTimeout(() => setSyncFlash(null), 3000);98        await refresh();99      } else if (s.status === "error") {100        setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);101        setTimeout(() => setSyncFlash(null), 4000);102      }103    }, 250);104    if (!res.ok) {105      setSyncStatus("error");106      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);107      setTimeout(() => setSyncFlash(null), 4000);108    }109  }110111  onMount(() => {112    void refresh();113  });114115  async function refresh(): Promise<void> {116    if (!props.isPro) return;117    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });118    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;119    setDevices(data?.devices ?? []);120  }121122  async function handleStartPairing(): Promise<void> {123    setError(null);124125    // audioCapture is declared as optional_permissions in the manifest.126    // Chrome will silently deny getUserMedia unless we request it here,127    // during a user gesture. Firefox ignores this API but getUserMedia works128    // under its existing host permission grant.129    if (typeof chrome !== "undefined" && chrome.permissions?.request) {130      const granted = await chrome.permissions.request({131        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],132      });133      if (!granted) {134        setPairing({ phase: "mic_denied" });135        setMode("pairing");136        return;137      }138    }139140    setPairing({ phase: "idle" });141    setMode("pairing");142143    const deps: ChirpPairingDeps = {144      onProgress: (p) => setPairing(p),145      fetchLocalIdentity: async () => {146        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });147        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };148        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");149        // The handler returns a LocalIdentity; we trust the background's shape.150        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;151      },152      completePairing: async (paired) => {153        const res = await sendMessage({154          type: "CHIRP_COMPLETE_PAIRING",155          payload: { paired },156        });157        const r = res as { ok: boolean; error?: string };158        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");159      },160      confirmSAS: (sas) =>161        new Promise<boolean>((resolve) => {162          setSasResolve(() => resolve);163          setSasCode(sas);164        }),165      getLocalSubnet,166      openPairingTransport: async (endpoint) => {167        // Native WebSocket transport for the pairing-mode peer scan. The168        // chirp-pair orchestrator only uses send/receive/close, so a thin169        // adapter over the platform WebSocket suffices.170        return new Promise((resolve, reject) => {171          let ws: WebSocket;172          try {173            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);174          } catch (err) {175            reject(err);176            return;177          }178          const inbox: Uint8Array[] = [];179          const waiters: Array<(b: Uint8Array) => void> = [];180          let closed = false;181          ws.binaryType = "arraybuffer";182          const timer = setTimeout(() => {183            try { ws.close(); } catch { /* ignore */ }184            reject(new Error("pairing_transport_timeout"));185          }, 2_000);186          ws.onopen = () => {187            clearTimeout(timer);188            resolve({189              async send(data: Uint8Array) {190                ws.send(data as Uint8Array<ArrayBuffer>);191              },192              receive(): Promise<Uint8Array> {193                if (closed) return Promise.reject(new Error("closed"));194                const v = inbox.shift();195                if (v) return Promise.resolve(v);196                return new Promise<Uint8Array>((res) => waiters.push(res));197              },198              async close() {199                try { ws.close(); } catch { /* ignore */ }200              },201            });202          };203          ws.onmessage = (ev) => {204            if (!(ev.data instanceof ArrayBuffer)) return;205            const bytes = new Uint8Array(ev.data);206            const w = waiters.shift();207            if (w) w(bytes); else inbox.push(bytes);208          };209          ws.onerror = () => {210            clearTimeout(timer);211            reject(new Error("pairing_transport_error"));212          };213          ws.onclose = () => {214            closed = true;215            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));216          };217        });218      },219    };220221    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";222    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;223224    try {225      await runChirpPairing(deps, role);226      await refresh();227    } catch (err) {228      if (err instanceof ChirpPairingFailure) {229        // The orchestrator already pushed the terminal phase into230        // `pairing()`; the inline error copy will render. No further231        // action needed — the user can tap "Try again" or "Cancel".232        return;233      }234      setError((err as Error).message);235      setMode("rest");236    }237  }238239  async function handleUnpair(deviceId: string): Promise<void> {240    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });241    setConfirmUnpair(null);242    await refresh();243  }244245  function relativeTime(when: number | undefined): string {246    if (!when) return "Not synced yet";247    const elapsed = Date.now() - when;248    if (elapsed < 60_000) return "Just now";249    const minutes = Math.floor(elapsed / 60_000);250    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;251    const hours = Math.floor(minutes / 60);252    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;253    const days = Math.floor(hours / 24);254    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;255    return "Not seen recently";256  }257258  function isStale(d: PairedDevice): boolean {259    if (d.lastSyncedAt === undefined) return false;260    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;261  }262263  // --- Sub-views --------------------------------------------------264265  const proGateView = () => (266    <div class="peach-chirp-settings peach-chirp-settings--gated">267      <header class="peach-chirp-settings-header">268        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>269          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">270            <path d="M11 4 L5 9 L11 14" />271          </svg>272        </button>273        <h1 class="peach-chirp-settings-title">Sync</h1>274      </header>275      <div class="peach-chirp-pro-card">276        <div class="peach-chirp-pro-stage" aria-hidden="true">277          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"278               stroke-linecap="round" stroke-linejoin="round">279            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />280            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />281            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />282          </svg>283          <div class="peach-chirp-pro-bird-slot">284            <MobileBird pose="idle" />285          </div>286        </div>287        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>288        <p class="peach-chirp-pro-body">289          Pairs over an inaudible chirp. Syncs locally across your devices.290          No codes, no servers, no cloud.291        </p>292        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>293          Unlock with Peach Pro294        </button>295        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>296      </div>297    </div>298  );299300  const restView = () => (301    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">302      <header class="peach-chirp-head">303        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>304          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">305            <path d="M10 3 L5 8 L10 13" />306          </svg>307        </button>308        <div class="peach-chirp-title">Sync</div>309        <div class="peach-chirp-head-spacer" />310      </header>311312      <div class="peach-chirp-chip-row">313        <div class="peach-chirp-chip" data-phase="idle">314          <span class="peach-chirp-chip-dot" />315          <span class="peach-chirp-chip-text">ready</span>316        </div>317      </div>318319      <Show when={error()}>320        <div class="peach-chirp-settings-error" role="alert">{error()}</div>321      </Show>322323      <div class="peach-chirp-stage act-idle" aria-hidden="true">324        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">325          <DesktopBird pose="idle" />326        </div>327        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">328          <MobileBird pose="hidden" />329        </div>330      </div>331332      <div class="peach-chirp-copy-host">333        <div class="peach-chirp-copy peach-chirp-copy-rest">334          <div class="peach-chirp-headline">335            {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}336          </div>337          <div class="peach-chirp-sub">338            {devices().length === 0339              ? "Peach pairs over a short, inaudible chirp — no codes, no servers."340              : "Pair another device, or manage the ones already connected below."}341          </div>342        </div>343      </div>344345      <Show when={devices().length > 0}>346        <section class="peach-chirp-paired-section">347          <h2 class="peach-chirp-paired-heading">Paired devices</h2>348          <ul class="peach-chirp-device-list">349            <For each={devices()}>350              {(d) => (351                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>352                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />353                  <div class="peach-chirp-device-info">354                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>355                    <span class="peach-chirp-device-meta">356                      {relativeTime(d.lastSyncedAt)}357                      <Show when={d.trustSource === "vouched"}>358                        <span class="peach-chirp-device-badge">Added automatically</span>359                      </Show>360                      <Show when={isStale(d)}>361                        <span class="peach-chirp-device-stale">Not seen recently</span>362                      </Show>363                    </span>364                  </div>365                  <Show366                    when={confirmUnpair() !== d.deviceId}367                    fallback={368                      <div class="peach-chirp-unpair-confirm">369                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>370                          Cancel371                        </button>372                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>373                          Remove374                        </button>375                      </div>376                    }377                  >378                    <button379                      class="peach-icon-btn peach-chirp-device-remove"380                      aria-label={`Unpair ${d.deviceName}`}381                      onClick={() => setConfirmUnpair(d.deviceId)}382                    >383                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">384                        <path d="M3 3 L11 11 M11 3 L3 11" />385                      </svg>386                    </button>387                  </Show>388                </li>389              )}390            </For>391          </ul>392        </section>393      </Show>394395      <Show when={devices().length > 0}>396        <div class="peach-chirp-sync-now-row">397          <button398            class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"399            disabled={syncStatus() === "syncing"}400            onClick={() => void handleSyncNow()}401          >402            {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}403          </button>404          <Show when={syncFlash()}>405            <span class="peach-chirp-sync-flash" aria-live="polite">406              {syncFlash()}407            </span>408          </Show>409        </div>410      </Show>411412      <div class="peach-chirp-action">413        <p class="peach-chirp-mic-notice">414          Chirp uses your microphone to pair. Your browser will ask for permission.415        </p>416        <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>417          <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">418            <path d="M3 9 L9 3 L15 9" />419            <path d="M3 13 L9 7 L15 13" opacity="0.5" />420          </svg>421          <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>422        </button>423      </div>424425    </div>426  );427428  const pairingView = () => (429    <Show430      when={sasCode()}431      fallback={432        <ChirpPairingFlow433          progress={pairing()}434          onCancel={() => setMode("rest")}435          onRetry={() => void handleStartPairing()}436          onDismiss={() => {437            setMode("rest");438            void refresh();439          }}440        />441      }442    >443      {/* Out-of-band verification gate — pairing is NOT persisted until the444          user confirms this code matches the one on the other device. */}445      <div446        class="chirp-sas-verify"447        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"448      >449        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>450        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">451          Confirm this code matches the one shown on your other device before pairing.452        </div>453        <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;">454          {sasCode()}455        </div>456        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">457          <button458            class="peach-btn peach-btn--accent peach-btn--md"459            type="button"460            onClick={() => resolveSAS(true)}461          >462            codes match — pair463          </button>464          <button465            class="peach-btn peach-btn--ghost peach-btn--md"466            type="button"467            onClick={() => resolveSAS(false)}468          >469            they don't match470          </button>471        </div>472      </div>473    </Show>474  );475476  const currentMode = createMemo(() => {477    if (!props.isPro) return "gated";478    if (mode() === "pairing") return "pairing";479    return "rest";480  });481482  return (483    <Show484      when={currentMode() === "gated"}485      fallback={486        <Show when={currentMode() === "pairing"} fallback={restView()}>487          {pairingView()}488        </Show>489      }490    >491      {proGateView()}492    </Show>493  );494};495496const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {497  return (498    <div class="peach-chirp-device-icon" data-type={props.type}>499      <Show when={props.type === "android" || props.type === "ios"}>500        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">501          <rect x="6" y="2" width="8" height="16" rx="1.5" />502          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />503        </svg>504      </Show>505      <Show when={props.type === "extension" || props.type === "desktop"}>506        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">507          <rect x="2" y="4" width="16" height="10" rx="1.4" />508          <path d="M7 17 L13 17" />509          <path d="M9 14 L9 17 M11 14 L11 17" />510        </svg>511      </Show>512    </div>513  );514};