@loadmill/droid-cua 2.39.0 → 2.41.0

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.
@@ -1,6 +1,9 @@
1
1
  import { buildActionEventMeta } from "../device/action-event-meta.js";
2
2
  import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitive-action.js";
3
3
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
4
+ import { getWindowsVisibleBounds } from "./windows-visible-bounds.js";
5
+ import sharp from "sharp";
6
+ import { capturePrimaryDisplay } from "./primary-display.js";
4
7
  const SCREENSHOT_WIDTH = 1440;
5
8
  const ACCESSIBILITY_HIT_TOLERANCE = 12;
6
9
  const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
@@ -26,6 +29,57 @@ function assertResult(result, label) {
26
29
  throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
27
30
  }
28
31
  }
32
+ function readScreenshot(result) {
33
+ const image = result.content?.find((item) => item.type === "image");
34
+ const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
35
+ const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
36
+ if (!image?.data || !size)
37
+ throw new Error("Desktop screenshot returned no image or dimensions.");
38
+ return { data: image.data, width: Number(size[1]), height: Number(size[2]) };
39
+ }
40
+ async function isBlackScreenshot(data) {
41
+ const { channels } = await sharp(Buffer.from(data, "base64")).stats();
42
+ return channels.slice(0, 3).every((channel) => channel.max === 0);
43
+ }
44
+ async function captureWindowFromDesktop(native, bounds) {
45
+ const display = native.getDisplaySize();
46
+ // Read full-resolution pixels; resizing the desktop first loses window detail.
47
+ const screen = native.takeScreenshot(undefined, undefined, 0, undefined, undefined);
48
+ if (!screen.base64)
49
+ throw new Error("Desktop screen capture returned no image.");
50
+ const left = Math.round(bounds.x * screen.width / display.width);
51
+ const top = Math.round(bounds.y * screen.height / display.height);
52
+ const width = Math.round(bounds.width * screen.width / display.width);
53
+ const height = Math.round(bounds.height * screen.height / display.height);
54
+ const visibleLeft = Math.max(0, left);
55
+ const visibleTop = Math.max(0, top);
56
+ const visibleRight = Math.min(screen.width, left + width);
57
+ const visibleBottom = Math.min(screen.height, top + height);
58
+ if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
59
+ throw new Error("The window must overlap the primary display for Windows screen capture.");
60
+ }
61
+ let image = sharp(Buffer.from(screen.base64, "base64")).extract({
62
+ left: visibleLeft,
63
+ top: visibleTop,
64
+ width: visibleRight - visibleLeft,
65
+ height: visibleBottom - visibleTop,
66
+ });
67
+ if (visibleLeft !== left || visibleTop !== top || visibleRight !== left + width || visibleBottom !== top + height) {
68
+ image = image.extend({
69
+ left: visibleLeft - left,
70
+ top: visibleTop - top,
71
+ right: left + width - visibleRight,
72
+ bottom: top + height - visibleBottom,
73
+ background: "black",
74
+ });
75
+ }
76
+ // Materialize the crop and padding before resize (Sharp can reorder operations).
77
+ const cropped = await image.png().toBuffer();
78
+ const { data, info } = await sharp(cropped)
79
+ .resize({ width: SCREENSHOT_WIDTH, withoutEnlargement: true })
80
+ .png().toBuffer({ resolveWithObject: true });
81
+ return { data: data.toString("base64"), width: info.width, height: info.height };
82
+ }
29
83
  function sameBounds(left, right) {
30
84
  return left && right && left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
31
85
  }
@@ -51,7 +105,7 @@ function formatPermissionError(error, platform) {
51
105
  if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
52
106
  return `${message} Grant Screen & System Audio Recording and Accessibility access to Droid (or your terminal for CLI runs) in System Settings > Privacy & Security, then restart it.`;
53
107
  }
54
- if (platform === "win32" && /access denied|elevat|integrity level|could not focus/i.test(message)) {
108
+ if (platform === "win32" && /access denied|elevat|integrity level/i.test(message)) {
55
109
  return `${message} Run Droid in an interactive desktop session and at the same integrity level as the target application; elevated Windows applications cannot be controlled by a non-elevated Droid process.`;
56
110
  }
57
111
  return message;
@@ -63,7 +117,7 @@ async function createLibraryClient() {
63
117
  ]);
