Extract familiar window mouse interop seam
This commit is contained in:
parent
29bf11afae
commit
9e75151e56
6 changed files with 747 additions and 392 deletions
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
299
apps/desktop/src/familiar-window-mouse-interop-support.ts
Normal file
299
apps/desktop/src/familiar-window-mouse-interop-support.ts
Normal 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);
|
||||
}
|
||||
401
apps/desktop/src/familiar-window-mouse-interop.ts
Normal file
401
apps/desktop/src/familiar-window-mouse-interop.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue