From cc675fe8983a6e61e397ac87937f5354f6fe8472 Mon Sep 17 00:00:00 2001 From: Alvin Unreal Date: Fri, 15 May 2026 20:35:40 +0200 Subject: [PATCH] Improve Windows pet input rearm --- apps/desktop/pet-preload.cjs | 21 ++++-- apps/desktop/src/check-packaging-contract.ts | 3 + apps/desktop/src/pet-window.ts | 73 +++++++++++++++++--- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/apps/desktop/pet-preload.cjs b/apps/desktop/pet-preload.cjs index ed51af8e..dd21c600 100644 --- a/apps/desktop/pet-preload.cjs +++ b/apps/desktop/pet-preload.cjs @@ -42,16 +42,29 @@ const getInteractiveTarget = (event) => { return target && target.closest(".pet-shell, .bubble"); }; -const setInteractiveHit = (interactive) => { - if (lastInteractiveHit === interactive) return; +const reportInteractiveHit = (interactive, source, force = false) => { + if (!force && lastInteractiveHit === interactive) return; lastInteractiveHit = interactive; - ipcRenderer.send("openpets:pet-hit-test", interactive); + ipcRenderer.send("openpets:pet-hit-test", interactive, source); +}; + +const setInteractiveHit = (interactive, source = "mouse") => { + if (lastInteractiveHit === interactive) return; + reportInteractiveHit(interactive, source); }; const updateInteractiveHit = (event) => { setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging); }; +ipcRenderer.on("openpets:pet-probe-hit-test", (_event, point) => { + if (!point || typeof point.clientX !== "number" || typeof point.clientY !== "number" || !Number.isFinite(point.clientX) || !Number.isFinite(point.clientY)) return; + const clientX = point.clientX; + const clientY = point.clientY; + const target = document.elementFromPoint(clientX, clientY); + reportInteractiveHit(Boolean(target && target.closest(".pet-shell, .bubble")) || dragging, typeof point.reason === "string" ? point.reason.slice(0, 80) : "probe", true); +}); + const installMouseInterop = () => { lastInteractiveHit = null; dragging = false; @@ -81,7 +94,7 @@ const installMouseInterop = () => { if (!dragging) setInteractiveHit(false); }, { passive: true }); - setInteractiveHit(false); + setInteractiveHit(false, "ready"); ipcRenderer.send("openpets:pet-ready"); }; diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 9f127ad0..7687a045 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -131,6 +131,9 @@ assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\) assert.match(petWindowSource, /setIgnoreMouseEvents\(false\)/, "visible pet and bubble hit targets must re-enable mouse handling."); assert.match(petWindowSource, /openpets:pet-ready/, "pet windows must resync passthrough after each renderer reload."); assert.match(petWindowSource, /function installMousePassthroughAndDrag[\s\S]*?const rearmPassthroughAfterLoad[\s\S]*?process\.platform !== "win32"[\s\S]*?window\.setIgnoreMouseEvents\(false\);[\s\S]*?window\.setIgnoreMouseEvents\(true, \{ forward: true \}\);/, "Windows pet reloads must toggle forwarded mouse passthrough to re-register hover and drag tracking."); +assert.match(petWindowSource, /scheduleWindowsMouseForwardingRearm\("did-finish-load\+75ms", 75\);[\s\S]*?scheduleWindowsMouseForwardingRearm\("did-finish-load\+175ms", 175\);/, "Windows pet reloads must retry mouse forwarding rearm after load settles."); +assert.match(petWindowSource, /openpets:pet-probe-hit-test/, "Windows pet reloads must probe current cursor hit target when mousemove forwarding is stale."); +assert.match(petPreloadSource, /openpets:pet-probe-hit-test[\s\S]*?elementFromPoint\(clientX, clientY\)[\s\S]*?reportInteractiveHit/, "pet preload must answer main-process cursor hit-test probes."); assert.match(petWindowSource, /did-finish-load", rearmAfterLoad/, "pet windows must re-arm mouse passthrough after every content load."); assert.match(petWindowSource, /did-fail-load", handleLoadFailure/, "pet windows must restore passthrough after failed content loads."); assert.match(petWindowSource, /window\.setIgnoreMouseEvents\(false\);[\s\S]*?await window\.loadFile/, "pet reloads must reset OS mouse passthrough before navigation."); diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index ccdbfa51..a44601b1 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, Menu, type IpcMainEvent } from "electron"; +import { app, BrowserWindow, ipcMain, Menu, screen, type IpcMainEvent } from "electron"; import { mkdir, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -136,6 +136,8 @@ function installMousePassthroughAndDrag(window: BrowserWindow): void { let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number } | null = null; let rendererReady = false; let listenersRemoved = false; + let lastInteractive = false; + const rearmTimers = new Set(); const windowId = window.id; const webContents = window.webContents; const canForwardMouseEvents = process.platform === "darwin" || process.platform === "win32"; @@ -156,6 +158,53 @@ function installMousePassthroughAndDrag(window: BrowserWindow): void { else window.setIgnoreMouseEvents(false); }; + const clearRearmTimers = (): void => { + for (const timer of rearmTimers) clearTimeout(timer); + rearmTimers.clear(); + }; + + 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): void => { + if (window.isDestroyed() || webContents.isDestroyed()) return; + const probe = getCursorProbe(); + debug("pet.window", "cursor hit-test probe", { windowId, reason, inside: probe.inside, cursor: probe.cursor, bounds: probe.bounds }); + if (!probe.inside) return; + webContents.send("openpets:pet-probe-hit-test", { clientX: probe.clientX, clientY: probe.clientY, reason }); + }; + + const rearmWindowsMouseForwarding = (reason: string): void => { + if (window.isDestroyed()) return; + if (dragging || lastInteractive) { + debug("pet.window", "windows mouse forwarding rearm skipped", { windowId, reason, dragging: Boolean(dragging), interactive: lastInteractive }); + return; + } + debug("pet.window", "windows mouse forwarding rearm", { windowId, reason }); + window.setIgnoreMouseEvents(false); + window.setIgnoreMouseEvents(true, { forward: true }); + requestCursorHitTestProbe(reason); + }; + + const scheduleWindowsMouseForwardingRearm = (reason: string, delayMs: number): void => { + const timer = setTimeout(() => { + rearmTimers.delete(timer); + rearmWindowsMouseForwarding(reason); + }, delayMs); + rearmTimers.add(timer); + }; + const rearmPassthroughAfterLoad = (): void => { if (window.isDestroyed()) return; if (process.platform !== "win32") { @@ -165,17 +214,20 @@ function installMousePassthroughAndDrag(window: BrowserWindow): void { // On Windows, rapid pet HTML reloads can leave Chromium's forwarded mouse // tracking stale while the cursor is already over the transparent window. - // Toggle passthrough after each completed reload so hover/drag mousemove - // forwarding is re-registered without requiring the cursor to leave/re-enter. - window.setIgnoreMouseEvents(false); - window.setIgnoreMouseEvents(true, { forward: true }); + // 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("did-finish-load"); + scheduleWindowsMouseForwardingRearm("did-finish-load+75ms", 75); + scheduleWindowsMouseForwardingRearm("did-finish-load+175ms", 175); }; - const handleHitTest = (event: IpcMainEvent, interactive: unknown): void => { + const handleHitTest = (event: IpcMainEvent, interactive: unknown, source: unknown): void => { if (!isFromWindow(event)) return; rendererReady = true; - debug("pet.window", "hit test", { windowId, interactive: Boolean(interactive), dragging }); - setPassthrough(!interactive && !dragging); + lastInteractive = Boolean(interactive); + debug("pet.window", "hit test", { windowId, interactive: lastInteractive, dragging, source: typeof source === "string" ? source : undefined }); + setPassthrough(!lastInteractive && !dragging); }; const handleReady = (event: IpcMainEvent): void => { @@ -206,12 +258,15 @@ function installMousePassthroughAndDrag(window: BrowserWindow): void { const resetForNavigation = (): void => { dragging = null; rendererReady = false; + lastInteractive = false; + clearRearmTimers(); debug("pet.window", "navigation reset passthrough", { windowId }); setPassthrough(false); }; const rearmAfterLoad = (): void => { dragging = null; + lastInteractive = false; debug("pet.window", "load rearm passthrough", { windowId }); rearmPassthroughAfterLoad(); }; @@ -222,6 +277,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow): void { const handleLoadFailure = (): void => { dragging = null; + lastInteractive = false; debug("pet.window", "load failure rearm passthrough", { windowId }); setPassthrough(true); }; @@ -234,6 +290,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow): void { ipcMain.off("openpets:pet-drag-start", handleDragStart); ipcMain.off("openpets:pet-drag-move", handleDragMove); ipcMain.off("openpets:pet-drag-end", handleDragEnd); + clearRearmTimers(); if (!webContents.isDestroyed()) { webContents.off("did-start-navigation", resetForNavigation); webContents.off("did-start-loading", resetForNavigation);