123456789101112131415161718import {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 75 76 77 78 if (typeof chrome !== "undefined" && chrome.permissions?.request) {79 const granted = await chrome.permissions.request({80 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],81 });82 if (!granted) {83 setPairing({ phase: "mic_denied" });84 setMode("pairing");85 return;86 }87 }8889 setPairing({ phase: "idle" });90 setMode("pairing");9192 const deps: ChirpPairingDeps = {93 onProgress: (p) => setPairing(p),94 fetchLocalIdentity: async () => {95 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });96 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };97 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");98 99 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;100 },101 completePairing: async (paired) => {102 const res = await sendMessage({103 type: "CHIRP_COMPLETE_PAIRING",104 payload: { paired },105 });106 const r = res as { ok: boolean; error?: string };107 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");108 },109 getLocalSubnet,110 openPairingTransport: async (endpoint) => {111 112 113 114 return new Promise((resolve, reject) => {115 let ws: WebSocket;116 try {117 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);118 } catch (err) {119 reject(err);120 return;121 }122 const inbox: Uint8Array[] = [];123 const waiters: Array<(b: Uint8Array) => void> = [];124 let closed = false;125 ws.binaryType = "arraybuffer";126 const timer = setTimeout(() => {127 try { ws.close(); } catch { }128 reject(new Error("pairing_transport_timeout"));129 }, 2_000);130 ws.onopen = () => {131 clearTimeout(timer);132 resolve({133 async send(data: Uint8Array) {134 ws.send(data as Uint8Array<ArrayBuffer>);135 },136 receive(): Promise<Uint8Array> {137 if (closed) return Promise.reject(new Error("closed"));138 const v = inbox.shift();139 if (v) return Promise.resolve(v);140 return new Promise<Uint8Array>((res) => waiters.push(res));141 },142 async close() {143 try { ws.close(); } catch { }144 },145 });146 };147 ws.onmessage = (ev) => {148 if (!(ev.data instanceof ArrayBuffer)) return;149 const bytes = new Uint8Array(ev.data);150 const w = waiters.shift();151 if (w) w(bytes); else inbox.push(bytes);152 };153 ws.onerror = () => {154 clearTimeout(timer);155 reject(new Error("pairing_transport_error"));156 };157 ws.onclose = () => {158 closed = true;159 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));160 };161 });162 },163 };164165 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";166 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;167168 try {169 await runChirpPairing(deps, role);170 await refresh();171 } catch (err) {172 if (err instanceof ChirpPairingFailure) {173 174 175 176 return;177 }178 setError((err as Error).message);179 setMode("rest");180 }181 }182183 async function handleUnpair(deviceId: string): Promise<void> {184 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });185 setConfirmUnpair(null);186 await refresh();187 }188189 function relativeTime(when: number | undefined): string {190 if (!when) return "Not synced yet";191 const elapsed = Date.now() - when;192 if (elapsed < 60_000) return "Just now";193 const minutes = Math.floor(elapsed / 60_000);194 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;195 const hours = Math.floor(minutes / 60);196 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;197 const days = Math.floor(hours / 24);198 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;199 return "Not seen recently";200 }201202 function isStale(d: PairedDevice): boolean {203 if (d.lastSyncedAt === undefined) return false;204 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;205 }206207 208209 const proGateView = () => (210 <div class="peach-chirp-settings peach-chirp-settings--gated">211 <header class="peach-chirp-settings-header">212 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>213 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">214 <path d="M11 4 L5 9 L11 14" />215 </svg>216 </button>217 <h1 class="peach-chirp-settings-title">Sync</h1>218 </header>219 <div class="peach-chirp-pro-card">220 <div class="peach-chirp-pro-stage" aria-hidden="true">221 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"222 stroke-linecap="round" stroke-linejoin="round">223 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />224 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />225 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />226 </svg>227 <div class="peach-chirp-pro-bird-slot">228 <MobileBird pose="idle" />229 </div>230 </div>231 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>232 <p class="peach-chirp-pro-body">233 Pairs over an inaudible chirp. Syncs locally across your devices.234 No codes, no servers, no cloud.235 </p>236 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>237 Unlock with Peach Pro238 </button>239 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>240 </div>241 </div>242 );243244 const restView = () => (245 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">246 <header class="peach-chirp-head">247 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>248 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">249 <path d="M10 3 L5 8 L10 13" />250 </svg>251 </button>252 <div class="peach-chirp-title">Sync</div>253 <div class="peach-chirp-head-spacer" />254 </header>255256 <div class="peach-chirp-chip-row">257 <div class="peach-chirp-chip" data-phase="idle">258 <span class="peach-chirp-chip-dot" />259 <span class="peach-chirp-chip-text">ready</span>260 </div>261 </div>262263 <Show when={error()}>264 <div class="peach-chirp-settings-error" role="alert">{error()}</div>265 </Show>266267 <div class="peach-chirp-stage act-idle" aria-hidden="true">268 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">269 <DesktopBird pose="idle" />270 </div>271 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">272 <MobileBird pose="hidden" />273 </div>274 </div>275276 <div class="peach-chirp-copy-host">277 <div class="peach-chirp-copy peach-chirp-copy-rest">278 <div class="peach-chirp-headline">279 {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}280 </div>281 <div class="peach-chirp-sub">282 {devices().length === 0283 ? "Peach pairs over a short, inaudible chirp — no codes, no servers."284 : "Pair another device, or manage the ones already connected below."}285 </div>286 </div>287 </div>288289 <Show when={devices().length > 0}>290 <section class="peach-chirp-paired-section">291 <h2 class="peach-chirp-paired-heading">Paired devices</h2>292 <ul class="peach-chirp-device-list">293 <For each={devices()}>294 {(d) => (295 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>296 <DeviceTypeIcon type={d.deviceType ?? "extension"} />297 <div class="peach-chirp-device-info">298 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>299 <span class="peach-chirp-device-meta">300 {relativeTime(d.lastSyncedAt)}301 <Show when={d.trustSource === "vouched"}>302 <span class="peach-chirp-device-badge">Added automatically</span>303 </Show>304 <Show when={isStale(d)}>305 <span class="peach-chirp-device-stale">Not seen recently</span>306 </Show>307 </span>308 </div>309 <Show310 when={confirmUnpair() !== d.deviceId}311 fallback={312 <div class="peach-chirp-unpair-confirm">313 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>314 Cancel315 </button>316 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>317 Remove318 </button>319 </div>320 }321 >322 <button323 class="peach-icon-btn peach-chirp-device-remove"324 aria-label={`Unpair ${d.deviceName}`}325 onClick={() => setConfirmUnpair(d.deviceId)}326 >327 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">328 <path d="M3 3 L11 11 M11 3 L3 11" />329 </svg>330 </button>331 </Show>332 </li>333 )}334 </For>335 </ul>336 </section>337 </Show>338339 <div class="peach-chirp-action">340 <p class="peach-chirp-mic-notice">341 Chirp uses your microphone to pair. Your browser will ask for permission.342 </p>343 <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>344 <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">345 <path d="M3 9 L9 3 L15 9" />346 <path d="M3 13 L9 7 L15 13" opacity="0.5" />347 </svg>348 <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>349 </button>350 </div>351352 </div>353 );354355 const pairingView = () => (356 <ChirpPairingFlow357 progress={pairing()}358 onCancel={() => setMode("rest")}359 onRetry={() => void handleStartPairing()}360 onDismiss={() => {361 setMode("rest");362 void refresh();363 }}364 />365 );366367 const currentMode = createMemo(() => {368 if (!props.isPro) return "gated";369 if (mode() === "pairing") return "pairing";370 return "rest";371 });372373 return (374 <Show375 when={currentMode() === "gated"}376 fallback={377 <Show when={currentMode() === "pairing"} fallback={restView()}>378 {pairingView()}379 </Show>380 }381 >382 {proGateView()}383 </Show>384 );385};386387const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {388 return (389 <div class="peach-chirp-device-icon" data-type={props.type}>390 <Show when={props.type === "android" || props.type === "ios"}>391 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">392 <rect x="6" y="2" width="8" height="16" rx="1.5" />393 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />394 </svg>395 </Show>396 <Show when={props.type === "extension" || props.type === "desktop"}>397 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">398 <rect x="2" y="4" width="16" height="10" rx="1.4" />399 <path d="M7 17 L13 17" />400 <path d="M9 14 L9 17 M11 14 L11 17" />401 </svg>402 </Show>403 </div>404 );405};