@loadmill/droid-cua 2.40.0 → 2.42.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 +33 -3
- package/build/index.js +69 -10
- package/build/src/browser/connection.js +5 -0
- package/build/src/browser/factory.js +1 -0
- package/build/src/browser/playwright/connection.js +3 -0
- package/build/src/cli/headless-runner.js +55 -3
- package/build/src/cli/headless-target.js +15 -7
- package/build/src/commands/help.js +10 -4
- package/build/src/core/execution-engine.js +26 -1
- package/build/src/core/report/render-html-report.js +36 -6
- package/build/src/core/report/run-log-model.js +65 -0
- package/build/src/core/report/run-report-model.js +1 -0
- package/build/src/core/report/run-report-sections.js +15 -0
- package/build/src/core/tool-handlers/assert.js +45 -2
- package/build/src/core/tool-handlers/query-network.js +92 -0
- package/build/src/desktop/computer-use.js +137 -51
- package/build/src/desktop/primary-display.js +74 -0
- package/build/src/device/openai.js +25 -0
- package/build/src/modes/execution-mode.js +10 -1
- package/build/src/network/browser-capture.js +110 -0
- package/build/src/network/certificate-authority.js +205 -0
- package/build/src/network/certificate-install.js +242 -0
- package/build/src/network/local-proxy.js +287 -0
- package/build/src/network/network-capture.js +243 -0
- package/build/src/network/query-network.js +215 -0
- package/build/src/network/run-network-capture.js +107 -0
- package/build/src/prompts/base.js +9 -6
- package/build/src/prompts/execution.js +10 -6
- package/package.json +2 -1
- package/skill/droid-cua/SKILL.md +1 -1
- package/skill/droid-cua/references/authoring-tests.md +16 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
- package/skill/droid-cua/references/running-and-healing.md +9 -1
|
@@ -3,6 +3,7 @@ import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitiv
|
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
4
4
|
import { getWindowsVisibleBounds } from "./windows-visible-bounds.js";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
+
import { capturePrimaryDisplay } from "./primary-display.js";
|
|
6
7
|
const SCREENSHOT_WIDTH = 1440;
|
|
7
8
|
const ACCESSIBILITY_HIT_TOLERANCE = 12;
|
|
8
9
|
const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
|
|
@@ -104,7 +105,7 @@ function formatPermissionError(error, platform) {
|
|
|
104
105
|
if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
|
|
105
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.`;
|
|
106
107
|
}
|
|
107
|
-
if (platform === "win32" && /access denied|elevat|integrity level
|
|
108
|
+
if (platform === "win32" && /access denied|elevat|integrity level/i.test(message)) {
|
|
108
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.`;
|
|
109
110
|
}
|
|
110
111
|
return message;
|
|
@@ -236,22 +237,26 @@ function normalizeShortcut(keys, platform) {
|
|
|
236
237
|
return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
|
|
237
238
|
}
|
|
238
239
|
/**
|
|
239
|
-
* @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds }} 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
|
|
240
241
|
*/
|
|
241
|
-
export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, } = {}) {
|
|
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, } = {}) {
|
|
242
243
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
243
244
|
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
244
245
|
}
|
|
245
|
-
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)
|
|
246
253
|
throw new Error("--desktop-app is required with --target desktop.");
|
|
247
254
|
if (!INPUT_MODES.has(inputMode))
|
|
248
255
|
throw new Error('Desktop input mode must be "accessibility" or "physical".');
|
|
249
256
|
const client = injectedClient || await createLibraryClient();
|
|
250
257
|
try {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
const windows = readWindows(await client.listWindows(application.bundleId));
|
|
254
|
-
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;
|
|
255
260
|
let capture = null;
|
|
256
261
|
let deviceInfo = null;
|
|
257
262
|
let native = injectedAccessibility;
|
|
@@ -260,19 +265,38 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
260
265
|
async function getWindow() {
|
|
261
266
|
return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
|
|
262
267
|
}
|
|
263
|
-
async function
|
|
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() {
|
|
264
283
|
const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
|
|
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}.` : ""}`);
|
|
268
286
|
}
|
|
269
287
|
}
|
|
288
|
+
async function focus() {
|
|
289
|
+
if (scope === "desktop")
|
|
290
|
+
return;
|
|
291
|
+
await refreshWindow();
|
|
292
|
+
await activateWindow();
|
|
293
|
+
}
|
|
270
294
|
async function captureFromDesktop(window) {
|
|
271
295
|
if (!window.isFocused) {
|
|
272
296
|
if (inputMode !== "physical") {
|
|
273
297
|
throw new Error("Windows desktop capture requires the target window to be visible and focused. Bring it to the foreground and retry.");
|
|
274
298
|
}
|
|
275
|
-
await
|
|
299
|
+
await activateWindow();
|
|
276
300
|
window = await getWindow();
|
|
277
301
|
}
|
|
278
302
|
native ||= await loadNativeDriver();
|
|
@@ -293,29 +317,38 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
293
317
|
}
|
|
294
318
|
async function screenshot({ allowDesktopFallback = true } = {}) {
|
|
295
319
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
}
|
|
312
341
|
}
|
|
313
342
|
capture = {
|
|
314
343
|
bounds: image.bounds,
|
|
315
344
|
actionBounds: image.actionBounds,
|
|
316
345
|
width: image.width,
|
|
317
346
|
height: image.height,
|
|
318
|
-
|
|
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),
|
|
319
352
|
};
|
|
320
353
|
if (deviceInfo) {
|
|
321
354
|
deviceInfo.device_width = image.actionBounds.width;
|
|
@@ -323,7 +356,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
323
356
|
deviceInfo.scaled_width = image.width;
|
|
324
357
|
deviceInfo.scaled_height = image.height;
|
|
325
358
|
deviceInfo.scale = image.width / image.actionBounds.width;
|
|
326
|
-
deviceInfo.desktopWindowId = targetWindow
|
|
359
|
+
deviceInfo.desktopWindowId = targetWindow?.windowId ?? null;
|
|
327
360
|
}
|
|
328
361
|
return image.data;
|
|
329
362
|
}
|
|
@@ -334,25 +367,77 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
334
367
|
function scaledPoint(x, y) {
|
|
335
368
|
if (!capture)
|
|
336
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
|
+
}
|
|
337
373
|
const content = capture.contentBounds;
|
|
338
374
|
const bounds = capture.actionBounds;
|
|
339
|
-
|
|
375
|
+
const point = [
|
|
340
376
|
Math.round(bounds.x + ((x - content.x) * bounds.width) / content.width),
|
|
341
377
|
Math.round(bounds.y + ((y - content.y) * bounds.height) / content.height),
|
|
342
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;
|
|
343
381
|
}
|
|
344
382
|
async function prepareAction(requireFocus) {
|
|
345
383
|
if (!capture)
|
|
346
|
-
throw new Error("
|
|
347
|
-
|
|
348
|
-
|
|
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)) {
|
|
349
403
|
throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
|
|
350
404
|
}
|
|
351
|
-
if (
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
}
|
|
410
|
+
}
|
|
354
411
|
}
|
|
355
|
-
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
|
+
};
|
|
356
441
|
async function performCursorFreeAction(actionType, x, y, direction = null) {
|
|
357
442
|
const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
|
|
358
443
|
const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
|
|
@@ -385,8 +470,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
385
470
|
}
|
|
386
471
|
else {
|
|
387
472
|
const result = action.type === "click"
|
|
388
|
-
? await
|
|
389
|
-
: await
|
|
473
|
+
? await physical.click(x, y, application?.bundleId, targetOptions())
|
|
474
|
+
: await physical.doubleClick(x, y, application?.bundleId, targetOptions());
|
|
390
475
|
assertResult(result, "Desktop click");
|
|
391
476
|
}
|
|
392
477
|
break;
|
|
@@ -396,7 +481,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
396
481
|
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
|
|
397
482
|
if (inputMode === "accessibility")
|
|
398
483
|
throw new Error(cursorFreeError(action.type));
|
|
399
|
-
assertResult(await
|
|
484
|
+
assertResult(await physical.moveMouse(x, y, application?.bundleId, targetOptions()), "Desktop mouse move");
|
|
400
485
|
break;
|
|
401
486
|
}
|
|
402
487
|
case "drag": {
|
|
@@ -408,7 +493,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
408
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" }) });
|
|
409
494
|
if (inputMode === "accessibility")
|
|
410
495
|
throw new Error(cursorFreeError(action.type));
|
|
411
|
-
assertResult(await
|
|
496
|
+
assertResult(await physical.drag(to, from, application?.bundleId, targetOptions()), "Desktop drag");
|
|
412
497
|
break;
|
|
413
498
|
}
|
|
414
499
|
case "scroll": {
|
|
@@ -423,20 +508,20 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
423
508
|
await performCursorFreeAction(action.type, x, y, direction);
|
|
424
509
|
}
|
|
425
510
|
else {
|
|
426
|
-
assertResult(await
|
|
511
|
+
assertResult(await physical.scroll(x, y, direction, amount, application?.bundleId, targetOptions()), "Desktop scroll");
|
|
427
512
|
}
|
|
428
513
|
break;
|
|
429
514
|
}
|
|
430
515
|
case "type": {
|
|
431
516
|
const text = typeof action.text === "string" ? action.text : "";
|
|
432
517
|
addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
|
|
433
|
-
assertResult(await
|
|
518
|
+
assertResult(await physical.type(text, application?.bundleId, targetOptions()), "Desktop typing");
|
|
434
519
|
break;
|
|
435
520
|
}
|
|
436
521
|
case "keypress": {
|
|
437
522
|
const shortcut = normalizeShortcut(action.keys, platform);
|
|
438
523
|
addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
|
|
439
|
-
assertResult(await
|
|
524
|
+
assertResult(await physical.key(shortcut, application?.bundleId, targetOptions()), "Desktop keypress");
|
|
440
525
|
break;
|
|
441
526
|
}
|
|
442
527
|
case "wait": {
|
|
@@ -463,20 +548,21 @@ export async function createDesktopController({ app: requestedApp, windowId: req
|
|
|
463
548
|
deviceInfo = {
|
|
464
549
|
platform: "desktop",
|
|
465
550
|
source: "local",
|
|
466
|
-
device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}
|
|
551
|
+
device_name: application ? `${application.displayName} — ${targetWindow.title || "Untitled"}` : "Full desktop",
|
|
467
552
|
device_width: capture.actionBounds.width,
|
|
468
553
|
device_height: capture.actionBounds.height,
|
|
469
554
|
scaled_width: capture.width,
|
|
470
555
|
scaled_height: capture.height,
|
|
471
556
|
scale: capture.width / capture.actionBounds.width,
|
|
472
|
-
|
|
473
|
-
|
|
557
|
+
desktopScope: scope,
|
|
558
|
+
desktopAppId: application?.bundleId ?? null,
|
|
559
|
+
desktopWindowId: targetWindow?.windowId ?? null,
|
|
474
560
|
desktopInputMode: inputMode,
|
|
475
561
|
};
|
|
476
562
|
return {
|
|
477
563
|
application,
|
|
478
|
-
window
|
|
479
|
-
deviceId: `desktop:${targetWindow.windowId}`,
|
|
564
|
+
get window() { return targetWindow; },
|
|
565
|
+
deviceId: scope === "desktop" ? "desktop:primary" : `desktop:${targetWindow.windowId}`,
|
|
480
566
|
deviceInfo,
|
|
481
567
|
focus,
|
|
482
568
|
getScreenshotAsBase64: () => screenshot(),
|
|
@@ -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
|
+
}
|
|
@@ -41,6 +41,29 @@ function normalizeMessageContent(content) {
|
|
|
41
41
|
function buildScreenshotDataUrl(screenshotBase64) {
|
|
42
42
|
return `data:image/png;base64,${screenshotBase64}`;
|
|
43
43
|
}
|
|
44
|
+
const queryNetworkTool = {
|
|
45
|
+
type: "function",
|
|
46
|
+
name: "query_network",
|
|
47
|
+
description: "Answer a question about captured HTTP traffic. All matching exchanges are considered in time order. Select the headers/bodies needed, or [] for metadata. Filters combine with AND; flow_ids selects any listed ID. Time filters select request start times (inclusive). from_step uses step-0001 style IDs. Null filters search the whole run. observe_ms waits the full duration before searching, maximum 30000. Narrow too_broad results; never treat errors, incomplete data or absence_supported=false as proof of absence.",
|
|
48
|
+
strict: true,
|
|
49
|
+
parameters: {
|
|
50
|
+
type: "object",
|
|
51
|
+
properties: {
|
|
52
|
+
question: { type: "string", description: "Ask for the specific values or conclusion needed, rather than a full body dump." },
|
|
53
|
+
sections: { type: "array", items: { type: "string", enum: ["request_headers", "response_headers", "request_body", "response_body"] } },
|
|
54
|
+
url_contains: { type: ["string", "null"] },
|
|
55
|
+
method: { type: ["string", "null"] },
|
|
56
|
+
status: { type: ["integer", "null"] },
|
|
57
|
+
flow_ids: { type: ["array", "null"], items: { type: "string" } },
|
|
58
|
+
after: { type: ["string", "null"] },
|
|
59
|
+
before: { type: ["string", "null"] },
|
|
60
|
+
from_step: { type: ["string", "null"] },
|
|
61
|
+
observe_ms: { type: ["integer", "null"] },
|
|
62
|
+
},
|
|
63
|
+
required: ["question", "sections", "url_contains", "method", "status", "flow_ids", "after", "before", "from_step", "observe_ms"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
44
67
|
const previewClickTool = {
|
|
45
68
|
type: "function",
|
|
46
69
|
name: "preview_click",
|
|
@@ -234,6 +257,8 @@ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, to
|
|
|
234
257
|
if (toolCapabilities.closeKeyboard) {
|
|
235
258
|
tools.push(deviceInfo?.platform?.toLowerCase() === "ios" ? iosCloseKeyboardTool : closeKeyboardTool);
|
|
236
259
|
}
|
|
260
|
+
if (toolCapabilities.queryNetwork)
|
|
261
|
+
tools.push(queryNetworkTool);
|
|
237
262
|
if (toolCapabilities.loadmill) {
|
|
238
263
|
tools.push(loadmillTool);
|
|
239
264
|
}
|
|
@@ -102,6 +102,7 @@ export class ExecutionMode {
|
|
|
102
102
|
// changed these three fields. Reset them to the values execution needs.
|
|
103
103
|
this.engine.toolCapabilities = {
|
|
104
104
|
assert: true,
|
|
105
|
+
queryNetwork: Boolean(this.engine.networkQuery),
|
|
105
106
|
navigate: this.isBrowserSession,
|
|
106
107
|
closeCurrentTab: this.isBrowserSession,
|
|
107
108
|
closeKeyboard: !this.isBrowserSession,
|
|
@@ -245,6 +246,7 @@ export class ExecutionMode {
|
|
|
245
246
|
async execute(context = {}) {
|
|
246
247
|
const runContext = {
|
|
247
248
|
...context,
|
|
249
|
+
shouldStop: () => this.shouldStop,
|
|
248
250
|
runId: context.runId || `run-${Date.now()}`
|
|
249
251
|
};
|
|
250
252
|
const addOutput = runContext.addOutput || printCliOutput;
|
|
@@ -274,6 +276,10 @@ export class ExecutionMode {
|
|
|
274
276
|
return { success: false, error: 'Stopped by user' };
|
|
275
277
|
}
|
|
276
278
|
}
|
|
279
|
+
if (this.engine.networkQuery) {
|
|
280
|
+
this.engine.networkQuery.markStep(stepContext.stepId);
|
|
281
|
+
this.session.addToTranscript(`[Step ${stepContext.stepId}] ${instruction}`);
|
|
282
|
+
}
|
|
277
283
|
this.emit(addOutput, 'user', instruction, runContext, stepContext, {
|
|
278
284
|
eventType: 'instruction_started',
|
|
279
285
|
payload: {
|
|
@@ -371,7 +377,10 @@ export class ExecutionMode {
|
|
|
371
377
|
captureSource: CAPTURE_SOURCES.INSTRUCTION_INPUT
|
|
372
378
|
});
|
|
373
379
|
const response = await this.sendCUARequest({
|
|
374
|
-
|
|
380
|
+
// The chain already contains the base prompt; keep new tool-provided context.
|
|
381
|
+
messages: this.session.previousResponseId
|
|
382
|
+
? this.session.messages.filter((message) => message.role !== "system" || message.content !== this.session.systemPrompt)
|
|
383
|
+
: this.session.messages,
|
|
375
384
|
screenshotBase64,
|
|
376
385
|
previousResponseId: this.session.previousResponseId,
|
|
377
386
|
deviceInfo: this.session.deviceInfo,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BODY_CAPTURE_LIMIT } from "./network-capture.js";
|
|
3
|
+
/** Observe one Playwright context without routing or changing its requests. */
|
|
4
|
+
export function captureBrowserNetwork(context, record) {
|
|
5
|
+
const flows = new Map();
|
|
6
|
+
const pending = new Set();
|
|
7
|
+
function track(work) {
|
|
8
|
+
pending.add(work);
|
|
9
|
+
void work.finally(() => pending.delete(work));
|
|
10
|
+
return work;
|
|
11
|
+
}
|
|
12
|
+
function error(flowId, cause) {
|
|
13
|
+
return record({ type: "error", flowId, phase: "browser", code: null, message: cause.message });
|
|
14
|
+
}
|
|
15
|
+
function body(flowId, side, headers, buffer, { complete = true, omitted, bytes = buffer.length } = {}) {
|
|
16
|
+
return record({
|
|
17
|
+
type: `${side}_body`, flowId, headers,
|
|
18
|
+
body: buffer.subarray(0, BODY_CAPTURE_LIMIT), bytes,
|
|
19
|
+
truncated: bytes > BODY_CAPTURE_LIMIT, complete, decoded: true, omitted,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const onRequest = (request) => {
|
|
23
|
+
if (!/^https?:/.test(request.url()))
|
|
24
|
+
return;
|
|
25
|
+
const flowId = randomUUID();
|
|
26
|
+
const timestamp = new Date().toISOString();
|
|
27
|
+
const flow = { flowId, response: null, responseReady: null, ready: null };
|
|
28
|
+
flows.set(request, flow);
|
|
29
|
+
flow.ready = track((async () => {
|
|
30
|
+
const headers = await request.allHeaders();
|
|
31
|
+
await record({
|
|
32
|
+
type: "request", flowId, timestamp, method: request.method(), url: request.url(), headers,
|
|
33
|
+
resourceType: request.resourceType(), serviceWorker: Boolean(request.serviceWorker()),
|
|
34
|
+
});
|
|
35
|
+
await body(flowId, "request", headers, request.postDataBuffer() || Buffer.alloc(0));
|
|
36
|
+
})().catch((cause) => error(flowId, cause)));
|
|
37
|
+
};
|
|
38
|
+
const onResponse = (response) => {
|
|
39
|
+
const flow = flows.get(response.request());
|
|
40
|
+
if (!flow)
|
|
41
|
+
return; // Ignore traffic that began before this test.
|
|
42
|
+
const timestamp = new Date().toISOString();
|
|
43
|
+
flow.response = response;
|
|
44
|
+
flow.responseReady = track((async () => {
|
|
45
|
+
await flow.ready;
|
|
46
|
+
const headers = await response.allHeaders();
|
|
47
|
+
await record({
|
|
48
|
+
type: "response", flowId: flow.flowId, timestamp, status: response.status(), headers,
|
|
49
|
+
fromServiceWorker: response.fromServiceWorker(),
|
|
50
|
+
});
|
|
51
|
+
return headers;
|
|
52
|
+
})().catch((cause) => error(flow.flowId, cause)));
|
|
53
|
+
};
|
|
54
|
+
const onFinished = (request) => {
|
|
55
|
+
const flow = flows.get(request);
|
|
56
|
+
if (!flow)
|
|
57
|
+
return;
|
|
58
|
+
track((async () => {
|
|
59
|
+
await flow.ready;
|
|
60
|
+
const headers = (await flow.responseReady) || {};
|
|
61
|
+
const contentType = String(headers["content-type"] || "").toLowerCase();
|
|
62
|
+
let omitted;
|
|
63
|
+
if (!/(?:^text\/|json|xml|javascript|x-www-form-urlencoded)/.test(contentType))
|
|
64
|
+
omitted = "binary-or-unknown-content-type";
|
|
65
|
+
if (flow.response?.status() >= 300 && flow.response.status() < 400)
|
|
66
|
+
omitted = "redirect-body-unavailable";
|
|
67
|
+
// Avoid retrieving known-large resources. Unknown sizes are bounded after reading.
|
|
68
|
+
if (Number(headers["content-length"]) > BODY_CAPTURE_LIMIT)
|
|
69
|
+
omitted = "body-too-large";
|
|
70
|
+
if (omitted) {
|
|
71
|
+
await body(flow.flowId, "response", headers, Buffer.alloc(0), { omitted, bytes: Number(headers["content-length"]) || 0 });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await body(flow.flowId, "response", headers, await flow.response.body());
|
|
75
|
+
}
|
|
76
|
+
})().catch(async (cause) => {
|
|
77
|
+
await error(flow.flowId, cause);
|
|
78
|
+
await body(flow.flowId, "response", {}, Buffer.alloc(0), { complete: false, omitted: "unavailable" });
|
|
79
|
+
}).finally(() => { flows.delete(request); }));
|
|
80
|
+
};
|
|
81
|
+
const onFailed = (request) => {
|
|
82
|
+
const flow = flows.get(request);
|
|
83
|
+
if (!flow)
|
|
84
|
+
return;
|
|
85
|
+
track((async () => {
|
|
86
|
+
await flow.ready;
|
|
87
|
+
await flow.responseReady;
|
|
88
|
+
await error(flow.flowId, new Error(request.failure()?.errorText || "Browser request failed."));
|
|
89
|
+
await body(flow.flowId, "response", {}, Buffer.alloc(0), { complete: false, omitted: "request-failed" });
|
|
90
|
+
flows.delete(request);
|
|
91
|
+
})());
|
|
92
|
+
};
|
|
93
|
+
const onClose = () => { track(error(undefined, new Error("Browser context closed during network capture."))); };
|
|
94
|
+
const listeners = { request: onRequest, response: onResponse, requestfinished: onFinished, requestfailed: onFailed, close: onClose };
|
|
95
|
+
for (const [name, listener] of Object.entries(listeners))
|
|
96
|
+
context.on(name, listener);
|
|
97
|
+
const flush = () => Promise.allSettled([...pending]);
|
|
98
|
+
return {
|
|
99
|
+
flush,
|
|
100
|
+
async stop() {
|
|
101
|
+
for (const [name, listener] of Object.entries(listeners))
|
|
102
|
+
context.off(name, listener);
|
|
103
|
+
await flush();
|
|
104
|
+
for (const flow of flows.values()) {
|
|
105
|
+
await body(flow.flowId, "response", {}, Buffer.alloc(0), { complete: false, omitted: "unfinished" });
|
|
106
|
+
}
|
|
107
|
+
flows.clear();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|