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 } from "../components/ChirpPairingFlow";33import {34 ChirpPairingFailure,35 runChirpPairing,36 type ChirpPairingDeps,37} from "../chirp-pair";38import { getLocalSubnet } from "../../shared/local-network";39import { PeachMark } from "../components/PeachMark";4041export interface ChirpSyncViewProps {42 isPro: boolean;43 onBack(): void;44 onLearnPro(): void;45}4647const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;4849type Mode = "idle" | "pairing";5051export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {52 const [devices, setDevices] = createSignal<PairedDevice[]>([]);53 const [mode, setMode] = createSignal<Mode>("idle");54 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });55 const [error, setError] = createSignal<string | null>(null);56 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);5758 onMount(() => {59 void refresh();60 });6162 async function refresh(): Promise<void> {63 if (!props.isPro) return;64 const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });65 const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;66 setDevices(data?.devices ?? []);67 }6869 async function handleStartPairing(): Promise<void> {70 setError(null);71 setPairing({ phase: "idle" });72 setMode("pairing");7374 const deps: ChirpPairingDeps = {75 onProgress: (p) => setPairing(p),76 fetchLocalIdentity: async () => {77 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });78 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };79 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");80 81 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;82 },83 completePairing: async (paired) => {84 const res = await sendMessage({85 type: "CHIRP_COMPLETE_PAIRING",86 payload: { paired },87 });88 const r = res as { ok: boolean; error?: string };89 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");90 },91 getLocalSubnet,92 openPairingTransport: async (endpoint) => {93 94 95 96 return new Promise((resolve, reject) => {97 let ws: WebSocket;98 try {99 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);100 } catch (err) {101 reject(err);102 return;103 }104 const inbox: Uint8Array[] = [];105 const waiters: Array<(b: Uint8Array) => void> = [];106 let closed = false;107 ws.binaryType = "arraybuffer";108 const timer = setTimeout(() => {109 try { ws.close(); } catch { }110 reject(new Error("pairing_transport_timeout"));111 }, 2_000);112 ws.onopen = () => {113 clearTimeout(timer);114 resolve({115 async send(data: Uint8Array) {116 ws.send(data as Uint8Array<ArrayBuffer>);117 },118 receive(): Promise<Uint8Array> {119 if (closed) return Promise.reject(new Error("closed"));120 const v = inbox.shift();121 if (v) return Promise.resolve(v);122 return new Promise<Uint8Array>((res) => waiters.push(res));123 },124 async close() {125 try { ws.close(); } catch { }126 },127 });128 };129 ws.onmessage = (ev) => {130 if (!(ev.data instanceof ArrayBuffer)) return;131 const bytes = new Uint8Array(ev.data);132 const w = waiters.shift();133 if (w) w(bytes); else inbox.push(bytes);134 };135 ws.onerror = () => {136 clearTimeout(timer);137 reject(new Error("pairing_transport_error"));138 };139 ws.onclose = () => {140 closed = true;141 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));142 };143 });144 },145 };146147 try {148 await runChirpPairing(deps);149 await refresh();150 } catch (err) {151 if (err instanceof ChirpPairingFailure) {152 153 154 155 return;156 }157 setError((err as Error).message);158 setMode("idle");159 }160 }161162 async function handleUnpair(deviceId: string): Promise<void> {163 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });164 setConfirmUnpair(null);165 await refresh();166 }167168 function relativeTime(when: number | undefined): string {169 if (!when) return "Not synced yet";170 const elapsed = Date.now() - when;171 if (elapsed < 60_000) return "Just now";172 const minutes = Math.floor(elapsed / 60_000);173 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;174 const hours = Math.floor(minutes / 60);175 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;176 const days = Math.floor(hours / 24);177 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;178 return "Not seen recently";179 }180181 function isStale(d: PairedDevice): boolean {182 if (d.lastSyncedAt === undefined) return false;183 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;184 }185186 187188 const proGateView = () => (189 <div class="peach-chirp-settings peach-chirp-settings--gated">190 <header class="peach-chirp-settings-header">191 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>192 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">193 <path d="M11 4 L5 9 L11 14" />194 </svg>195 </button>196 <h1 class="peach-chirp-settings-title">Sync</h1>197 </header>198 <div class="peach-chirp-pro-card">199 <div class="peach-chirp-pro-illustration">200 <PeachMark class="peach-chirp-pro-mark" />201 <svg class="peach-chirp-pro-waves" width="200" height="80" viewBox="0 0 200 80" fill="none" stroke="currentColor" stroke-width="1.4">202 <path d="M 30 40 Q 100 10 170 40" />203 <path d="M 30 50 Q 100 20 170 50" opacity="0.6" />204 <path d="M 30 60 Q 100 30 170 60" opacity="0.3" />205 </svg>206 </div>207 <h2 class="peach-chirp-pro-headline">Chirp Sync is a Peach Pro feature</h2>208 <p class="peach-chirp-pro-body">209 Pair your phone with your laptop using a quick inaudible chirp —210 then your vault stays in sync across devices on your local network.211 No QR codes, no relay servers, no cloud accounts.212 </p>213 <button class="peach-btn peach-btn-primary" onClick={() => props.onLearnPro()}>214 Learn more about Peach Pro215 </button>216 </div>217 </div>218 );219220 const idleView = () => (221 <div class="peach-chirp-settings">222 <header class="peach-chirp-settings-header">223 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>224 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">225 <path d="M11 4 L5 9 L11 14" />226 </svg>227 </button>228 <h1 class="peach-chirp-settings-title">Sync</h1>229 </header>230231 <Show when={error()}>232 <div class="peach-chirp-settings-error" role="alert">{error()}</div>233 </Show>234235 <Show when={devices().length === 0}>236 <div class="peach-chirp-hero">237 <ChirpHeroBirds />238 <h2 class="peach-chirp-hero-headline">Pair your phone with this Peach</h2>239 <p class="peach-chirp-hero-sub">240 Hold your phone near your laptop. Peach will chirp once,241 inaudibly, and pair automatically. No codes, no servers.242 </p>243 </div>244 </Show>245246 <div class="peach-chirp-pair-cta">247 <button class="peach-btn peach-btn-primary peach-chirp-pair-button" onClick={() => void handleStartPairing()}>248 <svg class="peach-chirp-pair-button-icon" width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">249 <path d="M3 9 L9 3 L15 9" />250 <path d="M3 13 L9 7 L15 13" opacity="0.5" />251 </svg>252 Pair a device253 </button>254 </div>255256 <Show when={devices().length > 0}>257 <h2 class="peach-chirp-settings-section">Paired devices</h2>258 <ul class="peach-chirp-device-list">259 <For each={devices()}>260 {(d) => (261 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>262 <DeviceTypeIcon type={d.deviceType ?? "extension"} />263 <div class="peach-chirp-device-info">264 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>265 <span class="peach-chirp-device-meta">266 {relativeTime(d.lastSyncedAt)}267 <Show when={d.trustSource === "vouched"}>268 <span class="peach-chirp-device-badge">Added automatically</span>269 </Show>270 <Show when={isStale(d)}>271 <span class="peach-chirp-device-stale">Not seen recently</span>272 </Show>273 </span>274 </div>275 <Show276 when={confirmUnpair() !== d.deviceId}277 fallback={278 <div class="peach-chirp-unpair-confirm">279 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>280 Cancel281 </button>282 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>283 Remove284 </button>285 </div>286 }287 >288 <button289 class="peach-icon-btn peach-chirp-device-remove"290 aria-label={`Unpair ${d.deviceName}`}291 onClick={() => setConfirmUnpair(d.deviceId)}292 >293 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">294 <path d="M3 3 L11 11 M11 3 L3 11" />295 </svg>296 </button>297 </Show>298 </li>299 )}300 </For>301 </ul>302 </Show>303304 <footer class="peach-chirp-settings-footer">305 <p>306 Chirp Sync stays entirely on your local network. No relay servers,307 no cloud accounts — just your devices in the same room.308 </p>309 </footer>310 </div>311 );312313 const pairingView = () => (314 <div class="peach-chirp-settings peach-chirp-settings--pairing">315 <ChirpPairingFlow316 progress={pairing()}317 onCancel={() => setMode("idle")}318 onRetry={() => void handleStartPairing()}319 onDismiss={() => {320 setMode("idle");321 void refresh();322 }}323 />324 </div>325 );326327 const currentMode = createMemo(() => {328 if (!props.isPro) return "gated";329 if (mode() === "pairing") return "pairing";330 return "idle";331 });332333 return (334 <Show335 when={currentMode() === "gated"}336 fallback={337 <Show when={currentMode() === "pairing"} fallback={idleView()}>338 {pairingView()}339 </Show>340 }341 >342 {proGateView()}343 </Show>344 );345};346347const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {348 return (349 <div class="peach-chirp-device-icon" data-type={props.type}>350 <Show when={props.type === "android" || props.type === "ios"}>351 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">352 <rect x="6" y="2" width="8" height="16" rx="1.5" />353 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />354 </svg>355 </Show>356 <Show when={props.type === "extension" || props.type === "desktop"}>357 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">358 <rect x="2" y="4" width="16" height="10" rx="1.4" />359 <path d="M7 17 L13 17" />360 <path d="M9 14 L9 17 M11 14 L11 17" />361 </svg>362 </Show>363 </div>364 );365};366367368369370371const ChirpHeroBirds: Component = () => {372 return (373 <svg class="peach-chirp-hero-svg" width="240" height="120" viewBox="0 0 240 120" fill="none">374 <ellipse cx="80" cy="78" rx="32" ry="24" class="peach-chirp-bird-body" />375 <circle cx="98" cy="56" r="16" class="peach-chirp-bird-head" />376 <circle cx="103" cy="54" r="2.4" class="peach-chirp-bird-eye" />377 <path d="M 113 56 L 122 56 L 116 62 Z" class="peach-chirp-bird-beak" />378 <path d="M 60 64 Q 50 76 60 86" class="peach-chirp-bird-wing" fill="none" stroke-width="2.4" stroke-linecap="round" />379380 <path d="M 130 60 Q 160 36 180 60" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.45" />381 <path d="M 130 70 Q 160 46 180 70" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.25" />382383 <ellipse cx="184" cy="80" rx="22" ry="17" class="peach-chirp-bird-body" />384 <circle cx="170" cy="64" r="11" class="peach-chirp-bird-head" />385 <circle cx="166" cy="62" r="1.8" class="peach-chirp-bird-eye" />386 <path d="M 158 64 L 151 64 L 156 69 Z" class="peach-chirp-bird-beak" />387 <path d="M 200 72 Q 210 82 200 92" class="peach-chirp-bird-wing" fill="none" stroke-width="2.2" stroke-linecap="round" />388 </svg>389 );390};