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);59 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");60 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);6162 async function handleSyncNow(): Promise<void> {63 setSyncStatus("syncing");64 setSyncFlash(null);65 const res = await sendMessage({66 type: "TRIGGER_SYNC",67 payload: { reason: "manual", force: true },68 }) as { ok: boolean; error?: string };69 70 71 setTimeout(async () => {72 const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {73 ok: boolean;74 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };75 };76 const s = state.data;77 if (!s) return;78 setSyncStatus(s.status);79 if (s.status === "idle") {80 setSyncFlash(81 (s.entriesReceived ?? 0) > 082 ? `Synced — ${s.entriesReceived} entries updated`83 : "Vault up to date",84 );85 setTimeout(() => setSyncFlash(null), 3000);86 await refresh();87 } else if (s.status === "error") {88 setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);89 setTimeout(() => setSyncFlash(null), 4000);90 }91 }, 250);92 if (!res.ok) {93 setSyncStatus("error");94 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);95 setTimeout(() => setSyncFlash(null), 4000);96 }97 }9899 onMount(() => {100 void refresh();101 });102103 async function refresh(): Promise<void> {104 if (!props.isPro) return;105 const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });106 const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;107 setDevices(data?.devices ?? []);108 }109110 async function handleStartPairing(): Promise<void> {111 setError(null);112113 114 115 116 117 if (typeof chrome !== "undefined" && chrome.permissions?.request) {118 const granted = await chrome.permissions.request({119 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],120 });121 if (!granted) {122 setPairing({ phase: "mic_denied" });123 setMode("pairing");124 return;125 }126 }127128 setPairing({ phase: "idle" });129 setMode("pairing");130131 const deps: ChirpPairingDeps = {132 onProgress: (p) => setPairing(p),133 fetchLocalIdentity: async () => {134 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });135 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };136 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");137 138 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;139 },140 completePairing: async (paired) => {141 const res = await sendMessage({142 type: "CHIRP_COMPLETE_PAIRING",143 payload: { paired },144 });145 const r = res as { ok: boolean; error?: string };146 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");147 },148 getLocalSubnet,149 openPairingTransport: async (endpoint) => {150 151 152 153 return new Promise((resolve, reject) => {154 let ws: WebSocket;155 try {156 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);157 } catch (err) {158 reject(err);159 return;160 }161 const inbox: Uint8Array[] = [];162 const waiters: Array<(b: Uint8Array) => void> = [];163 let closed = false;164 ws.binaryType = "arraybuffer";165 const timer = setTimeout(() => {166 try { ws.close(); } catch { }167 reject(new Error("pairing_transport_timeout"));168 }, 2_000);169 ws.onopen = () => {170 clearTimeout(timer);171 resolve({172 async send(data: Uint8Array) {173 ws.send(data as Uint8Array<ArrayBuffer>);174 },175 receive(): Promise<Uint8Array> {176 if (closed) return Promise.reject(new Error("closed"));177 const v = inbox.shift();178 if (v) return Promise.resolve(v);179 return new Promise<Uint8Array>((res) => waiters.push(res));180 },181 async close() {182 try { ws.close(); } catch { }183 },184 });185 };186 ws.onmessage = (ev) => {187 if (!(ev.data instanceof ArrayBuffer)) return;188 const bytes = new Uint8Array(ev.data);189 const w = waiters.shift();190 if (w) w(bytes); else inbox.push(bytes);191 };192 ws.onerror = () => {193 clearTimeout(timer);194 reject(new Error("pairing_transport_error"));195 };196 ws.onclose = () => {197 closed = true;198 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));199 };200 });201 },202 };203204 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";205 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;206207 try {208 await runChirpPairing(deps, role);209 await refresh();210 } catch (err) {211 if (err instanceof ChirpPairingFailure) {212 213 214 215 return;216 }217 setError((err as Error).message);218 setMode("rest");219 }220 }221222 async function handleUnpair(deviceId: string): Promise<void> {223 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });224 setConfirmUnpair(null);225 await refresh();226 }227228 function relativeTime(when: number | undefined): string {229 if (!when) return "Not synced yet";230 const elapsed = Date.now() - when;231 if (elapsed < 60_000) return "Just now";232 const minutes = Math.floor(elapsed / 60_000);233 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;234 const hours = Math.floor(minutes / 60);235 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;236 const days = Math.floor(hours / 24);237 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;238 return "Not seen recently";239 }240241 function isStale(d: PairedDevice): boolean {242 if (d.lastSyncedAt === undefined) return false;243 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;244 }245246 247248 const proGateView = () => (249 <div class="peach-chirp-settings peach-chirp-settings--gated">250 <header class="peach-chirp-settings-header">251 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>252 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">253 <path d="M11 4 L5 9 L11 14" />254 </svg>255 </button>256 <h1 class="peach-chirp-settings-title">Sync</h1>257 </header>258 <div class="peach-chirp-pro-card">259 <div class="peach-chirp-pro-stage" aria-hidden="true">260 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"261 stroke-linecap="round" stroke-linejoin="round">262 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />263 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />264 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />265 </svg>266 <div class="peach-chirp-pro-bird-slot">267 <MobileBird pose="idle" />268 </div>269 </div>270 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>271 <p class="peach-chirp-pro-body">272 Pairs over an inaudible chirp. Syncs locally across your devices.273 No codes, no servers, no cloud.274 </p>275 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>276 Unlock with Peach Pro277 </button>278 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>279 </div>280 </div>281 );282283 const restView = () => (284 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">285 <header class="peach-chirp-head">286 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>287 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">288 <path d="M10 3 L5 8 L10 13" />289 </svg>290 </button>291 <div class="peach-chirp-title">Sync</div>292 <div class="peach-chirp-head-spacer" />293 </header>294295 <div class="peach-chirp-chip-row">296 <div class="peach-chirp-chip" data-phase="idle">297 <span class="peach-chirp-chip-dot" />298 <span class="peach-chirp-chip-text">ready</span>299 </div>300 </div>301302 <Show when={error()}>303 <div class="peach-chirp-settings-error" role="alert">{error()}</div>304 </Show>305306 <div class="peach-chirp-stage act-idle" aria-hidden="true">307 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">308 <DesktopBird pose="idle" />309 </div>310 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">311 <MobileBird pose="hidden" />312 </div>313 </div>314315 <div class="peach-chirp-copy-host">316 <div class="peach-chirp-copy peach-chirp-copy-rest">317 <div class="peach-chirp-headline">318 {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}319 </div>320 <div class="peach-chirp-sub">321 {devices().length === 0322 ? "Peach pairs over a short, inaudible chirp — no codes, no servers."323 : "Pair another device, or manage the ones already connected below."}324 </div>325 </div>326 </div>327328 <Show when={devices().length > 0}>329 <section class="peach-chirp-paired-section">330 <h2 class="peach-chirp-paired-heading">Paired devices</h2>331 <ul class="peach-chirp-device-list">332 <For each={devices()}>333 {(d) => (334 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>335 <DeviceTypeIcon type={d.deviceType ?? "extension"} />336 <div class="peach-chirp-device-info">337 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>338 <span class="peach-chirp-device-meta">339 {relativeTime(d.lastSyncedAt)}340 <Show when={d.trustSource === "vouched"}>341 <span class="peach-chirp-device-badge">Added automatically</span>342 </Show>343 <Show when={isStale(d)}>344 <span class="peach-chirp-device-stale">Not seen recently</span>345 </Show>346 </span>347 </div>348 <Show349 when={confirmUnpair() !== d.deviceId}350 fallback={351 <div class="peach-chirp-unpair-confirm">352 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>353 Cancel354 </button>355 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>356 Remove357 </button>358 </div>359 }360 >361 <button362 class="peach-icon-btn peach-chirp-device-remove"363 aria-label={`Unpair ${d.deviceName}`}364 onClick={() => setConfirmUnpair(d.deviceId)}365 >366 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">367 <path d="M3 3 L11 11 M11 3 L3 11" />368 </svg>369 </button>370 </Show>371 </li>372 )}373 </For>374 </ul>375 </section>376 </Show>377378 <Show when={devices().length > 0}>379 <div class="peach-chirp-sync-now-row">380 <button381 class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"382 disabled={syncStatus() === "syncing"}383 onClick={() => void handleSyncNow()}384 >385 {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}386 </button>387 <Show when={syncFlash()}>388 <span class="peach-chirp-sync-flash" aria-live="polite">389 {syncFlash()}390 </span>391 </Show>392 </div>393 </Show>394395 <div class="peach-chirp-action">396 <p class="peach-chirp-mic-notice">397 Chirp uses your microphone to pair. Your browser will ask for permission.398 </p>399 <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>400 <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">401 <path d="M3 9 L9 3 L15 9" />402 <path d="M3 13 L9 7 L15 13" opacity="0.5" />403 </svg>404 <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>405 </button>406 </div>407408 </div>409 );410411 const pairingView = () => (412 <ChirpPairingFlow413 progress={pairing()}414 onCancel={() => setMode("rest")}415 onRetry={() => void handleStartPairing()}416 onDismiss={() => {417 setMode("rest");418 void refresh();419 }}420 />421 );422423 const currentMode = createMemo(() => {424 if (!props.isPro) return "gated";425 if (mode() === "pairing") return "pairing";426 return "rest";427 });428429 return (430 <Show431 when={currentMode() === "gated"}432 fallback={433 <Show when={currentMode() === "pairing"} fallback={restView()}>434 {pairingView()}435 </Show>436 }437 >438 {proGateView()}439 </Show>440 );441};442443const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {444 return (445 <div class="peach-chirp-device-icon" data-type={props.type}>446 <Show when={props.type === "android" || props.type === "ios"}>447 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">448 <rect x="6" y="2" width="8" height="16" rx="1.5" />449 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />450 </svg>451 </Show>452 <Show when={props.type === "extension" || props.type === "desktop"}>453 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">454 <rect x="2" y="4" width="16" height="10" rx="1.4" />455 <path d="M7 17 L13 17" />456 <path d="M9 14 L9 17 M11 14 L11 17" />457 </svg>458 </Show>459 </div>460 );461};