ChirpSync.tsx

17.7 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);6162  async function handleSyncNow(): Promise<void> {63    setSyncStatus("syncing");64    setSyncFlash(null);65    const res = await sendMessage({66      type: "TRIGGER_SYNC",67      payload: { reason: "manual", force: true },68    }) as { ok: boolean; error?: string };69    // Poll briefly for the new state — the dispatcher broadcasts but the70    // popup doesn't yet listen, so a short re-fetch covers it.71    setTimeout(async () => {72      const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {73        ok: boolean;74        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };75      };76      const s = state.data;77      if (!s) return;78      setSyncStatus(s.status);79      if (s.status === "idle") {80        setSyncFlash(81          (s.entriesReceived ?? 0) > 082            ? `Synced — ${s.entriesReceived} entries updated`83            : "Vault up to date",84        );85        setTimeout(() => setSyncFlash(null), 3000);86        await refresh();87      } else if (s.status === "error") {88        setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);89        setTimeout(() => setSyncFlash(null), 4000);90      }91    }, 250);92    if (!res.ok) {93      setSyncStatus("error");94      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);95      setTimeout(() => setSyncFlash(null), 4000);96    }97  }9899  onMount(() => {100    void refresh();101  });102103  async function refresh(): Promise<void> {104    if (!props.isPro) return;105    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });106    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;107    setDevices(data?.devices ?? []);108  }109110  async function handleStartPairing(): Promise<void> {111    setError(null);112113    // audioCapture is declared as optional_permissions in the manifest.114    // Chrome will silently deny getUserMedia unless we request it here,115    // during a user gesture. Firefox ignores this API but getUserMedia works116    // under its existing host permission grant.117    if (typeof chrome !== "undefined" && chrome.permissions?.request) {118      const granted = await chrome.permissions.request({119        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],120      });121      if (!granted) {122        setPairing({ phase: "mic_denied" });123        setMode("pairing");124        return;125      }126    }127128    setPairing({ phase: "idle" });129    setMode("pairing");130131    const deps: ChirpPairingDeps = {132      onProgress: (p) => setPairing(p),133      fetchLocalIdentity: async () => {134        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });135        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };136        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");137        // The handler returns a LocalIdentity; we trust the background's shape.138        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;139      },140      completePairing: async (paired) => {141        const res = await sendMessage({142          type: "CHIRP_COMPLETE_PAIRING",143          payload: { paired },144        });145        const r = res as { ok: boolean; error?: string };146        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");147      },148      getLocalSubnet,149      openPairingTransport: async (endpoint) => {150        // Native WebSocket transport for the pairing-mode peer scan. The151        // chirp-pair orchestrator only uses send/receive/close, so a thin152        // adapter over the platform WebSocket suffices.153        return new Promise((resolve, reject) => {154          let ws: WebSocket;155          try {156            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);157          } catch (err) {158            reject(err);159            return;160          }161          const inbox: Uint8Array[] = [];162          const waiters: Array<(b: Uint8Array) => void> = [];163          let closed = false;164          ws.binaryType = "arraybuffer";165          const timer = setTimeout(() => {166            try { ws.close(); } catch { /* ignore */ }167            reject(new Error("pairing_transport_timeout"));168          }, 2_000);169          ws.onopen = () => {170            clearTimeout(timer);171            resolve({172              async send(data: Uint8Array) {173                ws.send(data as Uint8Array<ArrayBuffer>);174              },175              receive(): Promise<Uint8Array> {176                if (closed) return Promise.reject(new Error("closed"));177                const v = inbox.shift();178                if (v) return Promise.resolve(v);179                return new Promise<Uint8Array>((res) => waiters.push(res));180              },181              async close() {182                try { ws.close(); } catch { /* ignore */ }183              },184            });185          };186          ws.onmessage = (ev) => {187            if (!(ev.data instanceof ArrayBuffer)) return;188            const bytes = new Uint8Array(ev.data);189            const w = waiters.shift();190            if (w) w(bytes); else inbox.push(bytes);191          };192          ws.onerror = () => {193            clearTimeout(timer);194            reject(new Error("pairing_transport_error"));195          };196          ws.onclose = () => {197            closed = true;198            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));199          };200        });201      },202    };203204    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";205    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;206207    try {208      await runChirpPairing(deps, role);209      await refresh();210    } catch (err) {211      if (err instanceof ChirpPairingFailure) {212        // The orchestrator already pushed the terminal phase into213        // `pairing()`; the inline error copy will render. No further214        // action needed — the user can tap "Try again" or "Cancel".215        return;216      }217      setError((err as Error).message);218      setMode("rest");219    }220  }221222  async function handleUnpair(deviceId: string): Promise<void> {223    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });224    setConfirmUnpair(null);225    await refresh();226  }227228  function relativeTime(when: number | undefined): string {229    if (!when) return "Not synced yet";230    const elapsed = Date.now() - when;231    if (elapsed < 60_000) return "Just now";232    const minutes = Math.floor(elapsed / 60_000);233    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;234    const hours = Math.floor(minutes / 60);235    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;236    const days = Math.floor(hours / 24);237    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;238    return "Not seen recently";239  }240241  function isStale(d: PairedDevice): boolean {242    if (d.lastSyncedAt === undefined) return false;243    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;244  }245246  // --- Sub-views --------------------------------------------------247248  const proGateView = () => (249    <div class="peach-chirp-settings peach-chirp-settings--gated">250      <header class="peach-chirp-settings-header">251        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>252          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">253            <path d="M11 4 L5 9 L11 14" />254          </svg>255        </button>256        <h1 class="peach-chirp-settings-title">Sync</h1>257      </header>258      <div class="peach-chirp-pro-card">259        <div class="peach-chirp-pro-stage" aria-hidden="true">260          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"261               stroke-linecap="round" stroke-linejoin="round">262            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />263            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />264            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />265          </svg>266          <div class="peach-chirp-pro-bird-slot">267            <MobileBird pose="idle" />268          </div>269        </div>270        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>271        <p class="peach-chirp-pro-body">272          Pairs over an inaudible chirp. Syncs locally across your devices.273          No codes, no servers, no cloud.274        </p>275        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>276          Unlock with Peach Pro277        </button>278        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>279      </div>280    </div>281  );282283  const restView = () => (284    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">285      <header class="peach-chirp-head">286        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>287          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">288            <path d="M10 3 L5 8 L10 13" />289          </svg>290        </button>291        <div class="peach-chirp-title">Sync</div>292        <div class="peach-chirp-head-spacer" />293      </header>294295      <div class="peach-chirp-chip-row">296        <div class="peach-chirp-chip" data-phase="idle">297          <span class="peach-chirp-chip-dot" />298          <span class="peach-chirp-chip-text">ready</span>299        </div>300      </div>301302      <Show when={error()}>303        <div class="peach-chirp-settings-error" role="alert">{error()}</div>304      </Show>305306      <div class="peach-chirp-stage act-idle" aria-hidden="true">307        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">308          <DesktopBird pose="idle" />309        </div>310        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">311          <MobileBird pose="hidden" />312        </div>313      </div>314315      <div class="peach-chirp-copy-host">316        <div class="peach-chirp-copy peach-chirp-copy-rest">317          <div class="peach-chirp-headline">318            {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}319          </div>320          <div class="peach-chirp-sub">321            {devices().length === 0322              ? "Peach pairs over a short, inaudible chirp — no codes, no servers."323              : "Pair another device, or manage the ones already connected below."}324          </div>325        </div>326      </div>327328      <Show when={devices().length > 0}>329        <section class="peach-chirp-paired-section">330          <h2 class="peach-chirp-paired-heading">Paired devices</h2>331          <ul class="peach-chirp-device-list">332            <For each={devices()}>333              {(d) => (334                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>335                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />336                  <div class="peach-chirp-device-info">337                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>338                    <span class="peach-chirp-device-meta">339                      {relativeTime(d.lastSyncedAt)}340                      <Show when={d.trustSource === "vouched"}>341                        <span class="peach-chirp-device-badge">Added automatically</span>342                      </Show>343                      <Show when={isStale(d)}>344                        <span class="peach-chirp-device-stale">Not seen recently</span>345                      </Show>346                    </span>347                  </div>348                  <Show349                    when={confirmUnpair() !== d.deviceId}350                    fallback={351                      <div class="peach-chirp-unpair-confirm">352                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>353                          Cancel354                        </button>355                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>356                          Remove357                        </button>358                      </div>359                    }360                  >361                    <button362                      class="peach-icon-btn peach-chirp-device-remove"363                      aria-label={`Unpair ${d.deviceName}`}364                      onClick={() => setConfirmUnpair(d.deviceId)}365                    >366                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">367                        <path d="M3 3 L11 11 M11 3 L3 11" />368                      </svg>369                    </button>370                  </Show>371                </li>372              )}373            </For>374          </ul>375        </section>376      </Show>377378      <Show when={devices().length > 0}>379        <div class="peach-chirp-sync-now-row">380          <button381            class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"382            disabled={syncStatus() === "syncing"}383            onClick={() => void handleSyncNow()}384          >385            {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}386          </button>387          <Show when={syncFlash()}>388            <span class="peach-chirp-sync-flash" aria-live="polite">389              {syncFlash()}390            </span>391          </Show>392        </div>393      </Show>394395      <div class="peach-chirp-action">396        <p class="peach-chirp-mic-notice">397          Chirp uses your microphone to pair. Your browser will ask for permission.398        </p>399        <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>400          <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">401            <path d="M3 9 L9 3 L15 9" />402            <path d="M3 13 L9 7 L15 13" opacity="0.5" />403          </svg>404          <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>405        </button>406      </div>407408    </div>409  );410411  const pairingView = () => (412    <ChirpPairingFlow413      progress={pairing()}414      onCancel={() => setMode("rest")}415      onRetry={() => void handleStartPairing()}416      onDismiss={() => {417        setMode("rest");418        void refresh();419      }}420    />421  );422423  const currentMode = createMemo(() => {424    if (!props.isPro) return "gated";425    if (mode() === "pairing") return "pairing";426    return "rest";427  });428429  return (430    <Show431      when={currentMode() === "gated"}432      fallback={433        <Show when={currentMode() === "pairing"} fallback={restView()}>434          {pairingView()}435        </Show>436      }437    >438      {proGateView()}439    </Show>440  );441};442443const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {444  return (445    <div class="peach-chirp-device-icon" data-type={props.type}>446      <Show when={props.type === "android" || props.type === "ios"}>447        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">448          <rect x="6" y="2" width="8" height="16" rx="1.5" />449          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />450        </svg>451      </Show>452      <Show when={props.type === "extension" || props.type === "desktop"}>453        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">454          <rect x="2" y="4" width="16" height="10" rx="1.4" />455          <path d="M7 17 L13 17" />456          <path d="M9 14 L9 17 M11 14 L11 17" />457        </svg>458      </Show>459    </div>460  );461};