Fix Windows pet drag recovery

This commit is contained in:
Alvin Unreal 2026-05-17 21:13:01 +02:00
parent 5ae609429c
commit e3babcbbe0
4 changed files with 72 additions and 9 deletions

View file

@ -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.");

View file

@ -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 {

View file

@ -53,6 +53,7 @@ const petWindowRenderCache = new WeakMap<BrowserWindow, string>();
const windowLoadChains = new WeakMap<BrowserWindow, Promise<void>>();
const windowLoadSequences = new WeakMap<BrowserWindow, number>();
const petMouseInteropRecovery = new WeakMap<BrowserWindow, (reason: string) => 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);

View file

@ -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;
});