Spaces:
Running
Running
| // Bridge to the Tauri overlay shell. The overlay window is click-through by | |
| // default; Rust polls the global cursor and re-enables events only while it's | |
| // inside one of these rects. The frontend's job: keep Rust's rect list fresh. | |
| // Everything no-ops outside Tauri (sim in a browser, the Space). | |
| interface HitRect { | |
| x: number; | |
| y: number; | |
| w: number; | |
| h: number; | |
| } | |
| const HIT_SELECTORS = [ | |
| ".puck", | |
| ".bubble", | |
| ".toasts", | |
| ".comp", | |
| ".drop", | |
| ".settings", | |
| ".interrupt-card", | |
| ".bloom-card", | |
| // dev-only: vite's error overlay must be clickable or it's undismissable | |
| // (anything interactive but unlisted = clicks sail through to apps below) | |
| "vite-error-overlay", | |
| ].join(", "); | |
| const PAD = 8; // forgiving edges — a near-miss poke should still land | |
| export function inTauri(): boolean { | |
| return "__TAURI_INTERNALS__" in window; | |
| } | |
| // Best-effort event-source → macOS app name. Imperfect (Claude Code lives in a | |
| // terminal, "browser" could be any of several) but right for the common cases; | |
| // easy to refine once we can read the real frontmost app per event. | |
| const SOURCE_APP: Record<string, string> = { | |
| Claude: "Claude", | |
| Build: "Terminal", | |
| Git: "Terminal", | |
| Mail: "Mail", | |
| Discord: "Discord", | |
| Calendar: "Calendar", | |
| Browser: "Safari", | |
| }; | |
| export interface Region { | |
| x: number; | |
| y: number; | |
| w: number; | |
| h: number; | |
| } | |
| /** What a peek yields: the line Puck says + how the patch made him feel (drives the | |
| * sprite gesture). `emotion` is a PeekEmotion string; the engine coerces it. */ | |
| export interface PeekResult { | |
| quip: string; | |
| emotion: string; | |
| } | |
| /** Companion loop: Puck peeks at a small region (given in LOGICAL/CSS px — we | |
| * scale to physical here) and Rust returns the daemon's `{quip, emotion}`. Image | |
| * stays native-side; only the short result crosses the IPC. Null outside Tauri or | |
| * on failure (Screen Recording not granted, vision down). */ | |
| export async function peekNative( | |
| region: Region, | |
| fairyState: { mischief: number; mood: string }, | |
| ): Promise<PeekResult | null> { | |
| if (!inTauri()) return null; | |
| const dpr = window.devicePixelRatio || 1; | |
| try { | |
| const { invoke } = await import("@tauri-apps/api/core"); | |
| const json = await invoke<string>("peek", { | |
| x: Math.round(region.x * dpr), | |
| y: Math.round(region.y * dpr), | |
| w: Math.round(region.w * dpr), | |
| h: Math.round(region.h * dpr), | |
| fairyState, | |
| }); | |
| const r = JSON.parse(json) as { quip?: string; emotion?: string }; | |
| return r.quip ? { quip: r.quip, emotion: r.emotion ?? "curious" } : null; | |
| } catch (e) { | |
| console.error("puck: native peek failed (grant Screen Recording?)", e); | |
| return null; | |
| } | |
| } | |
| /** Make the overlay the key window so you can type into it (chat). The borderless | |
| * always-on-top overlay never becomes key on its own. No-op outside Tauri. */ | |
| export async function focusWindow(): Promise<void> { | |
| if (!inTauri()) return; | |
| try { | |
| const { invoke } = await import("@tauri-apps/api/core"); | |
| await invoke("focus_window"); | |
| } catch (e) { | |
| console.error("puck: focus_window failed", e); | |
| } | |
| } | |
| /** Bring the app a notification is about to the front (the "tap to go there"). */ | |
| export async function focusApp(source: string): Promise<void> { | |
| if (!inTauri()) return; | |
| const app = SOURCE_APP[source]; | |
| if (!app) return; | |
| try { | |
| const { invoke } = await import("@tauri-apps/api/core"); | |
| await invoke("focus_app", { app }); | |
| } catch (e) { | |
| console.error("puck: focus_app failed", e); | |
| } | |
| } | |
| /** Native "look around": Rust captures the real screen and posts it to the daemon | |
| * in the background, returning at once (the ~20s vision inference must not block | |
| * — perceived events arrive via the normal poll). Resolves true once the capture | |
| * is dispatched, false on failure (e.g. Screen Recording not granted). The image | |
| * never crosses the IPC bridge (multi-MB through it crashed WKWebView). */ | |
| export async function lookAroundNative(fairyState: { mischief: number; mood: string }): Promise<boolean> { | |
| if (!inTauri()) return false; | |
| try { | |
| const { invoke } = await import("@tauri-apps/api/core"); | |
| await invoke("look_around", { fairyState }); | |
| return true; | |
| } catch (e) { | |
| console.error("puck: native look-around failed (grant Screen Recording?)", e); | |
| return false; | |
| } | |
| } | |
| /** Poll interactive element rects and report them to the Rust cursor poller. */ | |
| export function startHitRectReporter(): () => void { | |
| if (!inTauri()) return () => {}; | |
| let last = ""; | |
| const iv = setInterval(async () => { | |
| const rects: HitRect[] = [...document.querySelectorAll(HIT_SELECTORS)].map((el) => { | |
| const r = el.getBoundingClientRect(); | |
| return { | |
| x: Math.round(r.x - PAD), | |
| y: Math.round(r.y - PAD), | |
| w: Math.round(r.width + PAD * 2), | |
| h: Math.round(r.height + PAD * 2), | |
| }; | |
| }); | |
| const key = JSON.stringify(rects); | |
| if (key === last) return; // don't spam IPC when nothing moved | |
| last = key; | |
| try { | |
| const { invoke } = await import("@tauri-apps/api/core"); | |
| await invoke("set_hit_rects", { rects }); | |
| } catch (e) { | |
| console.error("puck: hit-rect report failed", e); | |
| } | |
| }, 150); | |
| return () => clearInterval(iv); | |
| } | |