privateer-agent 0.12.30 → 0.12.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The platform helper — one long-lived child process that owns the screen and the
3
+ * input devices, spoken to in newline-delimited JSON over stdin/stdout.
4
+ *
5
+ * WHY A SEPARATE PROCESS AT ALL. Every OS puts GUI control behind a native API that
6
+ * Node cannot reach without a compiled addon: CGEvent/ScreenCaptureKit on macOS,
7
+ * SendInput/BitBlt on Windows, the X11 or Wayland protocols on Linux. The alternative
8
+ * to a helper is an npm native module (nut.js, robotjs), which means a prebuilt binary
9
+ * per platform AND per architecture riding in node_modules — fighting the pinned-Node
10
+ * self-contained bundles, code signing and notarization, and the Intel/ARM mac split
11
+ * that has already cost us one bad release. A helper we compile ourselves is one file
12
+ * per platform, signed with the app, with no install-time build step anywhere.
13
+ *
14
+ * WHY LONG-LIVED, not a process per action. Three reasons, all load-bearing:
15
+ * • TCC. macOS attributes Screen Recording and Accessibility grants to a process's
16
+ * signing identity; one persistent helper prompts the user once, where a
17
+ * process-per-click would make consent state harder to reason about and the
18
+ * latency of each grant check real.
19
+ * • Latency. A GUI loop is screenshot → think → click → screenshot, dozens of times.
20
+ * Paying ~80ms of process spawn on each one is most of a second per interaction.
21
+ * • The coordinate space. Resolution and downscale live in ONE conversation with ONE
22
+ * process, so the frame the model saw and the click that follows it cannot be
23
+ * served by two helpers that disagree (computer/space.ts explains why that matters
24
+ * more here than anywhere else).
25
+ *
26
+ * DEGRADING IS THE NORMAL CASE, NOT AN ERROR PATH. Phase 1 ships this client and no
27
+ * binaries; a platform with no helper must say so in a sentence the model can relay,
28
+ * the way `sfx.configured: false` and the sprite pipeline's ffmpeg probe already do —
29
+ * never fail at the moment of use with a spawn error, and never after having promised
30
+ * the user it would work.
31
+ *
32
+ * A FAILED PROBE IS RETRIED, NOT LATCHED. Same reasoning as
33
+ * services/sprites/frameExtract.js: a spawn can fail for reasons unrelated to the
34
+ * binary being absent (a grant not yet given, a helper still being installed by an
35
+ * update), and caching that would take screen control down until the process restarts.
36
+ */
37
+
38
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
39
+ import { existsSync } from "node:fs";
40
+ import { dirname, join } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import type { CapturePlan, Display } from "./space.ts";
43
+
44
+ /** Point the client at a specific helper binary. The desktop shell sets this to the copy inside its app bundle. */
45
+ export const HELPER_PATH_ENV = "PRIVATEER_COMPUTER_HELPER";
46
+
47
+ /** The name the helper ships under, on PATH or beside the bundle. */
48
+ const HELPER_BIN = process.platform === "win32" ? "privateer-computer.exe" : "privateer-computer";
49
+
50
+ /** A helper call that hasn't answered in this long is treated as wedged. Generous: a capture on a large display is real work. */
51
+ const CALL_TIMEOUT_MS = 20_000;
52
+ /** How long a failed spawn is remembered before we try again. */
53
+ const PROBE_COOLDOWN_MS = 30_000;
54
+
55
+ // ─── Wire protocol ───────────────────────────────────────────────────────────
56
+ // Requests carry an `id` the reply echoes; nothing here assumes replies arrive in
57
+ // order, because a capture and a grant check legitimately overlap.
58
+
59
+ export interface CaptureRequest {
60
+ op: "capture";
61
+ display: string;
62
+ /** The helper resizes natively to exactly this — see computer/space.ts on why the helper, not us. */
63
+ targetWidth: number;
64
+ targetHeight: number;
65
+ /**
66
+ * Optionally, a SECOND much smaller copy of the same grab, for the permission dialog.
67
+ *
68
+ * Asked for in the same call rather than taken separately, and that is the whole
69
+ * point: a second capture would be a second moment in time, so the picture a person
70
+ * approves a click against could show a screen the model never saw. One grab, two
71
+ * encodings — every helper already has the resize step.
72
+ */
73
+ previewWidth?: number;
74
+ previewHeight?: number;
75
+ }
76
+
77
+ export type PointerAction = "move" | "click" | "double_click" | "down" | "up" | "drag" | "scroll";
78
+ export type MouseButton = "left" | "right" | "middle";
79
+
80
+ export interface PointerRequest {
81
+ op: "pointer";
82
+ action: PointerAction;
83
+ /**
84
+ * Which display the coordinates belong to. REQUIRED, and not a convenience: x/y are
85
+ * local to one display's own device pixels, because a global device-pixel space does
86
+ * not exist on a mixed-DPI desktop (computer/space.ts, Display.originX). The helper
87
+ * knows that display's bounds and scale and is the only thing that converts into the
88
+ * OS's global point space.
89
+ */
90
+ display: string;
91
+ /** DEVICE pixels, origin at this display's top-left. From computer/space.ts and nowhere else. */
92
+ x: number;
93
+ y: number;
94
+ toX?: number;
95
+ toY?: number;
96
+ button?: MouseButton;
97
+ scrollX?: number;
98
+ scrollY?: number;
99
+ }
100
+
101
+ export interface KeyRequest {
102
+ op: "key";
103
+ action: "type" | "press";
104
+ /** Literal text to type, for action "type". */
105
+ text?: string;
106
+ /** A chord such as "cmd+shift+4" or a named key such as "return", for action "press". */
107
+ keys?: string;
108
+ }
109
+
110
+ export type HelperRequest =
111
+ | { op: "displays" }
112
+ | { op: "grants" }
113
+ | CaptureRequest
114
+ | PointerRequest
115
+ | KeyRequest;
116
+
117
+ /** What the OS currently permits. Every field is a fact about this machine, not a preference. */
118
+ export interface Grants {
119
+ /** Screen Recording (macOS TCC) or equivalent. False ⇒ capture returns a black or empty frame. */
120
+ screen: boolean;
121
+ /** Accessibility (macOS) / uiAccess (Windows). False ⇒ synthesized input is silently dropped by the OS. */
122
+ input: boolean;
123
+ /**
124
+ * A password field somewhere has secure input enabled. The OS suppresses synthesized
125
+ * keystrokes entirely while this is on, so a `type` would appear to succeed and put
126
+ * nothing anywhere. Reported so the refusal can name the real cause.
127
+ */
128
+ secureInput: boolean;
129
+ /** pid of the frontmost application, for the self-click interlock in tools/computer.ts. */
130
+ frontmostPid?: number;
131
+ }
132
+
133
+ export interface CaptureResult {
134
+ mimeType: string;
135
+ /** base64 — handed to the model as an ImageContent block. */
136
+ data: string;
137
+ width: number;
138
+ height: number;
139
+ /** base64 PNG at the requested preview size, when one was asked for and produced. */
140
+ preview?: string;
141
+ }
142
+
143
+ /** Why screen control isn't available here. Written for a user to read, not a log. */
144
+ export interface Unavailable {
145
+ available: false;
146
+ reason: string;
147
+ }
148
+ export interface Available {
149
+ available: true;
150
+ }
151
+ export type Availability = Available | Unavailable;
152
+
153
+ // ─── Locating the helper ─────────────────────────────────────────────────────
154
+ //
155
+ // THREE SHAPES, not one, and the difference is the platform's own. macOS needs a
156
+ // compiled binary (CGEvent has no scriptable equivalent), so it ships as a signed
157
+ // artifact inside the desktop app — the ONE helper the CLI does not carry. Windows and
158
+ // Linux are served by a PowerShell script and a Node script, which ride in this package
159
+ // and therefore work for the CLI as well as the app.
160
+
161
+ /** How to start the helper: a command, its arguments, and any environment it needs. */
162
+ export interface HelperCommand {
163
+ command: string;
164
+ args: string[];
165
+ env?: Record<string, string>;
166
+ }
167
+
168
+ /** The mode a helper is being started in. `serve` is the long-lived protocol loop. */
169
+ export type HelperMode = "serve" | "grants";
170
+
171
+ function nativeDir(): string {
172
+ // src/computer → the package root, then native/. Present in the npm package (see the
173
+ // `files` list) and inside the desktop's bundled copy of this package.
174
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "native");
175
+ }
176
+
177
+ /**
178
+ * The Node to run a .mjs helper with.
179
+ *
180
+ * Under Electron `process.execPath` is the app, not Node — so it is re-entered as Node
181
+ * through ELECTRON_RUN_AS_NODE, the same accommodation desktop/scripts already make for
182
+ * the CLI shim. Getting this wrong would launch a second copy of the whole application
183
+ * per session, which is a spectacular failure rather than a quiet one, but a failure
184
+ * worth not having.
185
+ */
186
+ function nodeCommand(): { command: string; env?: Record<string, string> } {
187
+ const electron = !!(process as any).versions?.electron;
188
+ return electron
189
+ ? { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" } }
190
+ : { command: process.execPath };
191
+ }
192
+
193
+ function scriptCommand(script: string, mode: HelperMode): HelperCommand {
194
+ if (script.endsWith(".ps1")) {
195
+ // -NoProfile so a user's PowerShell profile cannot print into our protocol stream;
196
+ // -NonInteractive so nothing can block waiting for input that will never come;
197
+ // -ExecutionPolicy Bypass because the default policy refuses unsigned local scripts
198
+ // and this one ships inside a package rather than being signed on its own.
199
+ return {
200
+ command: "powershell.exe",
201
+ args: [
202
+ "-NoProfile",
203
+ "-NonInteractive",
204
+ "-ExecutionPolicy", "Bypass",
205
+ "-File", script,
206
+ mode === "serve" ? "-Serve" : "-Grants",
207
+ ],
208
+ };
209
+ }
210
+ const node = nodeCommand();
211
+ return { command: node.command, args: [script, `--${mode}`], env: node.env };
212
+ }
213
+
214
+ /** Candidate paths for the compiled macOS helper, in the order they should win. */
215
+ function bundledCandidates(): string[] {
216
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
217
+ return [
218
+ join(root, "build-resources", "bin", process.platform, HELPER_BIN),
219
+ join(root, "bin", HELPER_BIN),
220
+ ];
221
+ }
222
+
223
+ /**
224
+ * How to start the helper on this machine, or undefined when there is none.
225
+ *
226
+ * The explicit override wins over everything, and on macOS that is the ordinary case:
227
+ * the desktop points it at the signed copy inside its own app bundle. That matters
228
+ * beyond tidiness — a signed helper inside the bundle inherits the app's TCC identity,
229
+ * where a stray `privateer-computer` on PATH is a different binary whose grants the user
230
+ * would have to give again, discovering the problem only as the feature not working.
231
+ */
232
+ export function findHelper(mode: HelperMode = "serve"): HelperCommand | undefined {
233
+ const override = process.env[HELPER_PATH_ENV]?.trim();
234
+ if (override) {
235
+ if (!existsSync(override)) return undefined;
236
+ if (override.endsWith(".ps1") || override.endsWith(".mjs")) return scriptCommand(override, mode);
237
+ return { command: override, args: [`--${mode}`] };
238
+ }
239
+
240
+ if (process.platform === "win32") {
241
+ const script = join(nativeDir(), "win", "PrivateerComputer.ps1");
242
+ return existsSync(script) ? scriptCommand(script, mode) : undefined;
243
+ }
244
+
245
+ if (process.platform === "linux") {
246
+ const script = join(nativeDir(), "linux", "privateer-computer.mjs");
247
+ return existsSync(script) ? scriptCommand(script, mode) : undefined;
248
+ }
249
+
250
+ for (const c of bundledCandidates()) {
251
+ if (existsSync(c)) return { command: c, args: [`--${mode}`] };
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ /**
257
+ * Platform-specific truth about why there is no helper. Worth saying precisely, because
258
+ * "screen control unavailable" with no reason is what sends someone hunting through
259
+ * settings that are already correct.
260
+ *
261
+ * Note the Linux and Windows cases are about the helper SCRIPT being absent, which
262
+ * should not happen in a working install — the interesting Linux failures (no xdotool,
263
+ * a GNOME Wayland session) are reported by the helper itself, which is the only thing
264
+ * that can see them.
265
+ */
266
+ function missingReason(): string {
267
+ switch (process.platform) {
268
+ case "darwin":
269
+ return (
270
+ "The macOS screen-control helper isn't installed with this build. It ships with the " +
271
+ "Privateer desktop app; the command-line agent does not carry it. Once installed it " +
272
+ "also needs Screen Recording and Accessibility permission in System Settings."
273
+ );
274
+ case "win32":
275
+ return "The Windows screen-control helper is missing from this installation.";
276
+ case "linux":
277
+ return "The Linux screen-control helper is missing from this installation.";
278
+ default:
279
+ return `Screen control has no helper for ${process.platform}.`;
280
+ }
281
+ }
282
+
283
+ // ─── The client ──────────────────────────────────────────────────────────────
284
+
285
+ interface Pending {
286
+ resolve: (v: any) => void;
287
+ reject: (e: Error) => void;
288
+ timer: NodeJS.Timeout;
289
+ }
290
+
291
+ /**
292
+ * One helper connection. Created per session (tools/computer.ts holds it in a closure,
293
+ * never at module scope — the desktop runs several sessions in ONE process and a shared
294
+ * helper would let one window's coordinate state answer another window's click).
295
+ */
296
+ export class ComputerHelper {
297
+ private child: ChildProcessWithoutNullStreams | undefined;
298
+ private pending = new Map<number, Pending>();
299
+ private nextId = 1;
300
+ private buffer = "";
301
+ private lastFailureAt = 0;
302
+ private lastFailure = "";
303
+
304
+ /** Whether a helper can be reached right now, and if not, why — in a sentence a user can act on. */
305
+ availability(): Availability {
306
+ if (this.child && !this.child.killed) return { available: true };
307
+ if (!findHelper()) return { available: false, reason: missingReason() };
308
+ if (this.lastFailure && Date.now() - this.lastFailureAt < PROBE_COOLDOWN_MS) {
309
+ return { available: false, reason: this.lastFailure };
310
+ }
311
+ return { available: true };
312
+ }
313
+
314
+ private start(): ChildProcessWithoutNullStreams {
315
+ if (this.child && !this.child.killed) return this.child;
316
+ const helper = findHelper("serve");
317
+ if (!helper) throw new Error(missingReason());
318
+
319
+ const child = spawn(helper.command, helper.args, {
320
+ stdio: ["pipe", "pipe", "pipe"],
321
+ env: helper.env ? { ...process.env, ...helper.env } : process.env,
322
+ });
323
+ this.child = child;
324
+ this.buffer = "";
325
+
326
+ child.stdout.setEncoding("utf8");
327
+ child.stdout.on("data", (chunk: string) => this.onData(chunk));
328
+ // The helper's stderr is diagnostics, never a reply. Kept off the model's context
329
+ // on purpose: it carries window titles and application names from whatever is on
330
+ // screen, which is exactly the untrusted text we don't want reaching a planner.
331
+ child.stderr.resume();
332
+
333
+ const fail = (message: string) => {
334
+ this.lastFailure = message;
335
+ this.lastFailureAt = Date.now();
336
+ this.child = undefined;
337
+ for (const [, p] of this.pending) {
338
+ clearTimeout(p.timer);
339
+ p.reject(new Error(message));
340
+ }
341
+ this.pending.clear();
342
+ };
343
+
344
+ child.on("error", (err) => fail(`The screen-control helper could not start: ${err.message}`));
345
+ child.on("exit", (code, signal) =>
346
+ fail(`The screen-control helper stopped (${signal ?? `exit ${code}`}).`),
347
+ );
348
+ return child;
349
+ }
350
+
351
+ private onData(chunk: string): void {
352
+ this.buffer += chunk;
353
+ let nl: number;
354
+ while ((nl = this.buffer.indexOf("\n")) >= 0) {
355
+ const line = this.buffer.slice(0, nl).trim();
356
+ this.buffer = this.buffer.slice(nl + 1);
357
+ if (!line) continue;
358
+ let msg: any;
359
+ try {
360
+ msg = JSON.parse(line);
361
+ } catch {
362
+ continue; // a helper that writes noise to stdout must not wedge every caller
363
+ }
364
+ const p = this.pending.get(msg?.id);
365
+ if (!p) continue;
366
+ this.pending.delete(msg.id);
367
+ clearTimeout(p.timer);
368
+ if (msg.ok === false) p.reject(new Error(String(msg.error ?? "the helper refused")));
369
+ else p.resolve(msg);
370
+ }
371
+ }
372
+
373
+ private call<T>(req: HelperRequest): Promise<T> {
374
+ const child = this.start();
375
+ const id = this.nextId++;
376
+ return new Promise<T>((resolve, reject) => {
377
+ const timer = setTimeout(() => {
378
+ this.pending.delete(id);
379
+ reject(new Error(`The screen-control helper did not answer ${req.op} in ${CALL_TIMEOUT_MS / 1000}s.`));
380
+ }, CALL_TIMEOUT_MS);
381
+ this.pending.set(id, { resolve, reject, timer });
382
+ child.stdin.write(`${JSON.stringify({ id, ...req })}\n`);
383
+ });
384
+ }
385
+
386
+ async displays(): Promise<Display[]> {
387
+ const r = await this.call<{ displays: Display[] }>({ op: "displays" });
388
+ return r.displays ?? [];
389
+ }
390
+
391
+ async grants(): Promise<Grants> {
392
+ const r = await this.call<{ grants: Grants }>({ op: "grants" });
393
+ return r.grants ?? { screen: false, input: false, secureInput: false };
394
+ }
395
+
396
+ /** Capture `plan`'s display, downscaled by the helper to exactly the plan's agent-space size. */
397
+ async capture(plan: CapturePlan, preview?: { width: number; height: number }): Promise<CaptureResult> {
398
+ return this.call<CaptureResult>({
399
+ op: "capture",
400
+ display: plan.displayId,
401
+ targetWidth: plan.width,
402
+ targetHeight: plan.height,
403
+ ...(preview ? { previewWidth: preview.width, previewHeight: preview.height } : {}),
404
+ });
405
+ }
406
+
407
+ async pointer(req: Omit<PointerRequest, "op">): Promise<void> {
408
+ await this.call({ op: "pointer", ...req });
409
+ }
410
+
411
+ async key(req: Omit<KeyRequest, "op">): Promise<void> {
412
+ await this.call({ op: "key", ...req });
413
+ }
414
+
415
+ /** Stop the helper. Called when a session ends so a closed window leaves no process holding the screen. */
416
+ dispose(): void {
417
+ const child = this.child;
418
+ this.child = undefined;
419
+ for (const [, p] of this.pending) {
420
+ clearTimeout(p.timer);
421
+ p.reject(new Error("The session ended."));
422
+ }
423
+ this.pending.clear();
424
+ child?.kill();
425
+ }
426
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The approval preview sink — how the picture reaches the permission prompt.
3
+ *
4
+ * ── The problem this solves ──────────────────────────────────────────────────
5
+ *
6
+ * The gate classifies a tool call from `{ toolName, input }` and nothing else
7
+ * (permissions/classify.ts). That is the right shape for every other kind: a path, a
8
+ * command and a URL are all IN the input. A click is not — `{x: 812, y: 344}` means
9
+ * nothing without the screenshot those coordinates are relative to, and that screenshot
10
+ * lives in the closure of tools/computer.ts, which the classifier cannot reach and
11
+ * should not learn about.
12
+ *
13
+ * So the frame travels through an object both halves are handed: the tools write the
14
+ * last capture in, the gate extension reads a preview out. One per session.
15
+ *
16
+ * ── Why not module state, one more time ──────────────────────────────────────
17
+ *
18
+ * It would be two lines shorter and it would be a real bug. The desktop runs one
19
+ * session PER WINDOW inside a single process, so a module-level "last frame" would let
20
+ * one window's screenshot illustrate another window's approval — showing a person a
21
+ * picture of the wrong screen and asking them to approve a click on it. That is worse
22
+ * than showing no picture at all, because it looks like it worked. The sink is created
23
+ * per session in buildMoat and handed to exactly the two things that need it, the same
24
+ * way relayFiles.bridge already is.
25
+ *
26
+ * ── Bounded on purpose ───────────────────────────────────────────────────────
27
+ *
28
+ * One frame per display, replaced on each capture, never accumulated. A GUI loop takes
29
+ * dozens of screenshots per task; keeping a history would grow a session's memory
30
+ * without anyone asking for it, and the only frame an approval can honestly be
31
+ * illustrated with is the most recent one for that display anyway — the model's
32
+ * coordinates are relative to that and nothing else.
33
+ */
34
+
35
+ import type { ApprovalPreview } from "../permissions/gate.ts";
36
+
37
+ /** One display's most recent frame, downscaled for a dialog. */
38
+ export interface PreviewFrame {
39
+ displayId: string;
40
+ /** base64 PNG at preview size, not the full frame the model received. */
41
+ data: string;
42
+ width: number;
43
+ height: number;
44
+ /** The AGENT-space dimensions the tool's coordinates are in. */
45
+ frameWidth: number;
46
+ frameHeight: number;
47
+ }
48
+
49
+ /** The pointer actions worth drawing a marker for. Others get the picture alone. */
50
+ const POINTER_ACTIONS = new Set(["click", "double_click", "right_click", "move", "drag", "scroll"]);
51
+
52
+ function num(v: unknown): number | undefined {
53
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
54
+ return Number.isFinite(n) ? n : undefined;
55
+ }
56
+
57
+ export class ComputerPreviewSink {
58
+ private frames = new Map<string, PreviewFrame>();
59
+ /** The display captured most recently, so an action that omits `display` still illustrates. */
60
+ private lastDisplayId: string | undefined;
61
+
62
+ /** Record a capture. Replaces whatever that display had. */
63
+ put(frame: PreviewFrame): void {
64
+ this.frames.set(frame.displayId, frame);
65
+ this.lastDisplayId = frame.displayId;
66
+ }
67
+
68
+ /** Forget everything — called when a session's helper is disposed. */
69
+ clear(): void {
70
+ this.frames.clear();
71
+ this.lastDisplayId = undefined;
72
+ }
73
+
74
+ /**
75
+ * Build the preview for a pending `computer_control` call, or undefined when there is
76
+ * nothing honest to show.
77
+ *
78
+ * Resolves the display exactly as the tool does — the named one, else the last
79
+ * captured — so the picture in the dialog is the picture the coordinates are against.
80
+ * If those two ever disagreed the dialog would be actively misleading, which is the
81
+ * one outcome worse than a text-only prompt.
82
+ */
83
+ previewFor(toolName: string, input: unknown): ApprovalPreview | undefined {
84
+ if (toolName !== "computer_control") return undefined;
85
+ const obj: Record<string, unknown> =
86
+ input && typeof input === "object" ? (input as Record<string, unknown>) : {};
87
+
88
+ const action = typeof obj.action === "string" ? obj.action : undefined;
89
+ // `type`, `key` and `wait` have no location, and a picture with no marker invites
90
+ // the reader to look for one. They keep the text-only prompt, where the detail line
91
+ // already carries the whole decision (the literal text, verbatim).
92
+ if (!action || !POINTER_ACTIONS.has(action)) return undefined;
93
+
94
+ const displayId = typeof obj.display === "string" && obj.display ? obj.display : this.lastDisplayId;
95
+ const frame = displayId ? this.frames.get(displayId) : undefined;
96
+
97
+ const x = num(obj.x);
98
+ const y = num(obj.y);
99
+ const toX = num(obj.to_x);
100
+ const toY = num(obj.to_y);
101
+
102
+ // A frame with no coordinates, or coordinates with no frame, are both still worth
103
+ // sending: the picture alone tells someone which screen this is about, and a marker
104
+ // position with no picture is dropped harmlessly by the renderer.
105
+ if (!frame && x === undefined) return undefined;
106
+
107
+ return {
108
+ action,
109
+ ...(frame
110
+ ? {
111
+ image: {
112
+ data: frame.data,
113
+ width: frame.width,
114
+ height: frame.height,
115
+ frameWidth: frame.frameWidth,
116
+ frameHeight: frame.frameHeight,
117
+ },
118
+ }
119
+ : {}),
120
+ ...(x !== undefined && y !== undefined ? { target: { x, y } } : {}),
121
+ ...(action === "drag" && toX !== undefined && toY !== undefined ? { to: { x: toX, y: toY } } : {}),
122
+ };
123
+ }
124
+ }