diff --git a/apps/desktop/src/agent-pet-controller.ts b/apps/desktop/src/agent-pet-controller.ts index 6497d1d4..97a7afd0 100644 --- a/apps/desktop/src/agent-pet-controller.ts +++ b/apps/desktop/src/agent-pet-controller.ts @@ -10,6 +10,7 @@ import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol. import { clearTransientReaction, createAgentPetWindow, getTransientDisplayDurationMs, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, showPetWindowInactive, type PetShowMediaOptions, type PetTransientDisplay } from "./pet-window.js"; import { focusTerminalWindow } from "./terminal-focus.js"; import { createPetTransientPresentation, type PetTransientPresentation } from "./pet-transient-presentation.js"; +import { setWindowPosition } from "./window-position.js"; const agentPetWindows = new Map(); const transientPresentations = new Map(); @@ -48,7 +49,7 @@ export function repositionConfinedPet(petId: string, win?: BrowserWindow): void const clamped = clampToTerminalBounds({ x: cx, y: cy }, defaultPetWindowSize, confinementBounds); if (clamped.x !== cx || clamped.y !== cy) { debug("pet.agent", "reposition confined", { petId, from: { x: cx, y: cy }, to: clamped }); - window.setPosition(clamped.x, clamped.y, false); + setWindowPosition(window, clamped.x, clamped.y, false); } } @@ -141,7 +142,7 @@ export function reclampAgentPetWindows(reason?: DisplayChangeReason): void { const [currentX, currentY] = window.getPosition(); if (safePosition.x !== currentX || safePosition.y !== currentY) { info("pet.agent", "reclamp position", { petId, windowId: window.id, from: { x: currentX, y: currentY }, to: safePosition }); - window.setPosition(safePosition.x, safePosition.y, false); + setWindowPosition(window, safePosition.x, safePosition.y, false); } } // A live display-scale change invalidates the Linux setShape() click-through diff --git a/apps/desktop/src/default-pet-controller.ts b/apps/desktop/src/default-pet-controller.ts index 9292704e..e6d306ad 100644 --- a/apps/desktop/src/default-pet-controller.ts +++ b/apps/desktop/src/default-pet-controller.ts @@ -16,6 +16,7 @@ import { composeVoiceActivityBadge, composeVoiceActivityDisplay } from "./voice- import { createPetTransientPresentation, type PetTransientPresentation } from "./pet-transient-presentation.js"; import type { ManagerCheckInOffer } from "./manager-check-in-service.js"; import type { DisplayChangeReason } from "./pet-display-coordinator.js"; +import { setWindowPosition } from "./window-position.js"; let defaultPetWindow: BrowserWindow | null = null; let paused = false; @@ -514,10 +515,10 @@ async function moveDefaultPetBy(rawX: number, rawY: number, rawDurationMs: unkno const blocked = getMovementBlockedReason(window, true); if (blocked) return { moved: false, reason: blocked }; const t = step / steps; - window.setPosition(Math.round(current.x + (target.x - current.x) * t), Math.round(current.y + (target.y - current.y) * t), false); + setWindowPosition(window, Math.round(current.x + (target.x - current.x) * t), Math.round(current.y + (target.y - current.y) * t), false); await delay(durationMs / steps); } - window.setPosition(target.x, target.y, false); + setWindowPosition(window, target.x, target.y, false); handlePositionChanged(target); debug("pet.default", "move finished", { windowId: window.id, target }); return { moved: true }; @@ -604,7 +605,7 @@ export function reclampDefaultPetWindow(reason: DisplayChangeReason, changedDisp : getSafeDefaultPetPosition(currentPosition); info("pet.default", "reclamp position", { windowId: defaultPetWindow.id, position: safePosition, restored: Boolean(restoredPosition), reason, changedDisplayKey }); - defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); + setWindowPosition(defaultPetWindow, safePosition.x, safePosition.y, false); handlePositionChanged(safePosition); recoverDefaultPetMouseInterop("display-change"); // A live display-scale change invalidates the Linux setShape() click-through mask @@ -626,6 +627,6 @@ export function resetDefaultPetToInitialPosition(): void { resetDefaultPetPosition(safePosition); if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { - defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); + setWindowPosition(defaultPetWindow, safePosition.x, safePosition.y, false); } } diff --git a/apps/desktop/src/lan-pet-controller.ts b/apps/desktop/src/lan-pet-controller.ts index 43b45ce9..339ffb83 100644 --- a/apps/desktop/src/lan-pet-controller.ts +++ b/apps/desktop/src/lan-pet-controller.ts @@ -8,6 +8,7 @@ import type { LanPetRecord, LanPoint } from "./lan-contract.js"; import { createAgentPetWindow, getTransientDisplayDurationMs, loadExplicitPetContent, readWindowPosition, showPetWindowInactive, type PetTransientDisplay } from "./pet-window.js"; import type { OpenPetsReaction } from "./local-ipc-protocol.js"; import { registerRoamingPet, unregisterRoamingPet } from "./pet-roaming-controller.js"; +import { setWindowPosition } from "./window-position.js"; type VisitingPetWindow = { readonly ownerHost: string; @@ -83,7 +84,7 @@ export function reclampLanVisitingPetWindows(): void { if (entry.window.isDestroyed()) continue; const safe = readWindowPosition(entry.window); const [x, y] = entry.window.getPosition(); - if (safe.x !== x || safe.y !== y) entry.window.setPosition(safe.x, safe.y, false); + if (safe.x !== x || safe.y !== y) setWindowPosition(entry.window, safe.x, safe.y, false); } } diff --git a/apps/desktop/src/pet-window-context-menu.ts b/apps/desktop/src/pet-window-context-menu.ts index ae4a1927..c5ba13d8 100644 --- a/apps/desktop/src/pet-window-context-menu.ts +++ b/apps/desktop/src/pet-window-context-menu.ts @@ -27,6 +27,7 @@ import { } from "./plugin-service.js"; import type { PluginCommandForm } from "./plugin-sdk-bridge.js"; import { escapeHtml } from "./pet-window-render.js"; +import { setWindowPosition } from "./window-position.js"; export function installPetContextMenu( window: BrowserWindow, @@ -429,7 +430,7 @@ async function openPluginCommandForm( display.workArea.y + display.workArea.height - bounds.height, ); if (nextX !== bounds.x || nextY !== bounds.y) { - window.setPosition(Math.round(nextX), Math.round(nextY)); + setWindowPosition(window, nextX, nextY); } }); window.once("closed", () => { diff --git a/apps/desktop/src/plugin-delivery.ts b/apps/desktop/src/plugin-delivery.ts index ffc283aa..42454fe1 100644 --- a/apps/desktop/src/plugin-delivery.ts +++ b/apps/desktop/src/plugin-delivery.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from "electron"; import { createRequire } from "node:module"; import { join } from "node:path"; import { getDisplayKey } from "./display.js"; +import { setWindowPosition } from "./window-position.js"; const require = createRequire(import.meta.url); @@ -807,7 +808,7 @@ async function createOrUpdateAirmailWindow(displayKey: string, activeItem: Queue await window.loadURL(targetUrl); if (window.isDestroyed() || getActiveItem()?.generationId !== activeItem.generationId) return; - window.setPosition(x_start, y_pos); + setWindowPosition(window, x_start, y_pos); window.showInactive(); const [visibleXStart, visibleYStart] = window.getPosition(); logDebug("ui", "delivery flight starts from visible window position", { displayKey, requestedXStart: x_start, requestedYStart: y_pos, visibleXStart, visibleYStart, xEnd: x_end }); @@ -837,7 +838,7 @@ async function createOrUpdateAirmailWindow(displayKey: string, activeItem: Queue const t = easeLinear(progress); const x = Math.round(visibleXStart + (x_end - visibleXStart) * t); - window.setPosition(x, visibleYStart); + setWindowPosition(window, x, visibleYStart); if (progress >= 1) { clearInterval(animTimer); @@ -866,7 +867,7 @@ async function createOrUpdateAirmailWindow(displayKey: string, activeItem: Queue await window.loadURL(targetUrl); if (window.isDestroyed() || getActiveItem()?.generationId !== activeItem.generationId) return; // Set straight to parked/waiting if we did an in-place update - window.setPosition(x_end, y_pos); + setWindowPosition(window, x_end, y_pos); window.webContents.send("openpets:pet-reaction-state", "waiting"); } } catch (err) { diff --git a/apps/desktop/src/plugin-pet-registry.ts b/apps/desktop/src/plugin-pet-registry.ts index fc1fc50d..b0527dce 100644 --- a/apps/desktop/src/plugin-pet-registry.ts +++ b/apps/desktop/src/plugin-pet-registry.ts @@ -13,6 +13,7 @@ import { publishPluginPetEvent } from "./plugin-events-source.js"; import { resolveReactionSpriteState } from "./reaction-animation-mapping.js"; import type { PluginAnimationSpec, PluginPetInfo, PluginPetState } from "./plugin-sdk-bridge.js"; import type { DisplayChangeReason } from "./pet-display-coordinator.js"; +import { setWindowPosition } from "./window-position.js"; /** * Multi-pet registry (ยง4): addressable pet handles for plugins. "default" is @@ -367,7 +368,7 @@ export function reclampPluginPetWindows(reason?: DisplayChangeReason): void { if (!window || window.isDestroyed()) continue; const [cx, cy] = window.getPosition(); const safe = readWindowPosition(window); - if (safe.x !== cx || safe.y !== cy) window.setPosition(safe.x, safe.y, false); + if (safe.x !== cx || safe.y !== cy) setWindowPosition(window, safe.x, safe.y, false); // A live display-scale change invalidates the Linux setShape() click-through // mask for plugin pet windows too, not just the default pet -- see the // matching fix in default-pet-controller.ts's reclampDefaultPetWindow(). diff --git a/apps/desktop/src/window-position.ts b/apps/desktop/src/window-position.ts new file mode 100644 index 00000000..dd41756a --- /dev/null +++ b/apps/desktop/src/window-position.ts @@ -0,0 +1,27 @@ +import type { BrowserWindow } from "electron"; + +/** + * Electron's BrowserWindow.setPosition rejects -0 and non-finite numbers with + * "Error processing argument at index N, conversion failure from", which + * surfaces as an uncaught main-process exception. Math.round() returns -0 for + * values in [-0.5, 0), so any animation that crosses x=0 or y=0 can hit it. + */ +export function toWindowCoordinate(value: number): number | null { + if (!Number.isFinite(value)) return null; + return Math.round(value) + 0; // + 0 turns -0 into 0 +} + +/** setPosition that never throws on -0/NaN. Returns false if the move was skipped. */ +export function setWindowPosition( + window: Pick, + x: number, + y: number, + animate?: boolean, +): boolean { + const safeX = toWindowCoordinate(x); + const safeY = toWindowCoordinate(y); + if (safeX === null || safeY === null) return false; + if (animate === undefined) window.setPosition(safeX, safeY); + else window.setPosition(safeX, safeY, animate); + return true; +} diff --git a/apps/desktop/tests/window-position.test.ts b/apps/desktop/tests/window-position.test.ts new file mode 100644 index 00000000..95683f30 --- /dev/null +++ b/apps/desktop/tests/window-position.test.ts @@ -0,0 +1,50 @@ +/** + * Unit tests for window-position.ts. + * + * Electron's setPosition throws on -0 and NaN; setWindowPosition must never + * pass either through. No Electron process is required. + */ + +import assert from "node:assert/strict"; + +import { setWindowPosition, toWindowCoordinate } from "../src/window-position.js"; + +function recorder() { + const calls: unknown[][] = []; + return { calls, window: { setPosition: (...args: unknown[]) => { calls.push(args); } } as any }; +} + +// Math.round(-0.3) is -0; the helper must hand Electron a plain 0. +assert.ok(Object.is(Math.round(-0.3), -0), "precondition: Math.round yields -0"); +assert.ok(Object.is(toWindowCoordinate(-0.3), 0), "-0.3 -> +0"); +assert.ok(Object.is(toWindowCoordinate(-0), 0), "-0 -> +0"); +assert.equal(toWindowCoordinate(-480), -480); +assert.equal(toWindowCoordinate(12.6), 13); +assert.equal(toWindowCoordinate(Number.NaN), null); +assert.equal(toWindowCoordinate(Number.POSITIVE_INFINITY), null); + +// Airmail flight: -480 -> 2064 over 15 s at 16 ms ticks. Tick 176 (2828 ms) rounds to -0. +{ + const { calls, window } = recorder(); + const x = Math.round(-480 + (2064 - -480) * (2828 / 15_000)); + assert.ok(Object.is(x, -0), "precondition: airmail tick lands on -0"); + assert.equal(setWindowPosition(window, x, 240), true); + assert.ok(Object.is(calls[0]?.[0], 0), "airmail -0 passed through as +0"); + assert.deepEqual(calls[0], [0, 240]); +} + +// animate flag is forwarded only when given. +{ + const { calls, window } = recorder(); + setWindowPosition(window, 10, 20, false); + assert.deepEqual(calls[0], [10, 20, false]); +} + +// Non-finite coordinates skip the move instead of throwing. +{ + const { calls, window } = recorder(); + assert.equal(setWindowPosition(window, Number.NaN, 0), false); + assert.equal(calls.length, 0); +} + +console.error("window-position.test.ts: all window position tests passed.");