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);61 62 63 64 const [sasCode, setSasCode] = createSignal<string | null>(null);65 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);6667 function resolveSAS(ok: boolean) {68 const r = sasResolve();69 setSasCode(null);70 setSasResolve(null);71 r?.(ok);72 }7374 async function handleSyncNow(): Promise<void> {75 setSyncStatus("syncing");76 setSyncFlash(null);77 const res = await sendMessage({78 type: "TRIGGER_SYNC",79 payload: { reason: "manual", force: true },80 }) as { ok: boolean; error?: string };81 82 83 setTimeout(async () => {84 const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {85 ok: boolean;86 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };87 };88 const s = state.data;89 if (!s) return;90 setSyncStatus(s.status);91 if (s.status === "idle") {92 setSyncFlash(93 (s.entriesReceived ?? 0) > 094 ? `Synced — ${s.entriesReceived} entries updated`95 : "Vault up to date",96 );97 setTimeout(() => setSyncFlash(null), 3000);98 await refresh();99 } else if (s.status === "error") {100 setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);101 setTimeout(() => setSyncFlash(null), 4000);102 }103 }, 250);104 if (!res.ok) {105 setSyncStatus("error");106 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);107 setTimeout(() => setSyncFlash(null), 4000);108 }109 }110111 onMount(() => {112 void refresh();113 });114115 async function refresh(): Promise<void> {116 if (!props.isPro) return;117 const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });118 const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;119 setDevices(data?.devices ?? []);120 }121122 async function handleStartPairing(): Promise<void> {123 setError(null);124125 126 127 128 129 if (typeof chrome !== "undefined" && chrome.permissions?.request) {130 const granted = await chrome.permissions.request({131 permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],132 });133 if (!granted) {134 setPairing({ phase: "mic_denied" });135 setMode("pairing");136 return;137 }138 }139140 setPairing({ phase: "idle" });141 setMode("pairing");142143 const deps: ChirpPairingDeps = {144 onProgress: (p) => setPairing(p),145 fetchLocalIdentity: async () => {146 const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });147 const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };148 if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");149 150 return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;151 },152 completePairing: async (paired) => {153 const res = await sendMessage({154 type: "CHIRP_COMPLETE_PAIRING",155 payload: { paired },156 });157 const r = res as { ok: boolean; error?: string };158 if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");159 },160 confirmSAS: (sas) =>161 new Promise<boolean>((resolve) => {162 setSasResolve(() => resolve);163 setSasCode(sas);164 }),165 getLocalSubnet,166 openPairingTransport: async (endpoint) => {167 168 169 170 return new Promise((resolve, reject) => {171 let ws: WebSocket;172 try {173 ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);174 } catch (err) {175 reject(err);176 return;177 }178 const inbox: Uint8Array[] = [];179 const waiters: Array<(b: Uint8Array) => void> = [];180 let closed = false;181 ws.binaryType = "arraybuffer";182 const timer = setTimeout(() => {183 try { ws.close(); } catch { }184 reject(new Error("pairing_transport_timeout"));185 }, 2_000);186 ws.onopen = () => {187 clearTimeout(timer);188 resolve({189 async send(data: Uint8Array) {190 ws.send(data as Uint8Array<ArrayBuffer>);191 },192 receive(): Promise<Uint8Array> {193 if (closed) return Promise.reject(new Error("closed"));194 const v = inbox.shift();195 if (v) return Promise.resolve(v);196 return new Promise<Uint8Array>((res) => waiters.push(res));197 },198 async close() {199 try { ws.close(); } catch { }200 },201 });202 };203 ws.onmessage = (ev) => {204 if (!(ev.data instanceof ArrayBuffer)) return;205 const bytes = new Uint8Array(ev.data);206 const w = waiters.shift();207 if (w) w(bytes); else inbox.push(bytes);208 };209 ws.onerror = () => {210 clearTimeout(timer);211 reject(new Error("pairing_transport_error"));212 };213 ws.onclose = () => {214 closed = true;215 while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));216 };217 });218 },219 };220221 const role: PairingRole = isNativePlatform() ? "initiator" : "responder";222 if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;223224 try {225 await runChirpPairing(deps, role);226 await refresh();227 } catch (err) {228 if (err instanceof ChirpPairingFailure) {229 230 231 232 return;233 }234 setError((err as Error).message);235 setMode("rest");236 }237 }238239 async function handleUnpair(deviceId: string): Promise<void> {240 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });241 setConfirmUnpair(null);242 await refresh();243 }244245 function relativeTime(when: number | undefined): string {246 if (!when) return "Not synced yet";247 const elapsed = Date.now() - when;248 if (elapsed < 60_000) return "Just now";249 const minutes = Math.floor(elapsed / 60_000);250 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;251 const hours = Math.floor(minutes / 60);252 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;253 const days = Math.floor(hours / 24);254 if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;255 return "Not seen recently";256 }257258 function isStale(d: PairedDevice): boolean {259 if (d.lastSyncedAt === undefined) return false;260 return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;261 }262263 264265 const proGateView = () => (266 <div class="peach-chirp-settings peach-chirp-settings--gated">267 <header class="peach-chirp-settings-header">268 <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>269 <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">270 <path d="M11 4 L5 9 L11 14" />271 </svg>272 </button>273 <h1 class="peach-chirp-settings-title">Sync</h1>274 </header>275 <div class="peach-chirp-pro-card">276 <div class="peach-chirp-pro-stage" aria-hidden="true">277 <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"278 stroke-linecap="round" stroke-linejoin="round">279 <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />280 <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />281 <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />282 </svg>283 <div class="peach-chirp-pro-bird-slot">284 <MobileBird pose="idle" />285 </div>286 </div>287 <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>288 <p class="peach-chirp-pro-body">289 Pairs over an inaudible chirp. Syncs locally across your devices.290 No codes, no servers, no cloud.291 </p>292 <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>293 Unlock with Peach Pro294 </button>295 <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>296 </div>297 </div>298 );299300 const restView = () => (301 <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">302 <header class="peach-chirp-head">303 <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>304 <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">305 <path d="M10 3 L5 8 L10 13" />306 </svg>307 </button>308 <div class="peach-chirp-title">Sync</div>309 <div class="peach-chirp-head-spacer" />310 </header>311312 <div class="peach-chirp-chip-row">313 <div class="peach-chirp-chip" data-phase="idle">314 <span class="peach-chirp-chip-dot" />315 <span class="peach-chirp-chip-text">ready</span>316 </div>317 </div>318319 <Show when={error()}>320 <div class="peach-chirp-settings-error" role="alert">{error()}</div>321 </Show>322323 <div class="peach-chirp-stage act-idle" aria-hidden="true">324 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">325 <DesktopBird pose="idle" />326 </div>327 <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">328 <MobileBird pose="hidden" />329 </div>330 </div>331332 <div class="peach-chirp-copy-host">333 <div class="peach-chirp-copy peach-chirp-copy-rest">334 <div class="peach-chirp-headline">335 {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}336 </div>337 <div class="peach-chirp-sub">338 {devices().length === 0339 ? "Peach pairs over a short, inaudible chirp — no codes, no servers."340 : "Pair another device, or manage the ones already connected below."}341 </div>342 </div>343 </div>344345 <Show when={devices().length > 0}>346 <section class="peach-chirp-paired-section">347 <h2 class="peach-chirp-paired-heading">Paired devices</h2>348 <ul class="peach-chirp-device-list">349 <For each={devices()}>350 {(d) => (351 <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>352 <DeviceTypeIcon type={d.deviceType ?? "extension"} />353 <div class="peach-chirp-device-info">354 <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>355 <span class="peach-chirp-device-meta">356 {relativeTime(d.lastSyncedAt)}357 <Show when={d.trustSource === "vouched"}>358 <span class="peach-chirp-device-badge">Added automatically</span>359 </Show>360 <Show when={isStale(d)}>361 <span class="peach-chirp-device-stale">Not seen recently</span>362 </Show>363 </span>364 </div>365 <Show366 when={confirmUnpair() !== d.deviceId}367 fallback={368 <div class="peach-chirp-unpair-confirm">369 <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>370 Cancel371 </button>372 <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>373 Remove374 </button>375 </div>376 }377 >378 <button379 class="peach-icon-btn peach-chirp-device-remove"380 aria-label={`Unpair ${d.deviceName}`}381 onClick={() => setConfirmUnpair(d.deviceId)}382 >383 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">384 <path d="M3 3 L11 11 M11 3 L3 11" />385 </svg>386 </button>387 </Show>388 </li>389 )}390 </For>391 </ul>392 </section>393 </Show>394395 <Show when={devices().length > 0}>396 <div class="peach-chirp-sync-now-row">397 <button398 class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"399 disabled={syncStatus() === "syncing"}400 onClick={() => void handleSyncNow()}401 >402 {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}403 </button>404 <Show when={syncFlash()}>405 <span class="peach-chirp-sync-flash" aria-live="polite">406 {syncFlash()}407 </span>408 </Show>409 </div>410 </Show>411412 <div class="peach-chirp-action">413 <p class="peach-chirp-mic-notice">414 Chirp uses your microphone to pair. Your browser will ask for permission.415 </p>416 <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>417 <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">418 <path d="M3 9 L9 3 L15 9" />419 <path d="M3 13 L9 7 L15 13" opacity="0.5" />420 </svg>421 <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>422 </button>423 </div>424425 </div>426 );427428 const pairingView = () => (429 <Show430 when={sasCode()}431 fallback={432 <ChirpPairingFlow433 progress={pairing()}434 onCancel={() => setMode("rest")}435 onRetry={() => void handleStartPairing()}436 onDismiss={() => {437 setMode("rest");438 void refresh();439 }}440 />441 }442 >443 {444}445 <div446 class="chirp-sas-verify"447 style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"448 >449 <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>450 <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">451 Confirm this code matches the one shown on your other device before pairing.452 </div>453 <div style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;">454 {sasCode()}455 </div>456 <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">457 <button458 class="peach-btn peach-btn--accent peach-btn--md"459 type="button"460 onClick={() => resolveSAS(true)}461 >462 codes match — pair463 </button>464 <button465 class="peach-btn peach-btn--ghost peach-btn--md"466 type="button"467 onClick={() => resolveSAS(false)}468 >469 they don't match470 </button>471 </div>472 </div>473 </Show>474 );475476 const currentMode = createMemo(() => {477 if (!props.isPro) return "gated";478 if (mode() === "pairing") return "pairing";479 return "rest";480 });481482 return (483 <Show484 when={currentMode() === "gated"}485 fallback={486 <Show when={currentMode() === "pairing"} fallback={restView()}>487 {pairingView()}488 </Show>489 }490 >491 {proGateView()}492 </Show>493 );494};495496const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {497 return (498 <div class="peach-chirp-device-icon" data-type={props.type}>499 <Show when={props.type === "android" || props.type === "ios"}>500 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">501 <rect x="6" y="2" width="8" height="16" rx="1.5" />502 <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />503 </svg>504 </Show>505 <Show when={props.type === "extension" || props.type === "desktop"}>506 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">507 <rect x="2" y="4" width="16" height="10" rx="1.4" />508 <path d="M7 17 L13 17" />509 <path d="M9 14 L9 17 M11 14 L11 17" />510 </svg>511 </Show>512 </div>513 );514};