blob: 30567382e04cdb5530580f4f752929e384dc62db [file]
/**
* Cursor overlay window (main-process half) — the Maka-owned, Codex-style agent
* cursor. A transparent, always-on-top, click-through BrowserWindow that hosts a
* Canvas running the ported CursorEngine (Dubins glide + spring). MAIN drives it
* with per-action coordinates; the window persists across actions and repositions
* the cursor live over a one-way `overlay:move` channel (no teardown-per-move).
*
* Path 18 gates:
* - S13: action/session-scoped lifecycle; teardown is synchronous + event-driven,
* no timer keeps it alive.
* - S14 (load-bearing): `focusable:false` + `setIgnoreMouseEvents(true,{forward:true})`
* armed BEFORE show + `showInactive()` (never `.focus()`). The preload is
* RECEIVE-ONLY (main→renderer), so the overlay can never call back / inject.
* - S15: MAIN owns coordinates; the renderer only paints what MAIN sends.
* - S18: teardown is a single synchronous `destroy()`.
*
* Electron is required lazily so the module loads under `node --test`; tests
* inject a fake window factory + bounds resolver.
*/
import { createRequire } from 'node:module';
import { join } from 'node:path';
import type { BrowserWindowConstructorOptions, Rectangle } from 'electron';
import type { CuPresentationFence } from '@maka/runtime';
import { resolveOverlayAssetDir } from '../overlay-assets.js';
import { cursorPresentationReadyDeadlineMs } from '../../renderer/computer-use-overlay/engine/cursor-engine.js';
const requireElectron = createRequire(import.meta.url);
// Shared cursor-move contract lives in @maka/computer-use so the CLI can drive the
// same hook against a headless sink. This controller is the Electron implementation
// of that sink (it also satisfies OverlayCursorSink structurally via ensure/move).
export type { CursorMoveInput } from '@maka/computer-use';
import type {
CursorCancelInput,
CursorCompleteInput,
CursorMoveInput,
} from '@maka/computer-use';
/** Minimal window surface the controller drives (fake-able in node --test). */
export interface CursorOverlayWindowLike {
setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void;
setAlwaysOnTop(flag: boolean, level?: string, relativeLevel?: number): void;
/** `orderWindow(.above, relativeTo:)`, by media source id (`window:<id>:0`). */
moveAbove(mediaSourceId: string): void;
setVisibleOnAllWorkspaces(visible: boolean, options?: { visibleOnFullScreen?: boolean }): void;
loadFile(path: string): Promise<void>;
showInactive(): void;
isDestroyed(): boolean;
destroy(): void;
/** webContents.send — the one-way main→renderer push. */
send(channel: string, payload: unknown): void;
/** Fire cb once the page has loaded (webContents 'did-finish-load'). */
onReady(cb: () => void): void;
onGone(cb: () => void): void;
onPresentationPhase(
cb: (payload: {
sessionId: string;
generation: number;
actionId: string;
phase: 'readyForInteraction' | 'finished';
}) => void,
): void;
}
export interface CreateCursorOverlayControllerDeps {
createOverlayWindow?: (options: BrowserWindowConstructorOptions) => CursorOverlayWindowLike;
resolveOverlayBounds?: () => Rectangle;
/** Absolute path to the built overlay preload (dist/overlay/cursor-overlay-preload.cjs). */
preloadPath?: string;
/** Absolute path to the built overlay html (dist/overlay/cursor-overlay.html). */
htmlPath?: string;
onDisplayFrame?: (input: { actionId: string; completedAt: number; displayedAt: number }) => void;
subscribeDisplayChanges?: (cb: () => void) => () => void;
}
export interface CursorOverlayController {
/** Lazily create/refresh the overlay for a session (palette from sessionId). */
ensure(sessionId: string): void;
/** Move the cursor to a per-action screen coordinate (creates the window if needed). */
move(input: CursorMoveInput): CuPresentationFence;
/** Reconcile the display with the coordinate where backend execution completed. */
complete(input: CursorCompleteInput): void;
/** Finish a failed presentation without inventing a completion coordinate. */
cancel(input: CursorCancelInput): void;
/** Per-session teardown (the clearComputerUseOverlay(sessionId) bag). */
clearForSession(sessionId: string): void;
/** User abort (Esc) — tears down when actionId matches the live overlay. */
abort(actionId: string): void;
/** Unconditional teardown (window close / quit). */
destroyAll(): void;
isActive(): boolean;
getSessionId(): string | null;
}
/**
* Two window levels, one per reason class, ported from Codex's
* `OverlayWindowLevelReasons`.
*
* FLIGHT is `NSPopUpMenuWindowLevel + 1` = 102 = `kCGOverlayWindowLevel`, the
* exact level Codex raises its cursor to while a reason holds. Above every
* application window and above open menus (the agent drives menus, so the
* cursor has to be visible over them), below the screen saver and below the
* real hardware cursor. `'screen-saver'` — which this used to sit at
* unconditionally — is 1000, high enough to paint over the screen saver and
* over system alerts.
*
* REST is Codex's ordering, done the way Codex does it. Codex sinks the cursor
* to the TARGET window's own level and calls `orderWindow(.above, relativeTo:)`
* with the target's window number, so a window later raised over the target
* covers the cursor too.
*
* This comment used to say Electron exposes no ordering relative to a foreign
* window, and settled for `'floating'` (3) — above every ordinary application
* window. That is the level a person reported twice as the cursor "leaking to
* the foreground": an arrow hanging over whatever they were reading, pointing
* at a window buried behind it.
*
* The claim was false for this Electron. `BrowserWindow.moveAbove(mediaSourceId)`
* parses `window:<CGWindowID>:<n>` and calls
* `orderWindowByShuffling:NSWindowAbove relativeTo:` — the same AppKit
* primitive. Verified against foreign windows owned by other processes:
*
* - `moveAbove` while always-on-top is a silent no-op. AppKit ordering is
* confined within a window level, so the level has to go first.
* - `setAlwaysOnTop(false)` alone is also not enough: it drops to layer 0 but
* leaves the overlay at the FRONT of it, still covering everything.
* - The two together both promote and demote, landing the overlay
* immediately above the named window, still unfocused.
* - An id that no longer names a window throws; a window that is alive but
* not in the ordered list (hidden, minimised, another Space) returns ok and
* orders the overlay to the front of its layer.
*
* `CURSOR_LEVEL.rest` therefore stays, as the fallback for the two cases where
* ordering cannot be done: no usable target id, and a reorder that threw.
*/
const CURSOR_LEVEL = {
flight: { name: 'pop-up-menu', relative: 1 },
rest: { name: 'floating', relative: 0 },
} as const;
type CursorLevel = keyof typeof CURSOR_LEVEL;
/** S14 window options — the focus/click-through contract surface, one literal. */
export function cursorOverlayWindowOptions(bounds: Rectangle, preloadPath: string): BrowserWindowConstructorOptions {
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
focusable: false, // S14: never take keyboard focus from the driven app
transparent: true,
frame: false,
hasShadow: false,
alwaysOnTop: true,
skipTaskbar: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
acceptFirstMouse: false,
show: false, // shown via showInactive() only after click-through is armed
backgroundColor: '#00000000',
enableLargerThanScreen: true,
webPreferences: {
// Receive-only preload: exposes ipcRenderer.on callbacks, never send/invoke.
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
};
}
export function createCursorOverlayController(
deps: CreateCursorOverlayControllerDeps = {},
): CursorOverlayController {
const createOverlayWindow = deps.createOverlayWindow ?? defaultCreateOverlayWindow;
const resolveOverlayBounds = deps.resolveOverlayBounds ?? defaultResolveOverlayBounds;
const overlayAssetDir = resolveOverlayAssetDir(import.meta.url);
const preloadPath = deps.preloadPath ?? join(overlayAssetDir, 'cursor-overlay-preload.cjs');
const htmlPath = deps.htmlPath ?? join(overlayAssetDir, 'cursor-overlay.html');
const subscribeDisplayChanges = deps.subscribeDisplayChanges
?? defaultSubscribeDisplayChanges;
let win: CursorOverlayWindowLike | null = null;
let sessionId: string | null = null;
let actionId: string | null = null;
let bounds: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
let ready = false;
let generation = 0;
let level: CursorLevel | null = null;
/**
* Codex's `petLaunch` reason, minus the launch: it is cleared when the motion
* settles, and only if the observation said the target is exposed under the
* cursor. Sticky across a settle we never hear about (cancel, teardown,
* a renderer that goes away), because staying up is the safe direction.
*/
let mayRest = false;
/**
* The window the resting cursor should sit directly above.
*
* Held rather than passed, because the rest transition happens later than the
* move that knows it: the renderer reports `'finished'` and only then does the
* cursor sink.
*/
let restTargetWindowId: number | null = null;
/**
* The window the overlay was last actually ordered above, so a rest that
* names a different window is recognised as a new state rather than a repeat
* of the level it is already at.
*/
let orderedWindowId: number | null = null;
let queue: Array<{ channel: string; payload: unknown }> = [];
let unsubscribeDisplayChanges: (() => void) | undefined;
let presentation:
| {
actionId: string;
ready: () => void;
finish: () => void;
fence: CuPresentationFence;
completedAt?: number;
}
| undefined;
function createPresentation(action: string): NonNullable<typeof presentation> {
let readyForInteraction!: () => void;
let finished!: () => void;
const fence: CuPresentationFence = {
readyForInteraction: new Promise<void>((resolve) => { readyForInteraction = resolve; }),
finished: new Promise<void>((resolve) => { finished = resolve; }),
// What the renderer's release gate needs at worst, derived from the very
// spring that gate is read against. The runtime's own backstop was picked
// independently and is shorter than a long move takes, so it used to
// resolve the action with the glyph still hundreds of points out.
readyTimeoutMs: cursorPresentationReadyDeadlineMs(),
};
return {
actionId: action,
ready: readyForInteraction,
finish: finished,
fence,
};
}
function settlePresentation(): void {
presentation?.ready();
presentation?.finish();
presentation = undefined;
}
function applyLevel(next: CursorLevel): void {
if (!win) return;
// Resting is not a level alone, it is a level plus a position in another
// window's z-order, so "already resting" is not the same state when the
// window to rest above has changed. Re-order in that case; a plain repeat
// of the same level against the same window is still a no-op.
if (level === next && !(next === 'rest' && restTargetWindowId !== orderedWindowId)) return;
// Both calls are needed and the order matters: the level has to come off
// before AppKit will order across it, and dropping the level without
// ordering leaves the overlay at the front of the layer, covering exactly
// what it was supposed to stop covering.
if (next === 'rest' && restTargetWindowId !== null) {
win.setAlwaysOnTop(false);
try {
win.moveAbove(`window:${restTargetWindowId}:0`);
orderedWindowId = restTargetWindowId;
} catch {
// The target went away between the action and the settle. Without
// putting the level back the cursor is stranded at normal level,
// buried, and stays there until the next action.
win.setAlwaysOnTop(true, CURSOR_LEVEL.rest.name, CURSOR_LEVEL.rest.relative);
orderedWindowId = null;
}
level = next;
return;
}
level = next;
orderedWindowId = null;
const { name, relative } = CURSOR_LEVEL[next];
// The flag decides whether a level applies at all. `false` here would drop
// the overlay to normal level with no ordering guarantee against the app
// being driven — which is what the rest branch above pairs with an order.
win.setAlwaysOnTop(true, name, relative);
}
function teardown(): void {
const w = win;
win = null;
sessionId = null;
actionId = null;
settlePresentation();
ready = false;
level = null;
mayRest = false;
restTargetWindowId = null;
orderedWindowId = null;
queue = [];
unsubscribeDisplayChanges?.();
unsubscribeDisplayChanges = undefined;
if (w && !w.isDestroyed()) w.destroy();
}
function push(channel: string, payload: unknown): void {
if (!win) return;
if (ready) win.send(channel, payload);
else queue.push({ channel, payload });
}
function ensure(nextSessionId: string): void {
if (typeof nextSessionId !== 'string' || nextSessionId.length === 0) return;
if (win && !win.isDestroyed() && sessionId === nextSessionId) return;
// Different session (or dead window) → supersede so no orphan survives.
if (win) teardown();
bounds = resolveOverlayBounds();
generation += 1;
const windowGeneration = generation;
const w = createOverlayWindow(cursorOverlayWindowOptions(bounds, preloadPath));
// S14 (load-bearing): arm click + focus pass-through BEFORE the window shows.
w.setIgnoreMouseEvents(true, { forward: true });
win = w;
level = null;
mayRest = false;
orderedWindowId = null;
applyLevel('flight');
try {
w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
} catch {
/* not supported everywhere; best-effort */
}
sessionId = nextSessionId;
ready = false;
queue = [];
// Rebuild only when the screen area this window has to cover has actually
// moved. The overlay spans the union of every display's *bounds*, and the
// event this subscribes to reports any metrics change at all — including
// `workArea`, which twitches by a couple of points whenever the menu bar or
// the Dock changes shape and says nothing about where the displays are.
//
// Tearing down on the bare event destroyed the overlay for reasons that had
// nothing to do with it. Measured on this machine with a second display
// attached: `display-metrics-changed` arrives with `["workArea"]` and the
// primary display's work area alternating between 875 and 873 points high,
// roughly every two seconds, with no windows open at all. The overlay is
// created when an action begins and its HTML takes longer than that to
// load, so the window was routinely destroyed before `onReady` ever fired —
// never shown, never on screen, and reported by the scenario harness as
// "the agent cursor overlay — never appeared" on any turn short enough to
// dispatch only once.
unsubscribeDisplayChanges = subscribeDisplayChanges(() => {
if (win !== w) return;
if (sameRect(resolveOverlayBounds(), bounds)) return;
teardown();
});
w.onReady(() => {
if (win !== w) return; // superseded during load
ready = true;
w.send('overlay:reset', {
sessionId: nextSessionId,
generation: windowGeneration,
});
for (const m of queue) w.send(m.channel, m.payload);
queue = [];
w.showInactive();
});
w.onGone(() => {
if (win === w) teardown();
});
w.onPresentationPhase((payload) => {
if (
win !== w
|| payload.sessionId !== sessionId
|| payload.generation !== windowGeneration
|| !presentation
|| payload.actionId !== presentation.actionId
) return;
if (payload.phase === 'readyForInteraction') presentation.ready();
else {
const completed = presentation;
presentation = undefined;
completed.ready();
completed.finish();
// The motion has landed. Codex clears `petLaunch` here, and only here.
if (mayRest) applyLevel('rest');
if (completed.completedAt !== undefined) {
deps.onDisplayFrame?.({
actionId: completed.actionId,
completedAt: completed.completedAt,
displayedAt: Date.now(),
});
}
if (actionId === completed.actionId) actionId = null;
}
});
void w.loadFile(htmlPath).catch(() => {
if (win === w) teardown();
});
}
function resolveRestTarget(targetWindowId: number | undefined): number | null {
return Number.isInteger(targetWindowId) && (targetWindowId as number) > 0
? (targetWindowId as number)
: null;
}
function move(input: CursorMoveInput): CuPresentationFence {
if (
typeof input.sessionId !== 'string'
|| input.sessionId.length === 0
|| !Number.isFinite(input.screenX)
|| !Number.isFinite(input.screenY)
) {
return { readyForInteraction: Promise.resolve(), finished: Promise.resolve() };
}
ensure(input.sessionId);
settlePresentation();
actionId = input.actionId;
restTargetWindowId = resolveRestTarget(input.targetWindowId);
// A launch: up while it flies, and it stays up unless this action's
// observation positively said the target is exposed right where the cursor
// lands. Silence is not that evidence.
mayRest = input.keepElevated === false;
applyLevel('flight');
const nextPresentation = createPresentation(input.actionId);
presentation = nextPresentation;
// Screen → window-local so the renderer paints at origin (0,0).
push('overlay:move', {
actionId: input.actionId,
x: input.screenX - bounds.x,
y: input.screenY - bounds.y,
kind: input.kind,
pressed: input.pressed === true,
...(input.instant === true ? { instant: true } : {}),
});
return nextPresentation.fence;
}
function complete(input: CursorCompleteInput): void {
if (typeof input.sessionId !== 'string' || input.sessionId.length === 0) return;
if (!Number.isFinite(input.screenX) || !Number.isFinite(input.screenY)) return;
if (input.sessionId !== sessionId) return;
if (presentation && input.actionId !== presentation.actionId) return;
actionId = input.actionId;
// The landing is the last thing that happens before the cursor sinks, and
// it is allowed to name a different window than the move did: an action
// whose `onActionBegin` never ran (no presentation point to fly to) reaches
// here with the only window id this action ever carried. Reading it only in
// `move()` left the sink ordering the cursor above the *previous* action's
// window — behind the one it is now pointing at.
const landingTarget = resolveRestTarget(input.targetWindowId);
if (landingTarget !== null) restTargetWindowId = landingTarget;
if (presentation?.actionId === input.actionId) {
// Reconciling to the executor's coordinate is another launch, so the
// cursor goes back up for the trip. Only while a presentation is live:
// without one there is no `finished` to come back down on, and a level
// raised with no way down is the static `'screen-saver'` again.
applyLevel('flight');
presentation.completedAt = Date.now();
} else if (level === 'rest') {
// No presentation means no `finished` will arrive to sink the cursor, so
// this is the only chance to put it above the window it just landed on.
// Without it the cursor stays ordered above the previous action's window
// — behind the one it is now pointing at.
applyLevel('rest');
}
push('overlay:complete', {
actionId: input.actionId,
x: input.screenX - bounds.x,
y: input.screenY - bounds.y,
kind: input.kind,
pulse: input.pulse,
});
}
function cancel(input: CursorCancelInput): void {
if (typeof input.sessionId !== 'string' || input.sessionId.length === 0) return;
if (input.sessionId !== sessionId || input.actionId !== actionId) return;
push('overlay:cancel', { actionId: input.actionId });
}
function clearForSession(id: string): void {
if (typeof id !== 'string' || id.length === 0) return;
if (id !== sessionId) return;
teardown();
}
function abort(id: string): void {
if (typeof id !== 'string' || id.length === 0) return;
if (id !== actionId) return;
teardown();
}
return {
ensure,
move,
complete,
cancel,
clearForSession,
abort,
destroyAll: teardown,
isActive: () => win !== null,
getSessionId: () => sessionId,
};
}
function defaultCreateOverlayWindow(options: BrowserWindowConstructorOptions): CursorOverlayWindowLike {
const { BrowserWindow } = requireElectron('electron') as typeof import('electron');
const bw = new BrowserWindow(options);
return {
setIgnoreMouseEvents: (ignore, opts) => bw.setIgnoreMouseEvents(ignore, opts),
setAlwaysOnTop: (flag, level, relativeLevel) =>
bw.setAlwaysOnTop(flag, level as Parameters<typeof bw.setAlwaysOnTop>[1], relativeLevel),
// The id is built by hand rather than taken from `desktopCapturer`: that
// would raise a Screen Recording prompt for a string the frame binding
// already gave us.
moveAbove: (mediaSourceId) => bw.moveAbove(mediaSourceId),
setVisibleOnAllWorkspaces: (visible, opts) => bw.setVisibleOnAllWorkspaces(visible, opts),
loadFile: (path) => bw.loadFile(path),
showInactive: () => bw.showInactive(),
isDestroyed: () => bw.isDestroyed(),
destroy: () => bw.destroy(),
send: (channel, payload) => { if (!bw.isDestroyed()) bw.webContents.send(channel, payload); },
onReady: (cb) => bw.webContents.once('did-finish-load', cb),
onGone: (cb) => {
bw.once('closed', cb);
bw.webContents.once('render-process-gone', cb);
bw.webContents.once('destroyed', cb);
},
onPresentationPhase: (cb) => {
bw.webContents.on('ipc-message', (_event, channel, payload) => {
if (channel !== 'overlay:presentation-phase') return;
if (!payload || typeof payload !== 'object') return;
const candidate = payload as Record<string, unknown>;
if (
typeof candidate.actionId !== 'string'
|| typeof candidate.sessionId !== 'string'
|| !Number.isInteger(candidate.generation)
|| (
candidate.phase !== 'readyForInteraction'
&& candidate.phase !== 'finished'
)
) return;
cb({
sessionId: candidate.sessionId,
generation: candidate.generation as number,
actionId: candidate.actionId,
phase: candidate.phase,
});
});
},
};
}
function defaultSubscribeDisplayChanges(cb: () => void): () => void {
const { screen } = requireElectron('electron') as typeof import('electron');
const added = () => cb();
const removed = () => cb();
const changed = () => cb();
screen.on('display-added', added);
screen.on('display-removed', removed);
screen.on('display-metrics-changed', changed);
return () => {
screen.removeListener('display-added', added);
screen.removeListener('display-removed', removed);
screen.removeListener('display-metrics-changed', changed);
};
}
function sameRect(a: Rectangle, b: Rectangle): boolean {
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
function defaultResolveOverlayBounds(): Rectangle {
const { screen } = requireElectron('electron') as typeof import('electron');
const displays = screen.getAllDisplays();
if (displays.length === 0) return screen.getPrimaryDisplay().bounds;
const left = Math.min(...displays.map((display) => display.bounds.x));
const top = Math.min(...displays.map((display) => display.bounds.y));
const right = Math.max(...displays.map(
(display) => display.bounds.x + display.bounds.width,
));
const bottom = Math.max(...displays.map(
(display) => display.bounds.y + display.bounds.height,
));
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
};
}