diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 029ca2b1..d9b237a0 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -144,9 +144,11 @@ assert.match(petWindowSource, /function installMousePassthroughAndDrag/, "pet wi assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent pet window background must use OS-level mouse passthrough."); 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, /function installMousePassthroughAndDrag[\s\S]*?const rearmPassthrough[\s\S]*?process\.platform !== "win32"[\s\S]*?rearmWindowsMouseForwarding\(reason\)/, "Windows pet reloads must toggle forwarded mouse passthrough to re-register hover and drag tracking."); +assert.match(petWindowSource, /scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+75ms`, 75\);[\s\S]*?scheduleWindowsMouseForwardingRearm\(`\$\{reason\}\+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(petWindowSource, /export function recoverPetMouseInterop/, "pet windows must expose a controlled mouse interop recovery hook for OS display and resume events."); +assert.match(petWindowSource, /petMouseInteropRecovery\.set\(window, scheduleMouseInteropRecovery\)/, "pet windows must register their mouse interop recovery callback."); 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."); @@ -163,6 +165,9 @@ assert.match(petWindowSource, /\.pet-shell[\s\S]*?-webkit-app-region: no-drag; c assert.match(petPreloadSource, /openpets:pet-hit-test/, "pet preload must report visible pet and bubble hit testing for passthrough."); assert.match(petPreloadSource, /openpets:pet-ready/, "pet preload must report readiness after installing mouse handlers."); assert.match(petPreloadSource, /openpets:pet-drag-start/, "pet preload must start controlled pet dragging from the sprite."); +assert.match(defaultPetControllerSource, /powerMonitor\.on\("resume", recoverDefaultPetWindowAfterResume\)/, "default pet must recover mouse interop after Windows sleep or resume."); +assert.match(defaultPetControllerSource, /recoverDefaultPetMouseInterop\("display-change"\)/, "default pet must recover mouse interop after monitor topology changes."); +assert.match(windowsSource, /recoverDefaultPetMouseInterop\("default-pet-changed"\)/, "changing default pet must recover mouse interop for dragging without app restart."); assert.match(petWindowSource, /function installPetContextMenu/, "pet windows must install a native right-click context menu."); assert.match(petWindowSource, /webContents\.on\("context-menu"/, "pet context menu must be handled in the Electron main process."); assert.match(petWindowSource, /Menu\.buildFromTemplate/, "pet context menu must use a small native Electron menu."); diff --git a/apps/desktop/src/default-pet-controller.ts b/apps/desktop/src/default-pet-controller.ts index c1941abe..dc47293e 100644 --- a/apps/desktop/src/default-pet-controller.ts +++ b/apps/desktop/src/default-pet-controller.ts @@ -1,10 +1,10 @@ -import { BrowserWindow, screen } from "electron"; +import { BrowserWindow, powerMonitor, screen } from "electron"; import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js"; import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js"; import { debug, info } from "./logger.js"; import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js"; -import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js"; +import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, recoverPetMouseInterop, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js"; let defaultPetWindow: BrowserWindow | null = null; let paused = false; @@ -70,6 +70,16 @@ export function refreshDefaultPetContent(): void { void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge, getCurrentDismissToken()); } +export function recoverDefaultPetMouseInterop(reason: string): void { + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { + debug("pet.default", "mouse interop recovery skipped", { reason, skippedReason: "no-window" }); + return; + } + + debug("pet.default", "mouse interop recovery requested", { windowId: defaultPetWindow.id, reason, petId: getAppStateSnapshot().preferences.defaultPetId }); + recoverPetMouseInterop(defaultPetWindow, reason); +} + export function applyExternalPetReaction(reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } { if (paused) { return { shown: false, reason: "paused" }; @@ -112,6 +122,7 @@ export function installDefaultPetDisplayHandlers(): void { screen.on("display-added", reclampDefaultPetWindow); screen.on("display-removed", reclampDefaultPetWindow); screen.on("display-metrics-changed", reclampDefaultPetWindow); + powerMonitor.on("resume", recoverDefaultPetWindowAfterResume); } function handleBubbleDismissed(dismissToken: string): void { @@ -248,6 +259,12 @@ function reclampDefaultPetWindow(): void { info("pet.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition }); defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); setDefaultPetPosition(safePosition); + recoverDefaultPetMouseInterop("display-change"); +} + +function recoverDefaultPetWindowAfterResume(): void { + recoverDefaultPetMouseInterop("power-resume"); + setTimeout(() => recoverDefaultPetMouseInterop("power-resume+500ms"), 500).unref?.(); } export function shouldOpenDefaultPetOnLaunch(): boolean { diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts index 23cf50a7..3bfc9823 100644 --- a/apps/desktop/src/pet-window.ts +++ b/apps/desktop/src/pet-window.ts @@ -53,6 +53,7 @@ const petWindowRenderCache = new WeakMap(); const windowLoadChains = new WeakMap>(); const windowLoadSequences = new WeakMap(); +const petMouseInteropRecovery = new WeakMap void>(); export function createDefaultPetWindow(options: DefaultPetWindowOptions, dismissToken?: string): BrowserWindow { const window = createBasePetWindow("OpenPets — Default Pet", options.position); @@ -91,6 +92,17 @@ export function createAgentPetWindow(options: AgentPetWindowOptions, dismissToke return window; } +export function recoverPetMouseInterop(window: BrowserWindow, reason: string): void { + if (window.isDestroyed()) return; + const recover = petMouseInteropRecovery.get(window); + if (recover) { + recover(reason); + return; + } + + debug("pet.window", "mouse interop recovery skipped", { windowId: window.id, reason, skippedReason: "unregistered-window" }); +} + function installPetContextMenu(window: BrowserWindow, action: { readonly label: string; readonly click: () => void }): void { const webContents = window.webContents; const handleContextMenu = (event: Electron.Event): void => { @@ -114,6 +126,26 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed 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("pet.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; @@ -177,7 +209,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed rearmTimers.add(timer); }; - const rearmPassthroughAfterLoad = (): void => { + const rearmPassthrough = (reason: string): void => { if (window.isDestroyed()) return; if (process.platform !== "win32") { setPassthrough(true); @@ -189,9 +221,13 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed // 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); + 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 => { @@ -270,6 +306,7 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed ipcMain.off("openpets:pet-drag-end", handleDragEnd); ipcMain.off("openpets:bubble-dismissed", handleBubbleDismissed); clearRearmTimers(); + petMouseInteropRecovery.delete(window); if (!webContents.isDestroyed()) { webContents.off("did-start-navigation", resetForNavigation); webContents.off("did-start-loading", resetForNavigation); @@ -279,6 +316,8 @@ function installMousePassthroughAndDrag(window: BrowserWindow, onBubbleDismissed } }; + petMouseInteropRecovery.set(window, scheduleMouseInteropRecovery); + ipcMain.on("openpets:pet-ready", handleReady); ipcMain.on("openpets:pet-hit-test", handleHitTest); ipcMain.on("openpets:pet-drag-start", handleDragStart); diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts index 59a28f7a..dfad1498 100644 --- a/apps/desktop/src/windows.ts +++ b/apps/desktop/src/windows.ts @@ -9,7 +9,7 @@ import { refreshAgentPetContent } from "./agent-pet-controller.js"; import { completeOnboarding, getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js"; import { getCatalogPageUiState, getCatalogSearchUiState, getCatalogUiState } from "./catalog.js"; import { getCodexPetsUiState, importCodexPet, readCodexPetSpritesheet } from "./codex-pets.js"; -import { refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js"; +import { recoverDefaultPetMouseInterop, refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js"; import { installPet, removePet, setDefaultInstalledPet } from "./pet-installation.js"; import { getInstalledPetDir } from "./pet-paths.js"; import { defaultPetSprite, reactionAnimationMetadata, selectableAnimationMetadata, validateReactionAnimationOverrides } from "./reaction-animation-mapping.js"; @@ -175,6 +175,8 @@ export function installInternalUiHandlers(): void { const state = await setDefaultInstalledPet(petId); refreshDefaultPetContent(); + recoverDefaultPetMouseInterop("default-pet-changed"); + setTimeout(() => recoverDefaultPetMouseInterop("default-pet-changed+500ms"), 500).unref?.(); return state; });