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);73 setPairing({ phase: "idle" });74 setMode("pairing");7576 const deps: ChirpPairingDeps = {77 onProgress: (p) => setPairing(p),78 fetchLocalIdentity: async () => {79 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });80 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };81 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");82 83 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;84 },85 completePairing: async (paired) => {86 const res = await sendMessage({87 type: "CHIRP_COMPLETE_PAIRING",88 payload: { paired },89 });90 const r = res as { ok: boolean; error?: string };91 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");92 },93 getLocalSubnet,94 openPairingTransport: async (endpoint) => {95 96 97 98 return new Promise((resolve, reject) => {99 let ws: WebSocket;100 try {101 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);102 } catch (err) {103 reject(err);104 return;105 }106 const inbox: Uint8Array[] = [];107 const waiters: Array<(b: Uint8Array) => void> = [];108 let closed = false;109 ws.binaryType = "arraybuffer";110 const timer = setTimeout(() => {111 try { ws.close(); } catch { }112 reject(new Error("pairing_transport_timeout"));113 }, 2_000);114 ws.onopen = () => {115 clearTimeout(timer);116 resolve({117 async send(data: Uint8Array) {118 ws.send(data as Uint8Array<ArrayBuffer>);119 },120 receive(): Promise<Uint8Array> {121 if (closed) return Promise.reject(new Error("closed"));122 const v = inbox.shift();123 if (v) return Promise.resolve(v);124 return new Promise<Uint8Array>((res) => waiters.push(res));125 },126 async close() {127 try { ws.close(); } catch { }128 },129 });130 };131 ws.onmessage = (ev) => {132 if (!(ev.data instanceof ArrayBuffer)) return;133 const bytes = new Uint8Array(ev.data);134 const w = waiters.shift();135 if (w) w(bytes); else inbox.push(bytes);136 };137 ws.onerror = () => {138 clearTimeout(timer);139 reject(new Error("pairing_transport_error"));140 };141 ws.onclose = () => {142 closed = true;143 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));144 };145 });146 },147 };148149 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";150 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;151152 try {153 await runChirpPairing(deps, role);154 await refresh();155 } catch (err) {156 if (err instanceof ChirpPairingFailure) {157 158 159 160 return;161 }162 setError((err as Error).message);163 setMode("rest");164 }165 }166167 async function handleUnpair(deviceId: string): Promise<void> {168 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });169 setConfirmUnpair(null);170 await refresh();171 }172173 function relativeTime(when: number | undefined): string {174 if (!when) return "Not synced yet";175 const elapsed = Date.now() - when;176 if (elapsed < 60_000) return "Just now";177 const minutes = Math.floor(elapsed / 60_000);178 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;179 const hours = Math.floor(minutes / 60);180 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;181 const days = Math.floor(hours / 24);182 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;183 return "Not seen recently";184 }185186 function isStale(d: PairedDevice): boolean {187 if (d.lastSyncedAt === undefined) return false;188 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;189 }190191 192193 const proGateView = () => (194 <div class="peach-chirp-settings peach-chirp-settings--gated">195 <header class="peach-chirp-settings-header">196 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>197 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">198 <path d="M11 4 L5 9 L11 14" />199 </svg>200 </button>201 <h1 class="peach-chirp-settings-title">Sync</h1>202 </header>203 <div class="peach-chirp-pro-card">204 <div class="peach-chirp-pro-stage" aria-hidden="true">205 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"206 stroke-linecap="round" stroke-linejoin="round">207 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />208 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />209 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />210 </svg>211 <div class="peach-chirp-pro-bird-slot">212 <MobileBird pose="idle" />213 </div>214 </div>215 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>216 <p class="peach-chirp-pro-body">217 Chirp Sync pairs your phone with your laptop using a quick218 inaudible chirp — then your vault stays in sync across devices on219 your local network. No QR codes, no relay servers, no cloud220 accounts.221 </p>222 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>223 Unlock with Peach Pro224 </button>225 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>226 </div>227 </div>228 );229230 const restView = () => (231 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">232 <header class="peach-chirp-head">233 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>234 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">235 <path d="M10 3 L5 8 L10 13" />236 </svg>237 </button>238 <div class="peach-chirp-title">Sync</div>239 <div class="peach-chirp-head-spacer" />240 </header>241242 <div class="peach-chirp-chip-row">243 <div class="peach-chirp-chip" data-phase="idle">244 <span class="peach-chirp-chip-dot" />245 <span class="peach-chirp-chip-text">ready</span>246 </div>247 </div>248249 <Show when={error()}>250 <div class="peach-chirp-settings-error" role="alert">{error()}</div>251 </Show>252253 <div class="peach-chirp-stage act-idle" aria-hidden="true">254 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">255 <DesktopBird pose="idle" />256 </div>257 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">258 <MobileBird pose="hidden" />259 </div>260 </div>261262 <div class="peach-chirp-copy-host">263 <div class="peach-chirp-copy peach-chirp-copy-rest">264 <div class="peach-chirp-headline">265 {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}266 </div>267 <div class="peach-chirp-sub">268 {devices().length === 0269 ? "Peach pairs over a short, inaudible chirp — no codes, no servers."270 : "Pair another device, or manage the ones already connected below."}271 </div>272 </div>273 </div>274275 <div class="peach-chirp-action">276 <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>277 <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">278 <path d="M3 9 L9 3 L15 9" />279 <path d="M3 13 L9 7 L15 13" opacity="0.5" />280 </svg>281 <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>282 </button>283 </div>284285 <Show when={devices().length > 0}>286 <section class="peach-chirp-paired-section">287 <h2 class="peach-chirp-paired-heading">Paired devices</h2>288 <ul class="peach-chirp-device-list">289 <For each={devices()}>290 {(d) => (291 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>292 <DeviceTypeIcon type={d.deviceType ?? "extension"} />293 <div class="peach-chirp-device-info">294 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>295 <span class="peach-chirp-device-meta">296 {relativeTime(d.lastSyncedAt)}297 <Show when={d.trustSource === "vouched"}>298 <span class="peach-chirp-device-badge">Added automatically</span>299 </Show>300 <Show when={isStale(d)}>301 <span class="peach-chirp-device-stale">Not seen recently</span>302 </Show>303 </span>304 </div>305 <Show306 when={confirmUnpair() !== d.deviceId}307 fallback={308 <div class="peach-chirp-unpair-confirm">309 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>310 Cancel311 </button>312 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>313 Remove314 </button>315 </div>316 }317 >318 <button319 class="peach-icon-btn peach-chirp-device-remove"320 aria-label={`Unpair ${d.deviceName}`}321 onClick={() => setConfirmUnpair(d.deviceId)}322 >323 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">324 <path d="M3 3 L11 11 M11 3 L3 11" />325 </svg>326 </button>327 </Show>328 </li>329 )}330 </For>331 </ul>332 </section>333 </Show>334335 <footer class="peach-chirp-foot">336 <div class="peach-chirp-foot-divider" />337 <div class="peach-chirp-foot-text">338 Chirp Sync stays on your local network. No relay servers, no cloud339 accounts — just your devices in the same room.340 </div>341 </footer>342 </div>343 );344345 const pairingView = () => (346 <ChirpPairingFlow347 progress={pairing()}348 onCancel={() => setMode("rest")}349 onRetry={() => void handleStartPairing()}350 onDismiss={() => {351 setMode("rest");352 void refresh();353 }}354 />355 );356357 const currentMode = createMemo(() => {358 if (!props.isPro) return "gated";359 if (mode() === "pairing") return "pairing";360 return "rest";361 });362363 return (364 <Show365 when={currentMode() === "gated"}366 fallback={367 <Show when={currentMode() === "pairing"} fallback={restView()}>368 {pairingView()}369 </Show>370 }371 >372 {proGateView()}373 </Show>374 );375};376377const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {378 return (379 <div class="peach-chirp-device-icon" data-type={props.type}>380 <Show when={props.type === "android" || props.type === "ios"}>381 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">382 <rect x="6" y="2" width="8" height="16" rx="1.5" />383 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />384 </svg>385 </Show>386 <Show when={props.type === "extension" || props.type === "desktop"}>387 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">388 <rect x="2" y="4" width="16" height="10" rx="1.4" />389 <path d="M7 17 L13 17" />390 <path d="M9 14 L9 17 M11 14 L11 17" />391 </svg>392 </Show>393 </div>394 );395};396