@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.
- package/README.md +27 -8
- package/build/index.js +4 -2
- package/build/src/cli/headless-execution-config.js +6 -5
- package/build/src/cli/headless-target.js +15 -7
- package/build/src/cli/skill-command.js +20 -3
- package/build/src/commands/help.js +8 -4
- package/build/src/core/cua-models.js +17 -15
- package/build/src/core/droid-cua-run-usage.js +22 -19
- package/build/src/core/execution-engine.js +11 -1
- package/build/src/desktop/computer-use.js +249 -78
- package/build/src/desktop/primary-display.js +74 -0
- package/build/src/desktop/windows-visible-bounds.js +55 -0
- package/build/src/integrations/loadmill/report-upload-manager.js +37 -9
- package/build/src/prompts/base.js +9 -6
- package/package.json +1 -1
- package/skill/droid-cua/SKILL.md +5 -3
- package/skill/droid-cua/references/authoring-tests.md +77 -18
- package/skill/droid-cua/references/iterative-improvement.md +10 -6
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +6 -0
- package/skill/droid-cua/references/project-and-auth.md +1 -1
- package/skill/droid-cua/references/running-and-healing.md +12 -4
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
199
|
-
|
|
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
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
Math.round(
|
|
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("
|
|
262
|
-
|
|
263
|
-
|
|
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 (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
281
|
-
const result =
|
|
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
|
|
308
|
-
: await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
384
|
-
device_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 /
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
395
|
-
deviceId: `desktop:${targetWindow.windowId}`,
|
|
564
|
+
get window() { return targetWindow; },
|
|
565
|
+
deviceId: scope === "desktop" ? "desktop:primary" : `desktop:${targetWindow.windowId}`,
|
|
396
566
|
deviceInfo,
|
|
397
|
-
|
|
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
|
+
}
|