Extract familiar window mouse interop seam

This commit is contained in:
OpenPets Dev 2026-06-19 05:58:30 +00:00
parent 29bf11afae
commit 9e75151e56
6 changed files with 747 additions and 392 deletions

View file

@ -74,6 +74,8 @@ const familiarInstallationSource = readFileSync(join(appDir, "src", "familiar-in
const familiarInstallationArchiveSource = readFileSync(join(appDir, "src", "familiar-installation-archive.ts"), "utf8");
const petWindowContentSource = readFileSync(join(appDir, "src", "familiar-window-content.ts"), "utf8");
const petWindowInteractionSource = readFileSync(join(appDir, "src", "familiar-window-interactions.ts"), "utf8");
const petWindowMouseInteropSource = readFileSync(join(appDir, "src", "familiar-window-mouse-interop.ts"), "utf8");
const petWindowMouseInteropSupportSource = readFileSync(join(appDir, "src", "familiar-window-mouse-interop-support.ts"), "utf8");
const petWindowPluginMenuSource = readFileSync(join(appDir, "src", "familiar-window-plugin-menu.ts"), "utf8");
const petWindowRendererBridgeSource = readFileSync(join(appDir, "src", "familiar-window-renderer-bridge.ts"), "utf8");
const petWindowRenderSource = readFileSync(join(appDir, "src", "familiar-window-render.ts"), "utf8");
@ -240,7 +242,7 @@ const controlCenterSettingsCombinedSource = [
].join("\n");
const petWindowRenderCombinedSource = `${petWindowSource}\n${petWindowRenderSource}\n${petWindowRenderBubblesSource}`;
const petWindowContentCombinedSource = `${petWindowSource}\n${petWindowContentSource}`;
const petWindowRuntimeCombinedSource = `${petWindowSource}\n${petWindowHostSource}\n${petWindowInteractionSource}`;
const petWindowRuntimeCombinedSource = `${petWindowSource}\n${petWindowHostSource}\n${petWindowInteractionSource}\n${petWindowMouseInteropSource}\n${petWindowMouseInteropSupportSource}`;
assert.match(loggerSource, /familiaros\.log/, "desktop logger must write a user-sendable familiaros.log file.");
assert.match(loggerSource, /familiaros\.previous\.log/, "desktop logger must retain a previous log file for bug reports.");
assert.match(loggerSource, /FAMILIAROS_LOG_LEVEL/, "desktop logger must support verbose dev logging via environment.");
@ -288,8 +290,9 @@ assert.match(petWindowSource, /function getTransientReactionAnimationMs/, "finit
assert.match(petWindowSource, /function clearTransientReaction/, "finite reaction animations must be clearable while the bubble remains visible.");
assert.match(petWindowRendererBridgeSource, /sendWindowChannel\(window, "familiaros:familiar-reaction-state"/, "finite reaction animations must clear sprite state without reloading the bubble.");
assert.match(petPreloadSource, /familiaros:familiar-reaction-state/, "familiar preload must accept in-place reaction state updates.");
assert.match(petWindowRuntimeCombinedSource, /const webContents = window\.webContents[\s\S]*?const removeListeners = \(\): void => \{[\s\S]*?if \(!webContents\.isDestroyed\(\)\)/, "familiar window cleanup must capture webContents and avoid touching destroyed BrowserWindow objects.");
assert.match(petWindowRuntimeCombinedSource, /window\.on\("close", removeListeners\);\s*window\.once\("closed", removeListeners\);/, "familiar window cleanup must run before and after close so agent lease release and Cmd/Ctrl+W are idempotent.");
assert.match(petWindowMouseInteropSupportSource, /webContents:\s*window\.webContents/, "mouse interop support must capture webContents once so cleanup can avoid touching a destroyed BrowserWindow.");
assert.match(petWindowMouseInteropSource, /const removeListeners = \(\): void => \{[\s\S]*?if \(!context\.webContents\.isDestroyed\(\)\)/, "mouse interop cleanup must avoid touching destroyed webContents objects.");
assert.match(petWindowMouseInteropSource, /context\.window\.on\("close", removeListeners\);\s*context\.window\.once\("closed", removeListeners\);/, "familiar window cleanup must run before and after close so agent lease release and Cmd/Ctrl+W are idempotent.");
assert.match(displaySource, /width:\s*220/, "familiar windows must stay tightly bounded around familiar and bubble.");
assert.match(displaySource, /height:\s*320/, "familiar windows must be tall enough for adaptive long message bubbles at large familiar scale without becoming a huge click shield.");
assert.match(petWindowRenderCombinedSource, /function getBubbleClassName|export function getBubbleClassName/, "familiar bubbles must classify explicit messages by length.");
@ -321,19 +324,20 @@ assert.match(petWindowPluginMenuSource, /export function installPetContextMenu/,
assert.match(petWindowRendererBridgeSource, /export function sendBubbleLayout/, "renderer bridge helper must export bubble layout IPC.");
assert.match(petWindowRendererBridgeSource, /export function setPetReactionState/, "renderer bridge helper must export reaction IPC.");
assert.match(petWindowRendererBridgeSource, /export function playPetWindowAudio/, "renderer bridge helper must export audio IPC.");
assert.match(petWindowInteractionSource, /export function installMousePassthroughAndDrag/, "interaction helper must export the mouse passthrough seam.");
assert.match(petWindowInteractionSource, /from "\.\/familiar-window-mouse-interop(?:\.js)?"/, "interaction helper must re-export the extracted mouse interop runtime.");
assert.match(petWindowMouseInteropSource, /export function installMousePassthroughAndDrag/, "mouse interop runtime must export the mouse passthrough seam.");
assert.match(petWindowRuntimeCombinedSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent familiar window background must use OS-level mouse passthrough.");
assert.match(petWindowRuntimeCombinedSource, /setIgnoreMouseEvents\(false\)/, "visible familiar and bubble hit targets must re-enable mouse handling.");
assert.match(petWindowRuntimeCombinedSource, /familiaros:familiar-ready/, "familiar windows must resync passthrough after each renderer reload.");
assert.match(petWindowInteractionSource, /export function installMousePassthroughAndDrag[\s\S]*?const rearmPassthrough[\s\S]*?process\.platform !== "win32"[\s\S]*?rearmWindowsMouseForwarding\(reason\)/, "Windows familiar reloads must toggle forwarded mouse passthrough to re-register hover and drag tracking.");
assert.match(petWindowInteractionSource, /scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+75ms`, 75\);[\s\S]*?scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+175ms`, 175\);/, "Windows familiar reloads must retry mouse forwarding rearm after load settles.");
assert.match(petWindowMouseInteropSupportSource, /export function rearmPassthrough[\s\S]*?process\.platform !== "win32"[\s\S]*?rearmWindowsMouseForwarding\(context, reason\)/, "Windows familiar reloads must toggle forwarded mouse passthrough to re-register hover and drag tracking.");
assert.match(petWindowMouseInteropSupportSource, /scheduleWindowsMouseForwardingRearm\(context, `\$\{reason\}\+75ms`, 75\);[\s\S]*?scheduleWindowsMouseForwardingRearm\(context, `\$\{reason\}\+175ms`, 175\);/, "Windows familiar reloads must retry mouse forwarding rearm after load settles.");
assert.match(petWindowRuntimeCombinedSource, /familiaros:familiar-probe-hit-test/, "Windows familiar reloads must probe current cursor hit target when mousemove forwarding is stale.");
assert.match(petWindowInteractionSource, /export function recoverPetMouseInterop/, "familiar windows must expose a controlled mouse interop recovery hook for OS display and resume events.");
assert.match(petWindowInteractionSource, /petMouseInteropRecovery\.set\(window, scheduleMouseInteropRecovery\)/, "familiar windows must register their mouse interop recovery callback.");
assert.match(petWindowInteractionSource, /export function installMousePassthroughAndDrag[\s\S]*?scheduleWindowsForwardingWatch[\s\S]*?rearmWindowsMouseForwarding\(reason, false\)[\s\S]*?scheduleWindowsForwardingWatch\(reason\)/, "Windows familiar passthrough must keep rearming while idle so hover and drag recover after familiar reloads without noisy logs.");
assert.match(petWindowMouseInteropSource, /export function recoverPetMouseInterop/, "familiar windows must expose a controlled mouse interop recovery hook for OS display and resume events.");
assert.match(petWindowMouseInteropSource, /petMouseInteropRecovery\.set\(context\.window, \(reason\) =>[\s\S]*?scheduleMouseInteropRecovery\(context, reason\)/, "familiar windows must register their mouse interop recovery callback.");
assert.match(petWindowMouseInteropSupportSource, /export function scheduleWindowsForwardingWatch[\s\S]*?rearmWindowsMouseForwarding\(context, reason, false\)[\s\S]*?scheduleWindowsForwardingWatch\(context, reason\)/, "Windows familiar passthrough must keep rearming while idle so hover and drag recover after familiar reloads without noisy logs.");
assert.match(petPreloadSource, /familiaros:familiar-probe-hit-test[\s\S]*?elementFromPoint\(clientX, clientY\)[\s\S]*?reportInteractiveHit/, "familiar preload must answer main-process cursor hit-test probes.");
assert.match(petWindowInteractionSource, /did-finish-load", rearmAfterLoad/, "familiar windows must re-arm mouse passthrough after every content load.");
assert.match(petWindowInteractionSource, /did-fail-load", handleLoadFailure/, "familiar windows must restore passthrough after failed content loads.");
assert.match(petWindowMouseInteropSource, /did-finish-load", rearmAfterLoad/, "familiar windows must re-arm mouse passthrough after every content load.");
assert.match(petWindowMouseInteropSource, /did-fail-load", handleLoadFailure/, "familiar windows must restore passthrough after failed content loads.");
assert.match(petWindowHostSource, /window\.setIgnoreMouseEvents\(false\);[\s\S]*?await window\.loadFile/, "familiar reloads must reset OS mouse passthrough before navigation.");
assert.match(petWindowHostSource, /export function allocateWindowLoadSequence/, "familiar content reloads must allocate request sequence before async rendering.");
assert.match(petWindowSource, /tryUpdateLoadedPetContent\(window, render, "default", sequence\)/, "default familiar transient updates must avoid BrowserWindow reloads when the familiar document is already loaded.");

View file

@ -48,7 +48,9 @@ local-ipc.ts → parseIpcRequest() → handleRequest()
familiar-window.ts
├── createDefaultPetWindow() / createAgentPetWindow()
├── familiar-window-host.ts (Electron BrowserWindow shell, navigation allowlist, and serialized HTML load queue)
├── familiar-window-interactions.ts (mouse passthrough, drag/scale IPC, motion-state publishing)
├── familiar-window-interactions.ts (public interaction facade plus motion-state publishing)
├── familiar-window-mouse-interop.ts (mouse passthrough, drag/scale IPC, and recovery listener wiring)
├── familiar-window-mouse-interop-support.ts (context creation, platform passthrough helpers, and hit-test probes)
├── familiar-window-plugin-menu.ts (pet context menu, default-pet plugin commands, plugin command forms)
├── familiar-window-renderer-bridge.ts (bubble layout, reaction, sprite, audio, and TTS renderer IPC helpers)
├── loadDefaultPetContent() / loadExplicitPetContent()
@ -230,7 +232,9 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
- `familiar-window-host.ts`: Extracted Electron familiar window shell, renderer navigation hardening, and serialized HTML reload queue
- `familiar-window-content.ts`: Extracted familiar HTML render builders and installed-familiar fallback logic
- `familiar-window-layout.ts`: Extracted familiar bubble sizing, scroll-cap selection, and base window layout calculations
- `familiar-window-interactions.ts`: Familiar window mouse passthrough/drag handling, scale-preview IPC, mouse interop recovery, and motion-state publishing
- `familiar-window-interactions.ts`: Public familiar-window interaction facade that re-exports mouse interop and owns motion-state publishing
- `familiar-window-mouse-interop.ts`: Extracted familiar mouse passthrough, drag/scale IPC, bubble/prompt event wiring, and mouse interop recovery registration
- `familiar-window-mouse-interop-support.ts`: Extracted familiar mouse interop context, platform-specific passthrough rearm helpers, cursor hit-test probes, and payload guards
- `familiar-window-plugin-menu.ts`: Extracted pet context-menu construction, default familiar plugin command menus, and plugin command form hosting
- `familiar-window-renderer-bridge.ts`: Extracted renderer IPC helpers for bubble layout updates, reaction state, sprite overrides, audio playback, and TTS
- `familiar-window-render.ts`: Shared familiar-window CSS/sprite render shell plus stage markup and bubble seam re-exports

View file

@ -1,372 +1,16 @@
import { BrowserWindow, ipcMain, screen, type IpcMainEvent } from "electron";
import { BrowserWindow } from "electron";
import type { PetScaleValue } from "./app-state.js";
import type { Point } from "./display.js";
import { debug } from "./logger.js";
import type { PetMotionState } from "./reaction-animation-mapping.js";
export interface PetWindowInteractionHooks {
readonly onBubbleDismissed?: (dismissToken: string) => void;
readonly onBubbleAction?: (dismissToken: string, actionId: string) => void;
readonly onBubbleSubmit?: (dismissToken: string, values: Record<string, string | number>) => void;
readonly onPetEvent?: (name: string, payload: Record<string, unknown>) => void;
readonly onPromptRequested?: () => void;
readonly onScaleChanged?: (scale: PetScaleValue) => void;
}
interface PetWindowInteractionDeps {
readonly previewScale: (window: BrowserWindow, scale: PetScaleValue) => void;
readonly readWindowPosition: (window: BrowserWindow) => Point;
}
const petMouseInteropRecovery = new WeakMap<BrowserWindow, (reason: string) => void>();
const petWindowDragging = new WeakMap<BrowserWindow, boolean>();
const petWindowScaling = new WeakMap<BrowserWindow, boolean>();
export function isPetWindowDragging(window: BrowserWindow): boolean {
return petWindowDragging.get(window) === true;
}
export function isPetWindowScaling(window: BrowserWindow): boolean {
return petWindowScaling.get(window) === true;
}
export function recoverPetMouseInterop(window: BrowserWindow, reason: string): void {
if (window.isDestroyed()) return;
const recover = petMouseInteropRecovery.get(window);
if (recover) {
recover(reason);
return;
}
debug("familiar.window", "mouse interop recovery skipped", { windowId: window.id, reason, skippedReason: "unregistered-window" });
}
export function installMousePassthroughAndDrag(window: BrowserWindow, hooks: PetWindowInteractionHooks = {}, deps: PetWindowInteractionDeps): void {
const { onBubbleDismissed, onBubbleAction, onBubbleSubmit, onPetEvent, onPromptRequested, onScaleChanged } = hooks;
const { previewScale, readWindowPosition } = deps;
let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number; readonly width: number; readonly height: number } | null = null;
let rendererReady = false;
let listenersRemoved = false;
let lastInteractive = false;
let forwardingWatchTimer: NodeJS.Timeout | null = null;
const rearmTimers = new Set<NodeJS.Timeout>();
const windowId = window.id;
const webContents = window.webContents;
const canForwardMouseEvents = process.platform === "darwin" || process.platform === "win32";
const scheduleMouseInteropRecovery = (reason: string): void => {
if (window.isDestroyed()) return;
dragging = null;
rendererReady = false;
lastInteractive = false;
clearRearmTimers();
debug("familiar.window", "mouse interop recovery", { windowId, reason });
setPassthrough(false);
if (process.platform === "win32") {
requestCursorHitTestProbe(reason);
scheduleWindowsMouseForwardingRearm(`${reason}+250ms`, 250);
scheduleWindowsMouseForwardingRearm(`${reason}+500ms`, 500);
scheduleWindowsMouseForwardingRearm(`${reason}+1000ms`, 1_000);
scheduleWindowsMouseForwardingRearm(`${reason}+1500ms`, 1_500);
return;
}
rearmPassthrough(reason);
};
const isFromWindow = (event: IpcMainEvent): boolean => event.sender === webContents;
const setPassthrough = (passthrough: boolean): void => {
if (window.isDestroyed()) return;
if (process.platform === "linux") {
// Electron does not support forwarded mouse events on Linux, so ignored
// windows cannot receive the renderer events required to start dragging.
// Keep Linux familiar windows interactive; this trades click-through for reliable drag.
window.setIgnoreMouseEvents(false);
return;
}
if (passthrough && canForwardMouseEvents) window.setIgnoreMouseEvents(true, { forward: true });
else if (passthrough) window.setIgnoreMouseEvents(true);
else window.setIgnoreMouseEvents(false);
};
const clearRearmTimers = (): void => {
for (const timer of rearmTimers) clearTimeout(timer);
rearmTimers.clear();
};
const clearWindowsForwardingWatch = (): void => {
if (!forwardingWatchTimer) return;
clearTimeout(forwardingWatchTimer);
forwardingWatchTimer = null;
};
const getCursorProbe = (): { readonly inside: boolean; readonly cursor: Point; readonly bounds: Electron.Rectangle; readonly clientX: number; readonly clientY: number } => {
const cursor = screen.getCursorScreenPoint();
const bounds = window.getContentBounds();
const clientX = cursor.x - bounds.x;
const clientY = cursor.y - bounds.y;
return {
cursor,
bounds,
clientX,
clientY,
inside: clientX >= 0 && clientX < bounds.width && clientY >= 0 && clientY < bounds.height,
};
};
const requestCursorHitTestProbe = (reason: string, logProbe = true): void => {
if (window.isDestroyed() || webContents.isDestroyed()) return;
const probe = getCursorProbe();
if (logProbe) debug("familiar.window", "cursor hit-test probe", { windowId, reason, inside: probe.inside, cursor: probe.cursor, bounds: probe.bounds });
if (!probe.inside) return;
webContents.send("familiaros:familiar-probe-hit-test", { clientX: probe.clientX, clientY: probe.clientY, reason });
};
const rearmWindowsMouseForwarding = (reason: string, logRearm = true): void => {
if (window.isDestroyed()) return;
if (dragging || lastInteractive) {
if (logRearm) debug("familiar.window", "windows mouse forwarding rearm skipped", { windowId, reason, dragging: Boolean(dragging), interactive: lastInteractive });
return;
}
if (logRearm) debug("familiar.window", "windows mouse forwarding rearm", { windowId, reason });
window.setIgnoreMouseEvents(false);
window.setIgnoreMouseEvents(true, { forward: true });
requestCursorHitTestProbe(reason, logRearm);
};
const scheduleWindowsMouseForwardingRearm = (reason: string, delayMs: number): void => {
const timer = setTimeout(() => {
rearmTimers.delete(timer);
rearmWindowsMouseForwarding(reason);
}, delayMs);
rearmTimers.add(timer);
};
const scheduleWindowsForwardingWatch = (reason: string): void => {
if (process.platform !== "win32" || forwardingWatchTimer || dragging || lastInteractive || window.isDestroyed()) return;
forwardingWatchTimer = setTimeout(() => {
forwardingWatchTimer = null;
if (window.isDestroyed() || dragging || lastInteractive) return;
if (getCursorProbe().inside) rearmWindowsMouseForwarding(reason, false);
scheduleWindowsForwardingWatch(reason);
}, 750);
forwardingWatchTimer.unref?.();
};
const rearmPassthrough = (reason: string): void => {
if (window.isDestroyed()) return;
if (process.platform !== "win32") {
setPassthrough(true);
return;
}
// On Windows, rapid familiar HTML reloads can leave Chromium's forwarded mouse
// tracking stale while the cursor is already over the transparent window.
// Toggle immediately, probe the current cursor hit target, then repeat the
// toggle shortly after load because Windows sometimes re-registers mouse
// forwarding after Chromium finishes late compositing work.
rearmWindowsMouseForwarding(reason);
scheduleWindowsMouseForwardingRearm(`${reason}+75ms`, 75);
scheduleWindowsMouseForwardingRearm(`${reason}+175ms`, 175);
};
const rearmPassthroughAfterLoad = (): void => {
rearmPassthrough("did-finish-load");
};
const handleHitTest = (event: IpcMainEvent, interactive: unknown, source: unknown): void => {
if (!isFromWindow(event)) return;
rendererReady = true;
lastInteractive = Boolean(interactive);
const sourceName = typeof source === "string" ? source : undefined;
if (sourceName !== "idle-forwarding-watch" || lastInteractive) debug("familiar.window", "hit test", { windowId, interactive: lastInteractive, dragging, source: sourceName });
setPassthrough(!lastInteractive && !dragging);
if (lastInteractive || dragging) clearWindowsForwardingWatch();
else scheduleWindowsForwardingWatch("idle-forwarding-watch");
};
const handleReady = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
rendererReady = true;
setPassthrough(true);
};
const handleDragStart = (event: IpcMainEvent, point: unknown): void => {
if (!isFromWindow(event) || !isScreenPoint(point) || window.isDestroyed()) return;
const startBounds = window.getBounds();
dragging = { startScreenX: point.screenX, startScreenY: point.screenY, startWindowX: startBounds.x, startWindowY: startBounds.y, width: startBounds.width, height: startBounds.height };
petWindowDragging.set(window, true);
debug("familiar.window", "drag start", { windowId, point, startBounds });
clearWindowsForwardingWatch();
setPassthrough(false);
onPetEvent?.("familiar:dragStart", {});
};
const handleDragMove = (event: IpcMainEvent, point: unknown): void => {
if (!isFromWindow(event) || !dragging || !isScreenPoint(point) || window.isDestroyed()) return;
const nextX = dragging.startWindowX + Math.round(point.screenX - dragging.startScreenX);
const nextY = dragging.startWindowY + Math.round(point.screenY - dragging.startScreenY);
window.setBounds({ x: nextX, y: nextY, width: dragging.width, height: dragging.height }, false);
};
const handleDragEnd = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
const wasDragging = dragging !== null;
dragging = null;
petWindowDragging.set(window, false);
debug("familiar.window", "drag end", { windowId, position: window.isDestroyed() ? null : readWindowPosition(window) });
// Do not resize/re-anchor after a drag — the user already positioned the window.
if (wasDragging) onPetEvent?.("familiar:dragEnd", {});
};
const handleBubbleDismissed = (event: IpcMainEvent, dismissToken: unknown): void => {
if (!isFromWindow(event)) return;
debug("familiar.window", "bubble dismissed", { windowId, dismissToken });
if (typeof dismissToken === "string") onBubbleDismissed?.(dismissToken);
};
const handlePromptRequested = (event: IpcMainEvent): void => {
if (!isFromWindow(event)) return;
debug("familiar.window", "prompt requested", { windowId });
onPromptRequested?.();
};
const handleScaleStart = (event: IpcMainEvent): void => {
if (!isFromWindow(event) || window.isDestroyed()) return;
petWindowScaling.set(window, true);
debug("familiar.window", "scale start", { windowId });
setPassthrough(false);
};
const handleScalePreview = (event: IpcMainEvent, payload: unknown): void => {
if (!isFromWindow(event) || window.isDestroyed()) return;
const scale = isRecord(payload) && typeof payload.scale === "number" ? payload.scale : undefined;
if (scale === undefined || !Number.isFinite(scale)) return;
previewScale(window, scale as PetScaleValue);
};
const handleScaleEnd = (event: IpcMainEvent, payload: unknown): void => {
if (!isFromWindow(event)) return;
petWindowScaling.set(window, false);
const scale = isRecord(payload) && typeof payload.scale === "number" ? payload.scale : undefined;
debug("familiar.window", "scale end", { windowId, scale });
if (scale !== undefined && Number.isFinite(scale)) {
onScaleChanged?.(scale as PetScaleValue);
}
setPassthrough(true);
};
const handleBubbleAction = (event: IpcMainEvent, dismissToken: unknown, actionId: unknown): void => {
if (!isFromWindow(event)) return;
debug("familiar.window", "bubble action", { windowId, dismissToken, actionId });
if (typeof dismissToken === "string" && typeof actionId === "string" && actionId.length <= 64) onBubbleAction?.(dismissToken, actionId);
};
const handleBubbleSubmit = (event: IpcMainEvent, dismissToken: unknown, values: unknown): void => {
if (!isFromWindow(event)) return;
debug("familiar.window", "bubble submit", { windowId, dismissToken });
if (typeof dismissToken !== "string" || typeof values !== "object" || values === null) return;
const out: Record<string, string | number> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>).slice(0, 8)) {
if (typeof value === "string" && value.length <= 1000) out[key] = value;
else if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
}
onBubbleSubmit?.(dismissToken, out);
};
const allowedPetEventNames = new Set(["familiar:clicked", "familiar:doubleClicked", "familiar:hover", "familiar:drop"]);
const handlePetEvent = (event: IpcMainEvent, name: unknown, payload: unknown): void => {
if (!isFromWindow(event)) return;
if (typeof name !== "string" || !allowedPetEventNames.has(name)) return;
const data = typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
if (name !== "familiar:hover") debug("familiar.window", "familiar event", { windowId, name });
onPetEvent?.(name, data);
};
const resetForNavigation = (): void => {
dragging = null;
petWindowDragging.set(window, false);
rendererReady = false;
lastInteractive = false;
clearRearmTimers();
debug("familiar.window", "navigation reset passthrough", { windowId });
setPassthrough(false);
};
const rearmAfterLoad = (): void => {
dragging = null;
petWindowDragging.set(window, false);
lastInteractive = false;
debug("familiar.window", "load rearm passthrough", { windowId });
rearmPassthroughAfterLoad();
};
const handleDomReady = (): void => {
if (!rendererReady) setPassthrough(true);
};
const handleLoadFailure = (): void => {
dragging = null;
lastInteractive = false;
debug("familiar.window", "load failure rearm passthrough", { windowId });
setPassthrough(true);
};
const removeListeners = (): void => {
if (listenersRemoved) return;
listenersRemoved = true;
ipcMain.off("familiaros:familiar-ready", handleReady);
ipcMain.off("familiaros:familiar-hit-test", handleHitTest);
ipcMain.off("familiaros:familiar-drag-start", handleDragStart);
ipcMain.off("familiaros:familiar-drag-move", handleDragMove);
ipcMain.off("familiaros:familiar-drag-end", handleDragEnd);
ipcMain.off("familiaros:bubble-dismissed", handleBubbleDismissed);
ipcMain.off("familiaros:familiar-open-prompt", handlePromptRequested);
ipcMain.off("familiaros:familiar-scale-start", handleScaleStart);
ipcMain.off("familiaros:familiar-scale-preview", handleScalePreview);
ipcMain.off("familiaros:familiar-scale-end", handleScaleEnd);
ipcMain.off("familiaros:bubble-action", handleBubbleAction);
ipcMain.off("familiaros:bubble-submit", handleBubbleSubmit);
ipcMain.off("familiaros:familiar-event", handlePetEvent);
clearRearmTimers();
clearWindowsForwardingWatch();
petMouseInteropRecovery.delete(window);
petWindowDragging.delete(window);
petWindowScaling.delete(window);
if (!webContents.isDestroyed()) {
webContents.off("did-start-navigation", resetForNavigation);
webContents.off("did-start-loading", resetForNavigation);
webContents.off("did-finish-load", rearmAfterLoad);
webContents.off("dom-ready", handleDomReady);
webContents.off("did-fail-load", handleLoadFailure);
}
};
petMouseInteropRecovery.set(window, scheduleMouseInteropRecovery);
ipcMain.on("familiaros:familiar-ready", handleReady);
ipcMain.on("familiaros:familiar-hit-test", handleHitTest);
ipcMain.on("familiaros:familiar-drag-start", handleDragStart);
ipcMain.on("familiaros:familiar-drag-move", handleDragMove);
ipcMain.on("familiaros:familiar-drag-end", handleDragEnd);
ipcMain.on("familiaros:bubble-dismissed", handleBubbleDismissed);
ipcMain.on("familiaros:familiar-open-prompt", handlePromptRequested);
ipcMain.on("familiaros:familiar-scale-start", handleScaleStart);
ipcMain.on("familiaros:familiar-scale-preview", handleScalePreview);
ipcMain.on("familiaros:familiar-scale-end", handleScaleEnd);
ipcMain.on("familiaros:bubble-action", handleBubbleAction);
ipcMain.on("familiaros:bubble-submit", handleBubbleSubmit);
ipcMain.on("familiaros:familiar-event", handlePetEvent);
webContents.on("did-start-navigation", resetForNavigation);
webContents.on("did-start-loading", resetForNavigation);
webContents.on("did-finish-load", rearmAfterLoad);
webContents.on("dom-ready", handleDomReady);
webContents.on("did-fail-load", handleLoadFailure);
window.on("close", removeListeners);
window.once("closed", removeListeners);
}
export {
installMousePassthroughAndDrag,
isPetWindowDragging,
isPetWindowScaling,
recoverPetMouseInterop,
} from "./familiar-window-mouse-interop.js";
export type {
PetWindowInteractionDeps,
PetWindowInteractionHooks,
} from "./familiar-window-mouse-interop.js";
export function installMotionStatePublisher(window: BrowserWindow): void {
let lastX = window.getPosition()[0];
@ -409,11 +53,3 @@ export function installMotionStatePublisher(window: BrowserWindow): void {
if (idleTimer) clearTimeout(idleTimer);
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isScreenPoint(value: unknown): value is { readonly screenX: number; readonly screenY: number } {
return typeof value === "object" && value !== null && typeof (value as { readonly screenX?: unknown }).screenX === "number" && typeof (value as { readonly screenY?: unknown }).screenY === "number";
}

View file

@ -0,0 +1,299 @@
import { BrowserWindow, screen, type IpcMainEvent } from "electron";
import type { PetScaleValue } from "./app-state.js";
import type { Point } from "./display.js";
import { debug } from "./logger.js";
export interface PetWindowInteractionHooks {
readonly onBubbleDismissed?: (dismissToken: string) => void;
readonly onBubbleAction?: (dismissToken: string, actionId: string) => void;
readonly onBubbleSubmit?: (
dismissToken: string,
values: Record<string, string | number>,
) => void;
readonly onPetEvent?: (name: string, payload: Record<string, unknown>) => void;
readonly onPromptRequested?: () => void;
readonly onScaleChanged?: (scale: PetScaleValue) => void;
}
export interface PetWindowInteractionDeps {
readonly previewScale: (
window: BrowserWindow,
scale: PetScaleValue,
) => void;
readonly readWindowPosition: (window: BrowserWindow) => Point;
}
export interface PetWindowDragState {
readonly startScreenX: number;
readonly startScreenY: number;
readonly startWindowX: number;
readonly startWindowY: number;
readonly width: number;
readonly height: number;
}
export interface PetWindowMouseInteropState {
dragging: PetWindowDragState | null;
rendererReady: boolean;
listenersRemoved: boolean;
lastInteractive: boolean;
forwardingWatchTimer: NodeJS.Timeout | null;
readonly rearmTimers: Set<NodeJS.Timeout>;
}
export interface PetWindowMouseInteropContext {
readonly window: BrowserWindow;
readonly windowId: number;
readonly webContents: BrowserWindow["webContents"];
readonly hooks: PetWindowInteractionHooks;
readonly deps: PetWindowInteractionDeps;
readonly state: PetWindowMouseInteropState;
readonly canForwardMouseEvents: boolean;
}
export const allowedPetEventNames = new Set([
"familiar:clicked",
"familiar:doubleClicked",
"familiar:hover",
"familiar:drop",
]);
export function createPetWindowMouseInteropContext(
window: BrowserWindow,
hooks: PetWindowInteractionHooks,
deps: PetWindowInteractionDeps,
): PetWindowMouseInteropContext {
return {
window,
windowId: window.id,
webContents: window.webContents,
hooks,
deps,
canForwardMouseEvents:
process.platform === "darwin" || process.platform === "win32",
state: {
dragging: null,
rendererReady: false,
listenersRemoved: false,
lastInteractive: false,
forwardingWatchTimer: null,
rearmTimers: new Set<NodeJS.Timeout>(),
},
};
}
export function scheduleMouseInteropRecovery(
context: PetWindowMouseInteropContext,
reason: string,
): void {
if (context.window.isDestroyed()) return;
context.state.dragging = null;
context.state.rendererReady = false;
context.state.lastInteractive = false;
clearRearmTimers(context);
debug("familiar.window", "mouse interop recovery", {
windowId: context.windowId,
reason,
});
setPassthrough(context, false);
if (process.platform === "win32") {
requestCursorHitTestProbe(context, reason);
scheduleWindowsMouseForwardingRearm(context, `${reason}+250ms`, 250);
scheduleWindowsMouseForwardingRearm(context, `${reason}+500ms`, 500);
scheduleWindowsMouseForwardingRearm(context, `${reason}+1000ms`, 1_000);
scheduleWindowsMouseForwardingRearm(context, `${reason}+1500ms`, 1_500);
return;
}
rearmPassthrough(context, reason);
}
export function isFromWindow(
context: PetWindowMouseInteropContext,
event: IpcMainEvent,
): boolean {
return event.sender === context.webContents;
}
export function setPassthrough(
context: PetWindowMouseInteropContext,
passthrough: boolean,
): void {
if (context.window.isDestroyed()) return;
if (process.platform === "linux") {
context.window.setIgnoreMouseEvents(false);
return;
}
if (passthrough && context.canForwardMouseEvents) {
context.window.setIgnoreMouseEvents(true, { forward: true });
} else if (passthrough) {
context.window.setIgnoreMouseEvents(true);
} else {
context.window.setIgnoreMouseEvents(false);
}
}
export function clearRearmTimers(
context: PetWindowMouseInteropContext,
): void {
for (const timer of context.state.rearmTimers) clearTimeout(timer);
context.state.rearmTimers.clear();
}
export function clearWindowsForwardingWatch(
context: PetWindowMouseInteropContext,
): void {
if (!context.state.forwardingWatchTimer) return;
clearTimeout(context.state.forwardingWatchTimer);
context.state.forwardingWatchTimer = null;
}
export function scheduleWindowsForwardingWatch(
context: PetWindowMouseInteropContext,
reason: string,
): void {
if (
process.platform !== "win32" ||
context.state.forwardingWatchTimer ||
context.state.dragging ||
context.state.lastInteractive ||
context.window.isDestroyed()
) {
return;
}
context.state.forwardingWatchTimer = setTimeout(() => {
context.state.forwardingWatchTimer = null;
if (
context.window.isDestroyed() ||
context.state.dragging ||
context.state.lastInteractive
) {
return;
}
if (getCursorProbe(context.window).inside) {
rearmWindowsMouseForwarding(context, reason, false);
}
scheduleWindowsForwardingWatch(context, reason);
}, 750);
context.state.forwardingWatchTimer.unref?.();
}
export function rearmPassthrough(
context: PetWindowMouseInteropContext,
reason: string,
): void {
if (context.window.isDestroyed()) return;
if (process.platform !== "win32") {
setPassthrough(context, true);
return;
}
rearmWindowsMouseForwarding(context, reason);
scheduleWindowsMouseForwardingRearm(context, `${reason}+75ms`, 75);
scheduleWindowsMouseForwardingRearm(context, `${reason}+175ms`, 175);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isScreenPoint(
value: unknown,
): value is { readonly screenX: number; readonly screenY: number } {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { readonly screenX?: unknown }).screenX === "number" &&
typeof (value as { readonly screenY?: unknown }).screenY === "number"
);
}
function getCursorProbe(window: BrowserWindow): {
readonly inside: boolean;
readonly cursor: Point;
readonly bounds: Electron.Rectangle;
readonly clientX: number;
readonly clientY: number;
} {
const cursor = screen.getCursorScreenPoint();
const bounds = window.getContentBounds();
const clientX = cursor.x - bounds.x;
const clientY = cursor.y - bounds.y;
return {
cursor,
bounds,
clientX,
clientY,
inside:
clientX >= 0 &&
clientX < bounds.width &&
clientY >= 0 &&
clientY < bounds.height,
};
}
function requestCursorHitTestProbe(
context: PetWindowMouseInteropContext,
reason: string,
logProbe = true,
): void {
if (context.window.isDestroyed() || context.webContents.isDestroyed()) return;
const probe = getCursorProbe(context.window);
if (logProbe) {
debug("familiar.window", "cursor hit-test probe", {
windowId: context.windowId,
reason,
inside: probe.inside,
cursor: probe.cursor,
bounds: probe.bounds,
});
}
if (!probe.inside) return;
context.webContents.send("familiaros:familiar-probe-hit-test", {
clientX: probe.clientX,
clientY: probe.clientY,
reason,
});
}
function rearmWindowsMouseForwarding(
context: PetWindowMouseInteropContext,
reason: string,
logRearm = true,
): void {
if (context.window.isDestroyed()) return;
if (context.state.dragging || context.state.lastInteractive) {
if (logRearm) {
debug("familiar.window", "windows mouse forwarding rearm skipped", {
windowId: context.windowId,
reason,
dragging: Boolean(context.state.dragging),
interactive: context.state.lastInteractive,
});
}
return;
}
if (logRearm) {
debug("familiar.window", "windows mouse forwarding rearm", {
windowId: context.windowId,
reason,
});
}
context.window.setIgnoreMouseEvents(false);
context.window.setIgnoreMouseEvents(true, { forward: true });
requestCursorHitTestProbe(context, reason, logRearm);
}
function scheduleWindowsMouseForwardingRearm(
context: PetWindowMouseInteropContext,
reason: string,
delayMs: number,
): void {
const timer = setTimeout(() => {
context.state.rearmTimers.delete(timer);
rearmWindowsMouseForwarding(context, reason);
}, delayMs);
context.state.rearmTimers.add(timer);
}

View file

@ -0,0 +1,401 @@
import { BrowserWindow, ipcMain, type IpcMainEvent } from "electron";
import type { PetScaleValue } from "./app-state.js";
import {
allowedPetEventNames,
clearRearmTimers,
clearWindowsForwardingWatch,
createPetWindowMouseInteropContext,
isFromWindow,
isRecord,
isScreenPoint,
rearmPassthrough,
scheduleMouseInteropRecovery,
scheduleWindowsForwardingWatch,
setPassthrough,
type PetWindowInteractionDeps,
type PetWindowInteractionHooks,
} from "./familiar-window-mouse-interop-support.js";
import { debug } from "./logger.js";
export type {
PetWindowInteractionDeps,
PetWindowInteractionHooks,
} from "./familiar-window-mouse-interop-support.js";
const petMouseInteropRecovery = new WeakMap<
BrowserWindow,
(reason: string) => void
>();
const petWindowDragging = new WeakMap<BrowserWindow, boolean>();
const petWindowScaling = new WeakMap<BrowserWindow, boolean>();
export function isPetWindowDragging(window: BrowserWindow): boolean {
return petWindowDragging.get(window) === true;
}
export function isPetWindowScaling(window: BrowserWindow): boolean {
return petWindowScaling.get(window) === true;
}
export function recoverPetMouseInterop(
window: BrowserWindow,
reason: string,
): void {
if (window.isDestroyed()) return;
const recover = petMouseInteropRecovery.get(window);
if (recover) {
recover(reason);
return;
}
debug("familiar.window", "mouse interop recovery skipped", {
windowId: window.id,
reason,
skippedReason: "unregistered-window",
});
}
export function installMousePassthroughAndDrag(
window: BrowserWindow,
hooks: PetWindowInteractionHooks = {},
deps: PetWindowInteractionDeps,
): void {
const context = createPetWindowMouseInteropContext(window, hooks, deps);
const handleHitTest = (
event: IpcMainEvent,
interactive: unknown,
source: unknown,
): void => {
if (!isFromWindow(context, event)) return;
context.state.rendererReady = true;
context.state.lastInteractive = Boolean(interactive);
const sourceName = typeof source === "string" ? source : undefined;
if (
sourceName !== "idle-forwarding-watch" ||
context.state.lastInteractive
) {
debug("familiar.window", "hit test", {
windowId: context.windowId,
interactive: context.state.lastInteractive,
dragging: context.state.dragging,
source: sourceName,
});
}
setPassthrough(
context,
!context.state.lastInteractive && !context.state.dragging,
);
if (context.state.lastInteractive || context.state.dragging) {
clearWindowsForwardingWatch(context);
} else {
scheduleWindowsForwardingWatch(context, "idle-forwarding-watch");
}
};
const handleReady = (event: IpcMainEvent): void => {
if (!isFromWindow(context, event)) return;
context.state.rendererReady = true;
setPassthrough(context, true);
};
const handleDragStart = (
event: IpcMainEvent,
point: unknown,
): void => {
if (
!isFromWindow(context, event) ||
!isScreenPoint(point) ||
context.window.isDestroyed()
) {
return;
}
const startBounds = context.window.getBounds();
context.state.dragging = {
startScreenX: point.screenX,
startScreenY: point.screenY,
startWindowX: startBounds.x,
startWindowY: startBounds.y,
width: startBounds.width,
height: startBounds.height,
};
petWindowDragging.set(context.window, true);
debug("familiar.window", "drag start", {
windowId: context.windowId,
point,
startBounds,
});
clearWindowsForwardingWatch(context);
setPassthrough(context, false);
context.hooks.onPetEvent?.("familiar:dragStart", {});
};
const handleDragMove = (
event: IpcMainEvent,
point: unknown,
): void => {
if (
!isFromWindow(context, event) ||
!context.state.dragging ||
!isScreenPoint(point) ||
context.window.isDestroyed()
) {
return;
}
const nextX =
context.state.dragging.startWindowX +
Math.round(point.screenX - context.state.dragging.startScreenX);
const nextY =
context.state.dragging.startWindowY +
Math.round(point.screenY - context.state.dragging.startScreenY);
context.window.setBounds(
{
x: nextX,
y: nextY,
width: context.state.dragging.width,
height: context.state.dragging.height,
},
false,
);
};
const handleDragEnd = (event: IpcMainEvent): void => {
if (!isFromWindow(context, event)) return;
const wasDragging = context.state.dragging !== null;
context.state.dragging = null;
petWindowDragging.set(context.window, false);
debug("familiar.window", "drag end", {
windowId: context.windowId,
position: context.window.isDestroyed()
? null
: context.deps.readWindowPosition(context.window),
});
if (wasDragging) context.hooks.onPetEvent?.("familiar:dragEnd", {});
};
const handleBubbleDismissed = (
event: IpcMainEvent,
dismissToken: unknown,
): void => {
if (!isFromWindow(context, event)) return;
debug("familiar.window", "bubble dismissed", {
windowId: context.windowId,
dismissToken,
});
if (typeof dismissToken === "string") {
context.hooks.onBubbleDismissed?.(dismissToken);
}
};
const handlePromptRequested = (event: IpcMainEvent): void => {
if (!isFromWindow(context, event)) return;
debug("familiar.window", "prompt requested", {
windowId: context.windowId,
});
context.hooks.onPromptRequested?.();
};
const handleScaleStart = (event: IpcMainEvent): void => {
if (!isFromWindow(context, event) || context.window.isDestroyed()) return;
petWindowScaling.set(context.window, true);
debug("familiar.window", "scale start", { windowId: context.windowId });
setPassthrough(context, false);
};
const handleScalePreview = (
event: IpcMainEvent,
payload: unknown,
): void => {
if (!isFromWindow(context, event) || context.window.isDestroyed()) return;
const scale =
isRecord(payload) && typeof payload.scale === "number"
? payload.scale
: undefined;
if (scale === undefined || !Number.isFinite(scale)) return;
context.deps.previewScale(context.window, scale as PetScaleValue);
};
const handleScaleEnd = (
event: IpcMainEvent,
payload: unknown,
): void => {
if (!isFromWindow(context, event)) return;
petWindowScaling.set(context.window, false);
const scale =
isRecord(payload) && typeof payload.scale === "number"
? payload.scale
: undefined;
debug("familiar.window", "scale end", {
windowId: context.windowId,
scale,
});
if (scale !== undefined && Number.isFinite(scale)) {
context.hooks.onScaleChanged?.(scale as PetScaleValue);
}
setPassthrough(context, true);
};
const handleBubbleAction = (
event: IpcMainEvent,
dismissToken: unknown,
actionId: unknown,
): void => {
if (!isFromWindow(context, event)) return;
debug("familiar.window", "bubble action", {
windowId: context.windowId,
dismissToken,
actionId,
});
if (
typeof dismissToken === "string" &&
typeof actionId === "string" &&
actionId.length <= 64
) {
context.hooks.onBubbleAction?.(dismissToken, actionId);
}
};
const handleBubbleSubmit = (
event: IpcMainEvent,
dismissToken: unknown,
values: unknown,
): void => {
if (!isFromWindow(context, event)) return;
debug("familiar.window", "bubble submit", {
windowId: context.windowId,
dismissToken,
});
if (
typeof dismissToken !== "string" ||
typeof values !== "object" ||
values === null
) {
return;
}
const out: Record<string, string | number> = {};
for (const [key, value] of Object.entries(
values as Record<string, unknown>,
).slice(0, 8)) {
if (typeof value === "string" && value.length <= 1000) out[key] = value;
else if (typeof value === "number" && Number.isFinite(value)) {
out[key] = value;
}
}
context.hooks.onBubbleSubmit?.(dismissToken, out);
};
const handlePetEvent = (
event: IpcMainEvent,
name: unknown,
payload: unknown,
): void => {
if (!isFromWindow(context, event)) return;
if (typeof name !== "string" || !allowedPetEventNames.has(name)) return;
const data =
typeof payload === "object" &&
payload !== null &&
!Array.isArray(payload)
? (payload as Record<string, unknown>)
: {};
if (name !== "familiar:hover") {
debug("familiar.window", "familiar event", {
windowId: context.windowId,
name,
});
}
context.hooks.onPetEvent?.(name, data);
};
const resetForNavigation = (): void => {
context.state.dragging = null;
petWindowDragging.set(context.window, false);
context.state.rendererReady = false;
context.state.lastInteractive = false;
clearRearmTimers(context);
debug("familiar.window", "navigation reset passthrough", {
windowId: context.windowId,
});
setPassthrough(context, false);
};
const rearmAfterLoad = (): void => {
context.state.dragging = null;
petWindowDragging.set(context.window, false);
context.state.lastInteractive = false;
debug("familiar.window", "load rearm passthrough", {
windowId: context.windowId,
});
rearmPassthrough(context, "did-finish-load");
};
const handleDomReady = (): void => {
if (!context.state.rendererReady) {
setPassthrough(context, true);
}
};
const handleLoadFailure = (): void => {
context.state.dragging = null;
context.state.lastInteractive = false;
debug("familiar.window", "load failure rearm passthrough", {
windowId: context.windowId,
});
setPassthrough(context, true);
};
const removeListeners = (): void => {
if (context.state.listenersRemoved) return;
context.state.listenersRemoved = true;
ipcMain.off("familiaros:familiar-ready", handleReady);
ipcMain.off("familiaros:familiar-hit-test", handleHitTest);
ipcMain.off("familiaros:familiar-drag-start", handleDragStart);
ipcMain.off("familiaros:familiar-drag-move", handleDragMove);
ipcMain.off("familiaros:familiar-drag-end", handleDragEnd);
ipcMain.off("familiaros:bubble-dismissed", handleBubbleDismissed);
ipcMain.off("familiaros:familiar-open-prompt", handlePromptRequested);
ipcMain.off("familiaros:familiar-scale-start", handleScaleStart);
ipcMain.off("familiaros:familiar-scale-preview", handleScalePreview);
ipcMain.off("familiaros:familiar-scale-end", handleScaleEnd);
ipcMain.off("familiaros:bubble-action", handleBubbleAction);
ipcMain.off("familiaros:bubble-submit", handleBubbleSubmit);
ipcMain.off("familiaros:familiar-event", handlePetEvent);
clearRearmTimers(context);
clearWindowsForwardingWatch(context);
petMouseInteropRecovery.delete(context.window);
petWindowDragging.delete(context.window);
petWindowScaling.delete(context.window);
if (!context.webContents.isDestroyed()) {
context.webContents.off("did-start-navigation", resetForNavigation);
context.webContents.off("did-start-loading", resetForNavigation);
context.webContents.off("did-finish-load", rearmAfterLoad);
context.webContents.off("dom-ready", handleDomReady);
context.webContents.off("did-fail-load", handleLoadFailure);
}
};
petMouseInteropRecovery.set(context.window, (reason) =>
scheduleMouseInteropRecovery(context, reason),
);
ipcMain.on("familiaros:familiar-ready", handleReady);
ipcMain.on("familiaros:familiar-hit-test", handleHitTest);
ipcMain.on("familiaros:familiar-drag-start", handleDragStart);
ipcMain.on("familiaros:familiar-drag-move", handleDragMove);
ipcMain.on("familiaros:familiar-drag-end", handleDragEnd);
ipcMain.on("familiaros:bubble-dismissed", handleBubbleDismissed);
ipcMain.on("familiaros:familiar-open-prompt", handlePromptRequested);
ipcMain.on("familiaros:familiar-scale-start", handleScaleStart);
ipcMain.on("familiaros:familiar-scale-preview", handleScalePreview);
ipcMain.on("familiaros:familiar-scale-end", handleScaleEnd);
ipcMain.on("familiaros:bubble-action", handleBubbleAction);
ipcMain.on("familiaros:bubble-submit", handleBubbleSubmit);
ipcMain.on("familiaros:familiar-event", handlePetEvent);
context.webContents.on("did-start-navigation", resetForNavigation);
context.webContents.on("did-start-loading", resetForNavigation);
context.webContents.on("did-finish-load", rearmAfterLoad);
context.webContents.on("dom-ready", handleDomReady);
context.webContents.on("did-fail-load", handleLoadFailure);
context.window.on("close", removeListeners);
context.window.once("closed", removeListeners);
}

View file

@ -6,15 +6,26 @@ import { fileURLToPath } from "node:url";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const familiarWindowSource = readFileSync(resolve(desktopRoot, "src/familiar-window.ts"), "utf8");
const familiarWindowInteractionSource = readFileSync(resolve(desktopRoot, "src/familiar-window-interactions.ts"), "utf8");
const familiarWindowMouseInteropSource = readFileSync(resolve(desktopRoot, "src/familiar-window-mouse-interop.ts"), "utf8");
const familiarWindowMouseInteropSupportSource = readFileSync(resolve(desktopRoot, "src/familiar-window-mouse-interop-support.ts"), "utf8");
assert.match(familiarWindowSource, /from "\.\/familiar-window-interactions(?:\.js)?"/, "familiar-window must import the extracted interaction helper module.");
assert.match(familiarWindowSource, /from "\.\/familiar-window-host(?:\.js)?"/, "familiar-window must compose the extracted host helper module with interactions.");
assert.match(familiarWindowSource, /installMousePassthroughAndDrag\(window, options, \{ previewScale: resizePetWindowForScale, readWindowPosition \}\)/, "familiar-window must route default familiar interop through the shared helper.");
assert.match(familiarWindowSource, /installMotionStatePublisher\(window\)/, "familiar-window must keep using the shared motion-state publisher seam.");
assert.match(familiarWindowInteractionSource, /export function installMousePassthroughAndDrag/, "interaction helper must export the passthrough and drag seam.");
assert.match(familiarWindowInteractionSource, /from "\.\/familiar-window-mouse-interop(?:\.js)?"/, "interaction helper must re-export the extracted mouse interop runtime seam.");
assert.match(familiarWindowInteractionSource, /export \{[\s\S]*?installMousePassthroughAndDrag[\s\S]*?\} from "\.\/familiar-window-mouse-interop(?:\.js)?"/, "interaction helper must re-export the passthrough and drag seam.");
assert.match(familiarWindowInteractionSource, /export function installMotionStatePublisher/, "interaction helper must export the motion-state publisher seam.");
assert.match(familiarWindowInteractionSource, /export function recoverPetMouseInterop/, "interaction helper must export mouse interop recovery.");
assert.match(familiarWindowInteractionSource, /export function isPetWindowDragging/, "interaction helper must expose drag state.");
assert.match(familiarWindowInteractionSource, /export function isPetWindowScaling/, "interaction helper must expose scale-preview state.");
assert.match(familiarWindowInteractionSource, /export \{[\s\S]*?recoverPetMouseInterop[\s\S]*?\} from "\.\/familiar-window-mouse-interop(?:\.js)?"/, "interaction helper must re-export mouse interop recovery.");
assert.match(familiarWindowInteractionSource, /export \{[\s\S]*?isPetWindowDragging[\s\S]*?\} from "\.\/familiar-window-mouse-interop(?:\.js)?"/, "interaction helper must expose drag state through the interop seam.");
assert.match(familiarWindowInteractionSource, /export \{[\s\S]*?isPetWindowScaling[\s\S]*?\} from "\.\/familiar-window-mouse-interop(?:\.js)?"/, "interaction helper must expose scale-preview state through the interop seam.");
assert.match(familiarWindowMouseInteropSource, /export function installMousePassthroughAndDrag/, "mouse interop runtime must export the passthrough and drag implementation seam.");
assert.match(familiarWindowMouseInteropSource, /export function recoverPetMouseInterop/, "mouse interop runtime must export the recovery implementation seam.");
assert.match(familiarWindowMouseInteropSource, /export function isPetWindowDragging/, "mouse interop runtime must export drag state.");
assert.match(familiarWindowMouseInteropSource, /export function isPetWindowScaling/, "mouse interop runtime must export scale-preview state.");
assert.match(familiarWindowMouseInteropSource, /from "\.\/familiar-window-mouse-interop-support(?:\.js)?"/, "mouse interop runtime must import the support seam.");
assert.match(familiarWindowMouseInteropSupportSource, /export function createPetWindowMouseInteropContext/, "mouse interop support must own context creation.");
assert.match(familiarWindowMouseInteropSupportSource, /export function scheduleMouseInteropRecovery/, "mouse interop support must own recovery timing.");
assert.match(familiarWindowMouseInteropSupportSource, /export function scheduleWindowsForwardingWatch/, "mouse interop support must own platform-specific forwarding watch helpers.");
console.error("Familiar window interaction seam validation passed.");