puck / frontend /src /lib /tauri.ts
vu1n's picture
Puck — desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
5.22 kB
// 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);
}