Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/src/agent-pet-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, BrowserWindow>();
const transientPresentations = new Map<string, PetTransientPresentation>();
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/src/default-pet-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
3 changes: 2 additions & 1 deletion apps/desktop/src/lan-pet-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/pet-window-context-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/plugin-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/plugin-pet-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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().
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/window-position.ts
Original file line number Diff line number Diff line change
@@ -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<BrowserWindow, "setPosition">,
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;
}
50 changes: 50 additions & 0 deletions apps/desktop/tests/window-position.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
Loading