ChirpSync.tsx

16.0 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);5960  onMount(() => {61    void refresh();62  });6364  async function refresh(): Promise<void> {65    if (!props.isPro) return;66    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });67    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;68    setDevices(data?.devices ?? []);69  }7071  async function handleStartPairing(): Promise<void> {72    setError(null);7374    // audioCapture is declared as optional_permissions in the manifest.75    // Chrome will silently deny getUserMedia unless we request it here,76    // during a user gesture. Firefox ignores this API but getUserMedia works77    // under its existing host permission grant.78    if (typeof chrome !== "undefined" && chrome.permissions?.request) {79      const granted = await chrome.permissions.request({ permissions: ["audioCapture"] });80      if (!granted) {81        setPairing({ phase: "mic_denied" });82        setMode("pairing");83        return;84      }85    }8687    setPairing({ phase: "idle" });88    setMode("pairing");8990    const deps: ChirpPairingDeps = {91      onProgress: (p) => setPairing(p),92      fetchLocalIdentity: async () => {93        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });94        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };95        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");96        // The handler returns a LocalIdentity; we trust the background's shape.97        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;98      },99      completePairing: async (paired) => {100        const res = await sendMessage({101          type: "CHIRP_COMPLETE_PAIRING",102          payload: { paired },103        });104        const r = res as { ok: boolean; error?: string };105        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");106      },107      getLocalSubnet,108      openPairingTransport: async (endpoint) => {109        // Native WebSocket transport for the pairing-mode peer scan. The110        // chirp-pair orchestrator only uses send/receive/close, so a thin111        // adapter over the platform WebSocket suffices.112        return new Promise((resolve, reject) => {113          let ws: WebSocket;114          try {115            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);116          } catch (err) {117            reject(err);118            return;119          }120          const inbox: Uint8Array[] = [];121          const waiters: Array<(b: Uint8Array) => void> = [];122          let closed = false;123          ws.binaryType = "arraybuffer";124          const timer = setTimeout(() => {125            try { ws.close(); } catch { /* ignore */ }126            reject(new Error("pairing_transport_timeout"));127          }, 2_000);128          ws.onopen = () => {129            clearTimeout(timer);130            resolve({131              async send(data: Uint8Array) {132                ws.send(data as Uint8Array<ArrayBuffer>);133              },134              receive(): Promise<Uint8Array> {135                if (closed) return Promise.reject(new Error("closed"));136                const v = inbox.shift();137                if (v) return Promise.resolve(v);138                return new Promise<Uint8Array>((res) => waiters.push(res));139              },140              async close() {141                try { ws.close(); } catch { /* ignore */ }142              },143            });144          };145          ws.onmessage = (ev) => {146            if (!(ev.data instanceof ArrayBuffer)) return;147            const bytes = new Uint8Array(ev.data);148            const w = waiters.shift();149            if (w) w(bytes); else inbox.push(bytes);150          };151          ws.onerror = () => {152            clearTimeout(timer);153            reject(new Error("pairing_transport_error"));154          };155          ws.onclose = () => {156            closed = true;157            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));158          };159        });160      },161    };162163    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";164    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;165166    try {167      await runChirpPairing(deps, role);168      await refresh();169    } catch (err) {170      if (err instanceof ChirpPairingFailure) {171        // The orchestrator already pushed the terminal phase into172        // `pairing()`; the inline error copy will render. No further173        // action needed — the user can tap "Try again" or "Cancel".174        return;175      }176      setError((err as Error).message);177      setMode("rest");178    }179  }180181  async function handleUnpair(deviceId: string): Promise<void> {182    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });183    setConfirmUnpair(null);184    await refresh();185  }186187  function relativeTime(when: number | undefined): string {188    if (!when) return "Not synced yet";189    const elapsed = Date.now() - when;190    if (elapsed < 60_000) return "Just now";191    const minutes = Math.floor(elapsed / 60_000);192    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;193    const hours = Math.floor(minutes / 60);194    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;195    const days = Math.floor(hours / 24);196    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;197    return "Not seen recently";198  }199200  function isStale(d: PairedDevice): boolean {201    if (d.lastSyncedAt === undefined) return false;202    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;203  }204205  // --- Sub-views --------------------------------------------------206207  const proGateView = () => (208    <div class="peach-chirp-settings peach-chirp-settings--gated">209      <header class="peach-chirp-settings-header">210        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>211          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">212            <path d="M11 4 L5 9 L11 14" />213          </svg>214        </button>215        <h1 class="peach-chirp-settings-title">Sync</h1>216      </header>217      <div class="peach-chirp-pro-card">218        <div class="peach-chirp-pro-stage" aria-hidden="true">219          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"220               stroke-linecap="round" stroke-linejoin="round">221            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />222            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />223            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />224          </svg>225          <div class="peach-chirp-pro-bird-slot">226            <MobileBird pose="idle" />227          </div>228        </div>229        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>230        <p class="peach-chirp-pro-body">231          Pairs over an inaudible chirp. Syncs locally across your devices.232          No codes, no servers, no cloud.233        </p>234        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>235          Unlock with Peach Pro236        </button>237        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>238      </div>239    </div>240  );241242  const restView = () => (243    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">244      <header class="peach-chirp-head">245        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>246          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">247            <path d="M10 3 L5 8 L10 13" />248          </svg>249        </button>250        <div class="peach-chirp-title">Sync</div>251        <div class="peach-chirp-head-spacer" />252      </header>253254      <div class="peach-chirp-chip-row">255        <div class="peach-chirp-chip" data-phase="idle">256          <span class="peach-chirp-chip-dot" />257          <span class="peach-chirp-chip-text">ready</span>258        </div>259      </div>260261      <Show when={error()}>262        <div class="peach-chirp-settings-error" role="alert">{error()}</div>263      </Show>264265      <div class="peach-chirp-stage act-idle" aria-hidden="true">266        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">267          <DesktopBird pose="idle" />268        </div>269        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">270          <MobileBird pose="hidden" />271        </div>272      </div>273274      <div class="peach-chirp-copy-host">275        <div class="peach-chirp-copy peach-chirp-copy-rest">276          <div class="peach-chirp-headline">277            {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}278          </div>279          <div class="peach-chirp-sub">280            {devices().length === 0281              ? "Peach pairs over a short, inaudible chirp — no codes, no servers."282              : "Pair another device, or manage the ones already connected below."}283          </div>284        </div>285      </div>286287      <Show when={devices().length > 0}>288        <section class="peach-chirp-paired-section">289          <h2 class="peach-chirp-paired-heading">Paired devices</h2>290          <ul class="peach-chirp-device-list">291            <For each={devices()}>292              {(d) => (293                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>294                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />295                  <div class="peach-chirp-device-info">296                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>297                    <span class="peach-chirp-device-meta">298                      {relativeTime(d.lastSyncedAt)}299                      <Show when={d.trustSource === "vouched"}>300                        <span class="peach-chirp-device-badge">Added automatically</span>301                      </Show>302                      <Show when={isStale(d)}>303                        <span class="peach-chirp-device-stale">Not seen recently</span>304                      </Show>305                    </span>306                  </div>307                  <Show308                    when={confirmUnpair() !== d.deviceId}309                    fallback={310                      <div class="peach-chirp-unpair-confirm">311                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>312                          Cancel313                        </button>314                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>315                          Remove316                        </button>317                      </div>318                    }319                  >320                    <button321                      class="peach-icon-btn peach-chirp-device-remove"322                      aria-label={`Unpair ${d.deviceName}`}323                      onClick={() => setConfirmUnpair(d.deviceId)}324                    >325                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">326                        <path d="M3 3 L11 11 M11 3 L3 11" />327                      </svg>328                    </button>329                  </Show>330                </li>331              )}332            </For>333          </ul>334        </section>335      </Show>336337      <div class="peach-chirp-action">338        <p class="peach-chirp-mic-notice">339          <svg width="11" height="11" viewBox="0 0 14 18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">340            <rect x="4" y="1" width="6" height="10" rx="3"/>341            <path d="M2 9a5 5 0 0010 0"/>342            <line x1="7" y1="14" x2="7" y2="17"/>343            <line x1="4" y1="17" x2="10" y2="17"/>344          </svg>345          Chirp uses your microphone to pair. Your browser will ask for permission.346        </p>347        <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>348          <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">349            <path d="M3 9 L9 3 L15 9" />350            <path d="M3 13 L9 7 L15 13" opacity="0.5" />351          </svg>352          <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>353        </button>354      </div>355356    </div>357  );358359  const pairingView = () => (360    <ChirpPairingFlow361      progress={pairing()}362      onCancel={() => setMode("rest")}363      onRetry={() => void handleStartPairing()}364      onDismiss={() => {365        setMode("rest");366        void refresh();367      }}368    />369  );370371  const currentMode = createMemo(() => {372    if (!props.isPro) return "gated";373    if (mode() === "pairing") return "pairing";374    return "rest";375  });376377  return (378    <Show379      when={currentMode() === "gated"}380      fallback={381        <Show when={currentMode() === "pairing"} fallback={restView()}>382          {pairingView()}383        </Show>384      }385    >386      {proGateView()}387    </Show>388  );389};390391const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {392  return (393    <div class="peach-chirp-device-icon" data-type={props.type}>394      <Show when={props.type === "android" || props.type === "ios"}>395        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">396          <rect x="6" y="2" width="8" height="16" rx="1.5" />397          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />398        </svg>399      </Show>400      <Show when={props.type === "extension" || props.type === "desktop"}>401        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">402          <rect x="2" y="4" width="16" height="10" rx="1.4" />403          <path d="M7 17 L13 17" />404          <path d="M9 14 L9 17 M11 14 L11 17" />405        </svg>406      </Show>407    </div>408  );409};410