64
118
  return connectInProcess(createComputerUseServer({ profile: "ax", vision: true }));
65
119
  }
66
- async function loadAccessibilityDriver() {
120
+ async function loadNativeDriver() {
67
121
  const { loadNative } = await import("@zavora-ai/computer-use-mcp/host-native");
68
122
  return loadNative();
69
123
  }
@@ -183,102 +237,214 @@ function normalizeShortcut(keys, platform) {
183
237
  return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
184
238
  }
185
239
  /**
186
- * @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", client?: any, accessibility?: any }} options
240
+ * @param {{ scope?: "window" | "desktop", app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds, displayCapture?: typeof capturePrimaryDisplay }} options
187
241
  */
188
- export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", client: injectedClient = null, accessibility: injectedAccessibility = null, } = {}) {
242
+ export async function createDesktopController({ scope = "window", app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, displayCapture = capturePrimaryDisplay, } = {}) {
189
243
  if (!SUPPORTED_PLATFORMS.has(platform)) {
190
244
  throw new Error("Desktop control is currently supported only on macOS and Windows.");
191
245
  }
192
- if (!requestedApp)
246
+ if (scope !== "window" && scope !== "desktop")
247
+ throw new Error('Desktop scope must be "window" or "desktop".');
248
+ if (scope === "desktop" && (requestedApp || requestedWindowId != null))
249
+ throw new Error("Full desktop cannot select an application or window.");
250
+ if (scope === "desktop" && inputMode !== "physical")
251
+ throw new Error("Full desktop requires physical input.");
252
+ if (scope === "window" && !requestedApp)
193
253
  throw new Error("--desktop-app is required with --target desktop.");
194
254
  if (!INPUT_MODES.has(inputMode))
195
255
  throw new Error('Desktop input mode must be "accessibility" or "physical".');
196
256
  const client = injectedClient || await createLibraryClient();
197
257
  try {
198
- const apps = (await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid));
199
- const application = chooseApplication(apps, requestedApp);
200
- const windows = readWindows(await client.listWindows(application.bundleId));
201
- let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
258
+ const application = scope === "window" ? chooseApplication((await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid)), requestedApp) : null;
259
+ let targetWindow = application ? chooseWindow(readWindows(await client.listWindows(application.bundleId)), requestedWindowId == null ? null : Number(requestedWindowId)) : null;
202
260
  let capture = null;
203
261
  let deviceInfo = null;
204
- let accessibility = injectedAccessibility;
262
+ let native = injectedAccessibility;
263
+ let lastVisibleBounds = null;
264
+ let fallbackLogged = false;
205
265
  async function getWindow() {
206
266
  return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
207
267
  }
208
- async function screenshot() {
209
- let window;
210
- let result;
211
- try {
212
- const currentWindows = readWindows(await client.listWindows(application.bundleId));
213
- const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
214
- if (focusedWindow)
215
- targetWindow = focusedWindow;
268
+ async function refreshWindow() {
269
+ const windows = readWindows(await client.listWindows(application.bundleId))
270
+ .filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
271
+ const selected = windows.find((window) => window.isFocused)
272
+ || windows.find((window) => window.windowId === targetWindow.windowId)
273
+ || chooseWindow(windows, null);
274
+ if (selected.windowId !== targetWindow.windowId)
275
+ capture = null;
276
+ targetWindow = selected;
277
+ if (deviceInfo) {
278
+ deviceInfo.desktopWindowId = selected.windowId;
279
+ deviceInfo.device_name = `${application.displayName} — ${selected.title || "Untitled"}`;
280
+ }
281
+ }
282
+ async function activateWindow() {
283
+ const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
284
+ if (!activation.activated || !(await getWindow()).isFocused) {
285
+ throw new Error(`Droid could not focus the selected desktop window, so no input was sent.${activation.reason ? ` Reason: ${activation.reason}.` : ""}`);
286
+ }
287
+ }
288
+ async function focus() {
289
+ if (scope === "desktop")
290
+ return;
291
+ await refreshWindow();
292
+ await activateWindow();
293
+ }
294
+ async function captureFromDesktop(window) {
295
+ if (!window.isFocused) {
296
+ if (inputMode !== "physical") {
297
+ throw new Error("Windows desktop capture requires the target window to be visible and focused. Bring it to the foreground and retry.");
298
+ }
299
+ await activateWindow();
216
300
  window = await getWindow();
217
- if (!window.isOnScreen)
218
- throw new Error("The selected desktop window is no longer visible.");
219
- result = await client.screenshot({
220
- width: SCREENSHOT_WIDTH,
221
- quality: 0,
222
- target_window_id: targetWindow.windowId,
223
- });
224
- assertResult(result, "Desktop screenshot");
301
+ }
302
+ native ||= await loadNativeDriver();
303
+ if (lastVisibleBounds?.windowId !== targetWindow.windowId || !sameBounds(lastVisibleBounds.outer, window.bounds)) {
304
+ lastVisibleBounds = {
305
+ windowId: targetWindow.windowId,
306
+ outer: { ...window.bounds },
307
+ visible: await visibleBoundsReader(targetWindow.windowId, window.bounds),
308
+ };
309
+ }
310
+ const actionBounds = lastVisibleBounds.visible;
311
+ const image = await captureWindowFromDesktop(native, actionBounds);
312
+ if (!fallbackLogged) {
313
+ console.warn("Windows window capture returned all-black pixels; using a visible-desktop crop. Keep the target unobstructed on the primary display.");
314
+ fallbackLogged = true;
315
+ }
316
+ return { ...image, bounds: { ...window.bounds }, actionBounds: { ...actionBounds } };
317
+ }
318
+ async function screenshot({ allowDesktopFallback = true } = {}) {
319
+ try {
320
+ let image;
321
+ if (scope === "desktop") {
322
+ native ||= await loadNativeDriver();
323
+ const screen = await displayCapture(native, platform);
324
+ image = { ...screen, actionBounds: { ...screen.bounds } };
325
+ }
326
+ else {
327
+ await refreshWindow();
328
+ const window = await getWindow();
329
+ if (!window.isOnScreen)
330
+ throw new Error("The selected desktop window is no longer visible.");
331
+ const result = await client.screenshot({
332
+ width: SCREENSHOT_WIDTH,
333
+ quality: 0,
334
+ target_window_id: targetWindow.windowId,
335
+ });
336
+ assertResult(result, "Desktop screenshot");
337
+ image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
338
+ if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
339
+ image = await captureFromDesktop(window);
340
+ }
341
+ }
342
+ capture = {
343
+ bounds: image.bounds,
344
+ actionBounds: image.actionBounds,
345
+ width: image.width,
346
+ height: image.height,
347
+ windowId: targetWindow?.windowId ?? null,
348
+ displayId: image.displayId,
349
+ contentBounds: scope === "desktop"
350
+ ? { x: 0, y: 0, width: image.width, height: image.height }
351
+ : getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
352
+ };
353
+ if (deviceInfo) {
354
+ deviceInfo.device_width = image.actionBounds.width;
355
+ deviceInfo.device_height = image.actionBounds.height;
356
+ deviceInfo.scaled_width = image.width;
357
+ deviceInfo.scaled_height = image.height;
358
+ deviceInfo.scale = image.width / image.actionBounds.width;
359
+ deviceInfo.desktopWindowId = targetWindow?.windowId ?? null;
360
+ }
361
+ return image.data;
225
362
  }
226
363
  catch (error) {
227
364
  throw new Error(formatPermissionError(error, platform));
228
365
  }
229
- const image = result.content?.find((item) => item.type === "image");
230
- const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
231
- const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
232
- if (!image?.data || !size)
233
- throw new Error("Desktop screenshot returned no image or dimensions.");
234
- capture = {
235
- bounds: { ...window.bounds },
236
- width: Number(size[1]),
237
- height: Number(size[2]),
238
- };
239
- capture.contentBounds = getScreenshotContentBounds(capture.width, capture.height, window.bounds, platform);
240
- if (deviceInfo) {
241
- deviceInfo.device_width = window.bounds.width;
242
- deviceInfo.device_height = window.bounds.height;
243
- deviceInfo.scaled_width = capture.width;
244
- deviceInfo.scaled_height = capture.height;
245
- deviceInfo.scale = capture.width / window.bounds.width;
246
- deviceInfo.desktopWindowId = targetWindow.windowId;
247
- }
248
- return image.data;
249
366
  }
250
367
  function scaledPoint(x, y) {
251
368
  if (!capture)
252
369
  throw new Error("Capture the desktop window before executing an action.");
370
+ if (scope === "desktop" && (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= capture.width || y >= capture.height)) {
371
+ throw new Error("The action is outside the primary display.");
372
+ }
253
373
  const content = capture.contentBounds;
254
- return [
255
- Math.round(capture.bounds.x + ((x - content.x) * capture.bounds.width) / content.width),
256
- Math.round(capture.bounds.y + ((y - content.y) * capture.bounds.height) / content.height),
374
+ const bounds = capture.actionBounds;
375
+ const point = [
376
+ Math.round(bounds.x + ((x - content.x) * bounds.width) / content.width),
377
+ Math.round(bounds.y + ((y - content.y) * bounds.height) / content.height),
257
378
  ];
379
+ // Retina rounding at the last image pixel must stay inside the display.
380
+ return scope === "desktop" ? [Math.min(point[0], bounds.width - 1), Math.min(point[1], bounds.height - 1)] : point;
258
381
  }
259
382
  async function prepareAction(requireFocus) {
260
383
  if (!capture)
261
- throw new Error("Capture the desktop window before executing an action.");
262
- const before = await getWindow();
263
- if (!sameBounds(before.bounds, capture.bounds)) {
384
+ throw new Error("A fresh desktop screenshot is required before acting.");
385
+ if (scope === "desktop") {
386
+ const display = native.getDisplaySize(capture.displayId);
387
+ if (display.width !== capture.bounds.width || display.height !== capture.bounds.height) {
388
+ throw new Error("The display resized after the screenshot. A fresh screenshot is required before acting.");
389
+ }
390
+ return;
391
+ }
392
+ let window;
393
+ try {
394
+ window = parseJsonResult(await client.getWindow(capture.windowId), "Window lookup");
395
+ }
396
+ catch {
397
+ throw new Error("The captured desktop window is no longer available. A fresh screenshot is required before acting.");
398
+ }
399
+ if (!window.isOnScreen || window.bundleId !== application.bundleId || capture.windowId !== targetWindow.windowId) {
400
+ throw new Error("The captured desktop window is no longer available. A fresh screenshot is required before acting.");
401
+ }
402
+ if (!sameBounds(window.bounds, capture.bounds)) {
264
403
  throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
265
404
  }
266
- if (!requireFocus)
267
- return;
268
- const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
269
- const focused = await getWindow();
270
- if (!activation.activated || !focused.isFocused) {
271
- throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
405
+ if (requireFocus) {
406
+ await activateWindow();
407
+ if (!sameBounds((await getWindow()).bounds, capture.bounds)) {
408
+ throw new Error("The desktop window moved or resized during focus. A fresh screenshot is required before acting.");
409
+ }
272
410
  }
273
411
  }
274
- const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
412
+ const targetOptions = () => scope === "desktop"
413
+ ? { focusStrategy: "none" }
414
+ : { targetWindowId: targetWindow.windowId, focusStrategy: "strict" };
415
+ // Native pointer calls avoid the library's first-monitor coordinate validation.
416
+ // Keyboard calls retain its text/clipboard handling, with app focus disabled.
417
+ const physical = scope === "window" ? client : {
418
+ click: (x, y) => native.mouseClick(x, y, "left", 1),
419
+ doubleClick: (x, y) => native.mouseClick(x, y, "left", 2),
420
+ moveMouse: (x, y) => native.mouseMove(x, y),
421
+ drag: async (to, from) => {
422
+ native.mouseMove(from[0], from[1]);
423
+ await new Promise((resolve) => setTimeout(resolve, 50));
424
+ native.mouseButton("press", from[0], from[1]);
425
+ try {
426
+ await new Promise((resolve) => setTimeout(resolve, 50));
427
+ native.mouseDrag(to[0], to[1]);
428
+ await new Promise((resolve) => setTimeout(resolve, 50));
429
+ }
430
+ finally {
431
+ native.mouseButton("release", to[0], to[1]);
432
+ }
433
+ },
434
+ scroll: (x, y, direction, amount) => {
435
+ native.mouseMove(x, y);
436
+ native.mouseScroll(direction === "down" ? amount : direction === "up" ? -amount : 0, direction === "right" ? amount : direction === "left" ? -amount : 0);
437
+ },
438
+ type: client.type,
439
+ key: client.key,
440
+ };
275
441
  async function performCursorFreeAction(actionType, x, y, direction = null) {
276
442
  const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
277
443
  const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
278
444
  if (!selected)
279
445
  throw new Error(cursorFreeError(actionType));
280
- accessibility ||= await loadAccessibilityDriver();
281
- const result = accessibility.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
446
+ native ||= await loadNativeDriver();
447
+ const result = native.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
282
448
  if (!result?.performed)
283
449
  throw new Error(`${cursorFreeError(actionType)}${result?.reason ? ` Reason: ${result.reason}.` : ""}`);
284
450
  }
@@ -304,8 +470,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
304
470
  }
305
471
  else {
306
472
  const result = action.type === "click"
307
- ? await client.click(x, y, application.bundleId, targetOptions())
308
- : await client.doubleClick(x, y, application.bundleId, targetOptions());
473
+ ? await physical.click(x, y, application?.bundleId, targetOptions())
474
+ : await physical.doubleClick(x, y, application?.bundleId, targetOptions());
309
475
  assertResult(result, "Desktop click");
310
476
  }
311
477
  break;
@@ -315,7 +481,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
315
481
  addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
316
482
  if (inputMode === "accessibility")
317
483
  throw new Error(cursorFreeError(action.type));
318
- assertResult(await client.moveMouse(x, y, application.bundleId, targetOptions()), "Desktop mouse move");
484
+ assertResult(await physical.moveMouse(x, y, application?.bundleId, targetOptions()), "Desktop mouse move");
319
485
  break;
320
486
  }
321
487
  case "drag": {
@@ -327,7 +493,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
327
493
  addOutput({ type: "action", text: `Dragging from (${from[0]}, ${from[1]}) to (${to[0]}, ${to[1]})`, ...meta({ pathStart: { x: from[0], y: from[1] }, pathEnd: { x: to[0], y: to[1] }, unit: "px" }) });
328
494
  if (inputMode === "accessibility")
329
495
  throw new Error(cursorFreeError(action.type));
330
- assertResult(await client.drag(to, from, application.bundleId, targetOptions()), "Desktop drag");
496
+ assertResult(await physical.drag(to, from, application?.bundleId, targetOptions()), "Desktop drag");
331
497
  break;
332
498
  }
333
499
  case "scroll": {
@@ -342,20 +508,20 @@ export async function createDesktopController({ app: requestedApp, windowId: req
342
508
  await performCursorFreeAction(action.type, x, y, direction);
343
509
  }
344
510
  else {
345
- assertResult(await client.scroll(x, y, direction, amount, application.bundleId, targetOptions()), "Desktop scroll");
511
+ assertResult(await physical.scroll(x, y, direction, amount, application?.bundleId, targetOptions()), "Desktop scroll");
346
512
  }
347
513
  break;
348
514
  }
349
515
  case "type": {
350
516
  const text = typeof action.text === "string" ? action.text : "";
351
517
  addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
352
- assertResult(await client.type(text, application.bundleId, targetOptions()), "Desktop typing");
518
+ assertResult(await physical.type(text, application?.bundleId, targetOptions()), "Desktop typing");
353
519
  break;
354
520
  }
355
521
  case "keypress": {
356
522
  const shortcut = normalizeShortcut(action.keys, platform);
357
523
  addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
358
- assertResult(await client.key(shortcut, application.bundleId, targetOptions()), "Desktop keypress");
524
+ assertResult(await physical.key(shortcut, application?.bundleId, targetOptions()), "Desktop keypress");
359
525
  break;
360
526
  }
361
527
  case "wait": {
@@ -375,26 +541,31 @@ export async function createDesktopController({ app: requestedApp, windowId: req
375
541
  return { success: false, error: message };
376
542
  }
377
543
  }
378
- await screenshot();
544
+ if (focusOnConnect)
545
+ await focus();
546
+ // Without focus, connection only probes the window; the desktop may be covered.
547
+ await screenshot({ allowDesktopFallback: focusOnConnect });
379
548
  deviceInfo = {
380
549
  platform: "desktop",
381
550
  source: "local",
382
- device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
383
- device_width: targetWindow.bounds.width,
384
- device_height: targetWindow.bounds.height,
551
+ device_name: application ? `${application.displayName} — ${targetWindow.title || "Untitled"}` : "Full desktop",
552
+ device_width: capture.actionBounds.width,
553
+ device_height: capture.actionBounds.height,
385
554
  scaled_width: capture.width,
386
555
  scaled_height: capture.height,
387
- scale: capture.width / targetWindow.bounds.width,
388
- desktopAppId: application.bundleId,
389
- desktopWindowId: targetWindow.windowId,
556
+ scale: capture.width / capture.actionBounds.width,
557
+ desktopScope: scope,
558
+ desktopAppId: application?.bundleId ?? null,
559
+ desktopWindowId: targetWindow?.windowId ?? null,
390
560
  desktopInputMode: inputMode,
391
561
  };
392
562
  return {
393
563
  application,
394
- window: targetWindow,
395
- deviceId: `desktop:${targetWindow.windowId}`,
564
+ get window() { return targetWindow; },
565
+ deviceId: scope === "desktop" ? "desktop:primary" : `desktop:${targetWindow.windowId}`,
396
566
  deviceInfo,
397
- getScreenshotAsBase64: screenshot,
567
+ focus,
568
+ getScreenshotAsBase64: () => screenshot(),
398
569
  handleModelAction: handleAction,
399
570
  close: () => client.close(),
400
571
  };
@@ -0,0 +1,74 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import sharp from "sharp";
7
+ const runFile = promisify(execFile);
8
+ const WINDOWS_CAPTURE = `
9
+ $ErrorActionPreference = 'Stop'
10
+ Add-Type -AssemblyName System.Drawing
11
+ Add-Type -TypeDefinition @'
12
+ using System;
13
+ using System.Runtime.InteropServices;
14
+ public static class PrimaryDisplay {
15
+ [StructLayout(LayoutKind.Sequential)] public struct Point { public int X; public int Y; }
16
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
17
+ [DllImport("user32.dll")] public static extern int GetSystemMetrics(int index);
18
+ [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(Point point, uint flags);
19
+ }
20
+ '@
21
+ [void][PrimaryDisplay]::SetProcessDPIAware()
22
+ $width = [PrimaryDisplay]::GetSystemMetrics(0)
23
+ $height = [PrimaryDisplay]::GetSystemMetrics(1)
24
+ $bitmap = [System.Drawing.Bitmap]::new($width, $height)
25
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
26
+ try {
27
+ $graphics.CopyFromScreen(0, 0, 0, 0, $bitmap.Size)
28
+ $bitmap.Save('CAPTURE_PATH', [System.Drawing.Imaging.ImageFormat]::Png)
29
+ # The native driver's display lookup accepts a uint32 monitor handle.
30
+ [PrimaryDisplay]::MonitorFromPoint([PrimaryDisplay+Point]::new(), 1).ToInt64() -band 0xffffffff
31
+ } finally {
32
+ $graphics.Dispose()
33
+ $bitmap.Dispose()
34
+ }
35
+ `;
36
+ /** Capture only the primary display, independently of the library's cached window target. */
37
+ export async function capturePrimaryDisplay(native, platform = process.platform, run = runFile) {
38
+ const directory = await mkdtemp(join(tmpdir(), "droid-desktop-"));
39
+ const path = join(directory, "screen.png");
40
+ try {
41
+ let displayId;
42
+ if (platform === "darwin") {
43
+ await run("/usr/sbin/screencapture", ["-x", "-m", "-t", "png", path], { timeout: 10000 });
44
+ }
45
+ else if (platform === "win32") {
46
+ const script = WINDOWS_CAPTURE.replace("CAPTURE_PATH", path.replaceAll("'", "''"));
47
+ const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
48
+ windowsHide: true,
49
+ timeout: 10000,
50
+ });
51
+ displayId = Number(stdout.trim());
52
+ if (!Number.isSafeInteger(displayId) || displayId <= 0)
53
+ throw new Error("Primary display lookup failed.");
54
+ }
55
+ else {
56
+ throw new Error("Desktop control is currently supported only on macOS and Windows.");
57
+ }
58
+ // Use the native driver's coordinate units, which can differ from screenshot pixels.
59
+ const display = native.getDisplaySize(displayId);
60
+ const { data, info } = await sharp(await readFile(path))
61
+ .resize({ width: 1440, withoutEnlargement: true })
62
+ .png().toBuffer({ resolveWithObject: true });
63
+ return {
64
+ data: data.toString("base64"),
65
+ width: info.width,
66
+ height: info.height,
67
+ bounds: { x: 0, y: 0, width: display.width, height: display.height },
68
+ displayId: displayId ?? display.displayId,
69
+ };
70
+ }
71
+ finally {
72
+ await rm(directory, { recursive: true, force: true });
73
+ }
74
+ }
@@ -0,0 +1,55 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const runFile = promisify(execFile);
4
+ const RECT_SCRIPT = `
5
+ Add-Type -TypeDefinition @'
6
+ using System;
7
+ using System.Runtime.InteropServices;
8
+ public static class WindowFrame {
9
+ public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9;
10
+ [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left; public int Top; public int Right; public int Bottom; }
11
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr handle, out Rect rect);
12
+ [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr handle, int attribute, out Rect rect, int size);
13
+ }
14
+ '@
15
+ $handle = [IntPtr]::new([long]WINDOW_ID)
16
+ $outer = [WindowFrame+Rect]::new()
17
+ $visible = [WindowFrame+Rect]::new()
18
+ if (-not [WindowFrame]::GetWindowRect($handle, [ref]$outer)) { exit 1 }
19
+ $result = [WindowFrame]::DwmGetWindowAttribute($handle, [WindowFrame]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$visible, 16)
20
+ if ($result -ne 0) { exit 1 }
21
+ @($outer.Left, $outer.Top, $outer.Right, $outer.Bottom, $visible.Left, $visible.Top, $visible.Right, $visible.Bottom) -join ','
22
+ `;
23
+ export async function getWindowsVisibleBounds(windowId, bounds) {
24
+ if (process.platform !== "win32")
25
+ return bounds;
26
+ if (!Number.isSafeInteger(windowId))
27
+ return bounds;
28
+ try {
29
+ // GetWindowRect includes invisible resize borders; DWM reports the pixels actually drawn.
30
+ const script = RECT_SCRIPT.replace("WINDOW_ID", String(windowId));
31
+ const { stdout } = await runFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
32
+ windowsHide: true,
33
+ timeout: 5000,
34
+ });
35
+ const [outerLeft, outerTop, outerRight, outerBottom, left, top, right, bottom] = stdout.trim().split(",").map(Number);
36
+ const scaleX = bounds.width / (outerRight - outerLeft);
37
+ const scaleY = bounds.height / (outerBottom - outerTop);
38
+ const insetLeft = Math.round((left - outerLeft) * scaleX);
39
+ const insetTop = Math.round((top - outerTop) * scaleY);
40
+ const insetRight = Math.round((outerRight - right) * scaleX);
41
+ const insetBottom = Math.round((outerBottom - bottom) * scaleY);
42
+ if (![insetLeft, insetTop, insetRight, insetBottom].every((value) => Number.isFinite(value) && value >= 0)) {
43
+ throw new Error("Invalid window frame insets.");
44
+ }
45
+ const width = bounds.width - insetLeft - insetRight;
46
+ const height = bounds.height - insetTop - insetBottom;
47
+ if (width <= 0 || height <= 0)
48
+ throw new Error("Invalid visible window dimensions.");
49
+ return { x: bounds.x + insetLeft, y: bounds.y + insetTop, width, height };
50
+ }
51
+ catch {
52
+ console.warn("Windows visible-frame lookup failed; using outer window bounds, which may include invisible resize borders.");
53
+ return bounds;
54
+ }
55
+ }