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({ 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 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 110 111 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 { }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 { }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 172 173 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 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