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";42import { PeachMark } from "../components/PeachMark";4344export interface ChirpSyncViewProps {45 isPro: boolean;46 onBack(): void;47 onLearnPro(): void;48}4950const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5152type Mode = "rest" | "pairing";5354export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {55 const [devices, setDevices] = createSignal<PairedDevice[]>([]);56 const [mode, setMode] = createSignal<Mode>("rest");57 const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });58 const [error, setError] = createSignal<string | null>(null);59 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);6061 onMount(() => {62 void refresh();63 });6465 async function refresh(): Promise<void> {66 if (!props.isPro) return;67 const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });68 const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;69 setDevices(data?.devices ?? []);70 }7172 async function handleStartPairing(): Promise<void> {73 setError(null);74 setPairing({ phase: "idle" });75 setMode("pairing");7677 const deps: ChirpPairingDeps = {78 onProgress: (p) => setPairing(p),79 fetchLocalIdentity: async () => {80 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });81 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };82 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");83 84 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;85 },86 completePairing: async (paired) => {87 const res = await sendMessage({88 type: "CHIRP_COMPLETE_PAIRING",89 payload: { paired },90 });91 const r = res as { ok: boolean; error?: string };92 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");93 },94 getLocalSubnet,95 openPairingTransport: async (endpoint) => {96 97 98 99 return new Promise((resolve, reject) => {100 let ws: WebSocket;101 try {102 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);103 } catch (err) {104 reject(err);105 return;106 }107 const inbox: Uint8Array[] = [];108 const waiters: Array<(b: Uint8Array) => void> = [];109 let closed = false;110 ws.binaryType = "arraybuffer";111 const timer = setTimeout(() => {112 try { ws.close(); } catch { }113 reject(new Error("pairing_transport_timeout"));114 }, 2_000);115 ws.onopen = () => {116 clearTimeout(timer);117 resolve({118 async send(data: Uint8Array) {119 ws.send(data as Uint8Array<ArrayBuffer>);120 },121 receive(): Promise<Uint8Array> {122 if (closed) return Promise.reject(new Error("closed"));123 const v = inbox.shift();124 if (v) return Promise.resolve(v);125 return new Promise<Uint8Array>((res) => waiters.push(res));126 },127 async close() {128 try { ws.close(); } catch { }129 },130 });131 };132 ws.onmessage = (ev) => {133 if (!(ev.data instanceof ArrayBuffer)) return;134 const bytes = new Uint8Array(ev.data);135 const w = waiters.shift();136 if (w) w(bytes); else inbox.push(bytes);137 };138 ws.onerror = () => {139 clearTimeout(timer);140 reject(new Error("pairing_transport_error"));141 };142 ws.onclose = () => {143 closed = true;144 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));145 };146 });147 },148 };149150 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";151 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;152153 try {154 await runChirpPairing(deps, role);155 await refresh();156 } catch (err) {157 if (err instanceof ChirpPairingFailure) {158 159 160 161 return;162 }163 setError((err as Error).message);164 setMode("rest");165 }166 }167168 async function handleUnpair(deviceId: string): Promise<void> {169 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });170 setConfirmUnpair(null);171 await refresh();172 }173174 function relativeTime(when: number | undefined): string {175 if (!when) return "Not synced yet";176 const elapsed = Date.now() - when;177 if (elapsed < 60_000) return "Just now";178 const minutes = Math.floor(elapsed / 60_000);179 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;180 const hours = Math.floor(minutes / 60);181 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;182 const days = Math.floor(hours / 24);183 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;184 return "Not seen recently";185 }186187 function isStale(d: PairedDevice): boolean {188 if (d.lastSyncedAt === undefined) return false;189 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;190 }191192 193194 const proGateView = () => (195 <div class="peach-chirp-settings peach-chirp-settings--gated">196 <header class="peach-chirp-settings-header">197 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>198 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">199 <path d="M11 4 L5 9 L11 14" />200 </svg>201 </button>202 <h1 class="peach-chirp-settings-title">Sync</h1>203 </header>204 <div class="peach-chirp-pro-card">205 <div class="peach-chirp-pro-illustration">206 <PeachMark class="peach-chirp-pro-mark" />207 <svg class="peach-chirp-pro-waves" width="200" height="80" viewBox="0 0 200 80" fill="none" stroke="currentColor" stroke-width="1.4">208 <path d="M 30 40 Q 100 10 170 40" />209 <path d="M 30 50 Q 100 20 170 50" opacity="0.6" />210 <path d="M 30 60 Q 100 30 170 60" opacity="0.3" />211 </svg>212 </div>213 <h2 class="peach-chirp-pro-headline">Chirp Sync is a Peach Pro feature</h2>214 <p class="peach-chirp-pro-body">215 Pair your phone with your laptop using a quick inaudible chirp —216 then your vault stays in sync across devices on your local network.217 No QR codes, no relay servers, no cloud accounts.218 </p>219 <button class="peach-btn peach-btn-primary" onClick={() => props.onLearnPro()}>220 Learn more about Peach Pro221 </button>222 </div>223 </div>224 );225226 const restView = () => (227 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">228 <header class="peach-chirp-head">229 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>230 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">231 <path d="M10 3 L5 8 L10 13" />232 </svg>233 </button>234 <div class="peach-chirp-title">Sync</div>235 <div class="peach-chirp-head-spacer" />236 </header>237238 <div class="peach-chirp-chip-row">239 <div class="peach-chirp-chip" data-phase="idle">240 <span class="peach-chirp-chip-dot" />241 <span class="peach-chirp-chip-text">ready</span>242 </div>243 </div>244245 <Show when={error()}>246 <div class="peach-chirp-settings-error" role="alert">{error()}</div>247 </Show>248249 <div class="peach-chirp-stage act-idle" aria-hidden="true">250 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">251 <DesktopBird pose="idle" />252 </div>253 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">254 <MobileBird pose="hidden" />255 </div>256 </div>257258 <div class="peach-chirp-copy-host">259 <div class="peach-chirp-copy peach-chirp-copy-rest">260 <div class="peach-chirp-headline">261 {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}262 </div>263 <div class="peach-chirp-sub">264 {devices().length === 0265 ? "Peach pairs over a short, inaudible chirp — no codes, no servers."266 : "Pair another device, or manage the ones already connected below."}267 </div>268 </div>269 </div>270271 <div class="peach-chirp-action">272 <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>273 <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">274 <path d="M3 9 L9 3 L15 9" />275 <path d="M3 13 L9 7 L15 13" opacity="0.5" />276 </svg>277 <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>278 </button>279 </div>280281 <Show when={devices().length > 0}>282 <section class="peach-chirp-paired-section">283 <h2 class="peach-chirp-paired-heading">Paired devices</h2>284 <ul class="peach-chirp-device-list">285 <For each={devices()}>286 {(d) => (287 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>288 <DeviceTypeIcon type={d.deviceType ?? "extension"} />289 <div class="peach-chirp-device-info">290 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>291 <span class="peach-chirp-device-meta">292 {relativeTime(d.lastSyncedAt)}293 <Show when={d.trustSource === "vouched"}>294 <span class="peach-chirp-device-badge">Added automatically</span>295 </Show>296 <Show when={isStale(d)}>297 <span class="peach-chirp-device-stale">Not seen recently</span>298 </Show>299 </span>300 </div>301 <Show302 when={confirmUnpair() !== d.deviceId}303 fallback={304 <div class="peach-chirp-unpair-confirm">305 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>306 Cancel307 </button>308 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>309 Remove310 </button>311 </div>312 }313 >314 <button315 class="peach-icon-btn peach-chirp-device-remove"316 aria-label={`Unpair ${d.deviceName}`}317 onClick={() => setConfirmUnpair(d.deviceId)}318 >319 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">320 <path d="M3 3 L11 11 M11 3 L3 11" />321 </svg>322 </button>323 </Show>324 </li>325 )}326 </For>327 </ul>328 </section>329 </Show>330331 <footer class="peach-chirp-foot">332 <div class="peach-chirp-foot-divider" />333 <div class="peach-chirp-foot-text">334 Chirp Sync stays on your local network. No relay servers, no cloud335 accounts — just your devices in the same room.336 </div>337 </footer>338 </div>339 );340341 const pairingView = () => (342 <ChirpPairingFlow343 progress={pairing()}344 onCancel={() => setMode("rest")}345 onRetry={() => void handleStartPairing()}346 onDismiss={() => {347 setMode("rest");348 void refresh();349 }}350 />351 );352353 const currentMode = createMemo(() => {354 if (!props.isPro) return "gated";355 if (mode() === "pairing") return "pairing";356 return "rest";357 });358359 return (360 <Show361 when={currentMode() === "gated"}362 fallback={363 <Show when={currentMode() === "pairing"} fallback={restView()}>364 {pairingView()}365 </Show>366 }367 >368 {proGateView()}369 </Show>370 );371};372373const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {374 return (375 <div class="peach-chirp-device-icon" data-type={props.type}>376 <Show when={props.type === "android" || props.type === "ios"}>377 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">378 <rect x="6" y="2" width="8" height="16" rx="1.5" />379 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />380 </svg>381 </Show>382 <Show when={props.type === "extension" || props.type === "desktop"}>383 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">384 <rect x="2" y="4" width="16" height="10" rx="1.4" />385 <path d="M7 17 L13 17" />386 <path d="M9 14 L9 17 M11 14 L11 17" />387 </svg>388 </Show>389 </div>390 );391};392