@loadmill/droid-cua 2.34.0 → 2.36.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 +37 -5
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/build/index.js +73 -33
- package/build/src/cli/headless-runner.js +89 -14
- package/build/src/cli/headless-selection.js +80 -0
- package/build/src/cli/headless-target.js +50 -3
- package/build/src/commands/help.js +8 -2
- package/build/src/core/session.js +5 -1
- package/build/src/core/test-data-import.js +2 -2
- package/build/src/desktop/computer-use.js +432 -0
- package/build/src/integrations/loadmill/client.js +25 -5
- package/build/src/modes/execution-mode.js +25 -0
- package/build/src/prompts/base.js +38 -4
- package/build/src/prompts/test-data-import.js +2 -2
- package/build/src/utils/cua-debug-tracer.js +2 -12
- package/build/src/utils/error-details.js +56 -0
- package/package.json +4 -2
- package/skill/droid-cua/references/running-and-healing.md +15 -8
|
@@ -4,13 +4,13 @@ function normalizeTarget(value) {
|
|
|
4
4
|
return "mobile";
|
|
5
5
|
}
|
|
6
6
|
if (typeof value !== "string") {
|
|
7
|
-
throw new Error("--target must be one of: mobile, web.");
|
|
7
|
+
throw new Error("--target must be one of: mobile, web, desktop.");
|
|
8
8
|
}
|
|
9
9
|
const normalized = value.trim().toLowerCase();
|
|
10
|
-
if (normalized === "mobile" || normalized === "web") {
|
|
10
|
+
if (normalized === "mobile" || normalized === "web" || normalized === "desktop") {
|
|
11
11
|
return normalized;
|
|
12
12
|
}
|
|
13
|
-
throw new Error("--target must be one of: mobile, web.");
|
|
13
|
+
throw new Error("--target must be one of: mobile, web, desktop.");
|
|
14
14
|
}
|
|
15
15
|
function normalizeSessionMode(value) {
|
|
16
16
|
if (value == null || value === "") {
|
|
@@ -251,6 +251,53 @@ export function resolveHeadlessTarget(args = {}) {
|
|
|
251
251
|
listDevices: false,
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
+
if (target === "desktop") {
|
|
255
|
+
if (avdName || platform || args.browser != null || args["session-mode"] != null) {
|
|
256
|
+
throw new Error("--avd, --platform, --browser, and --session-mode cannot be used with --target desktop.");
|
|
257
|
+
}
|
|
258
|
+
if (deviceSource !== "local") {
|
|
259
|
+
throw new Error("--device-source can only be used with --target mobile.");
|
|
260
|
+
}
|
|
261
|
+
assertNoCloudFlags(args);
|
|
262
|
+
if (args["list-devices"] != null) {
|
|
263
|
+
throw new Error("--list-devices can only be used with --target mobile.");
|
|
264
|
+
}
|
|
265
|
+
const listDesktopApps = args["list-desktop-apps"] != null;
|
|
266
|
+
const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
|
|
267
|
+
const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
|
|
268
|
+
const desktopWindow = rawWindow == null ? null : Number(rawWindow);
|
|
269
|
+
const desktopInput = hasNonEmptyString(args["desktop-input"]) ? args["desktop-input"].trim().toLowerCase() : "physical";
|
|
270
|
+
if (!listDesktopApps && !desktopApp) {
|
|
271
|
+
throw new Error("--desktop-app is required with --target desktop.");
|
|
272
|
+
}
|
|
273
|
+
if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
|
|
274
|
+
throw new Error("--desktop-window must be a non-negative integer window ID.");
|
|
275
|
+
}
|
|
276
|
+
if (desktopInput !== "accessibility" && desktopInput !== "physical") {
|
|
277
|
+
throw new Error('--desktop-input must be "accessibility" or "physical".');
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
target,
|
|
281
|
+
sessionMode: null,
|
|
282
|
+
browserFamily: null,
|
|
283
|
+
deviceSource: "local",
|
|
284
|
+
avdName: null,
|
|
285
|
+
platform: null,
|
|
286
|
+
deviceName: null,
|
|
287
|
+
osVersion: null,
|
|
288
|
+
appPath: null,
|
|
289
|
+
cloudSessionId: null,
|
|
290
|
+
artifacts: null,
|
|
291
|
+
listDevices: false,
|
|
292
|
+
listDesktopApps,
|
|
293
|
+
desktopApp,
|
|
294
|
+
desktopWindow,
|
|
295
|
+
desktopInput,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null) {
|
|
299
|
+
throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
|
|
300
|
+
}
|
|
254
301
|
if (args["session-mode"] != null) {
|
|
255
302
|
throw new Error("--session-mode can only be used with --target web.");
|
|
256
303
|
}
|
|
@@ -15,11 +15,16 @@ export async function handleHelp(args, session, context) {
|
|
|
15
15
|
addOutput({ type: 'info', text: '' });
|
|
16
16
|
addOutput({ type: 'info', text: 'Usage:' });
|
|
17
17
|
addOutput({ type: 'info', text: ' droid-cua --avd <device-name> [options]' });
|
|
18
|
+
addOutput({ type: 'info', text: ' droid-cua run <file-or-directory...> [options]' });
|
|
18
19
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions <file> [options]' });
|
|
19
20
|
addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
|
|
20
21
|
addOutput({ type: 'info', text: '' });
|
|
21
22
|
addOutput({ type: 'info', text: 'Options:' });
|
|
22
|
-
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or
|
|
23
|
+
addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
|
|
24
|
+
addOutput({ type: 'info', text: ' --desktop-app <id> Running desktop app ID or exact name' });
|
|
25
|
+
addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
|
|
26
|
+
addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
|
|
27
|
+
addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
|
|
23
28
|
addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
|
|
24
29
|
addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
|
|
25
30
|
addOutput({ type: 'info', text: ' --device-source <src> Mobile source: local (default) or lambdatest' });
|
|
@@ -28,7 +33,7 @@ export async function handleHelp(args, session, context) {
|
|
|
28
33
|
addOutput({ type: 'info', text: ' --app <path> LambdaTest app package path (.apk or .ipa)' });
|
|
29
34
|
addOutput({ type: 'info', text: ' --artifacts <kinds> Loadmill Cloud: download video,deviceLog,appiumLog (or all) after the run' });
|
|
30
35
|
addOutput({ type: 'info', text: ' --cloud-session-id <id> Loadmill Cloud: reconnect/retroactive-fetch by mtc_ id (implies --device-source loadmill-cloud)' });
|
|
31
|
-
addOutput({ type: 'info', text: ' --instructions <
|
|
36
|
+
addOutput({ type: 'info', text: ' --instructions <path> Legacy headless test file or directory selector' });
|
|
32
37
|
addOutput({ type: 'info', text: ' --scenario <prompt> Generate and run scenario cases for headless instructions' });
|
|
33
38
|
addOutput({ type: 'info', text: ' --test-data <file> Optional test-data.md file for --scenario' });
|
|
34
39
|
addOutput({ type: 'info', text: ' --secrets <pairs> Runtime typing secrets as KEY=value,OTHER=value' });
|
|
@@ -88,6 +93,7 @@ export async function handleHelp(args, session, context) {
|
|
|
88
93
|
addOutput({ type: 'info', text: ' droid-cua --avd avd:Pixel_8_API_35 (Launch Android AVD then connect)' });
|
|
89
94
|
addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
|
|
90
95
|
addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
|
|
96
|
+
addOutput({ type: 'info', text: ' droid-cua run tests/login.dcua admin/refund.dcua --context app/context.md' });
|
|
91
97
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
|
|
92
98
|
addOutput({ type: 'info', text: ' droid-cua --instructions tests --test-data tests/test-data.md --scenario "Run every checkout test with each buyer account"' });
|
|
93
99
|
addOutput({ type: 'info', text: ' droid-cua --target web --instructions tests/search.dcua' });
|
|
@@ -13,7 +13,11 @@ export class Session {
|
|
|
13
13
|
this.browserName = targetInfo?.browserName || null;
|
|
14
14
|
this.currentUrl = targetInfo?.currentUrl || null;
|
|
15
15
|
this.sessionMode = targetInfo?.sessionMode || null;
|
|
16
|
-
this.targetKind = targetInfo?.platform === "browser"
|
|
16
|
+
this.targetKind = targetInfo?.platform === "browser"
|
|
17
|
+
? "browser"
|
|
18
|
+
: targetInfo?.platform === "desktop"
|
|
19
|
+
? "desktop"
|
|
20
|
+
: "mobile";
|
|
17
21
|
this.messages = [];
|
|
18
22
|
this.previousResponseId = null;
|
|
19
23
|
this.transcript = [];
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { resolveEffectiveHelperModel } from "./runtime-config.js";
|
|
3
3
|
import { buildTestDataImportMessages } from "../prompts/test-data-import.js";
|
|
4
|
-
export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt"];
|
|
4
|
+
export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt", ".json", ".xml", ".md"];
|
|
5
5
|
export function isSupportedTestDataImportPath(filePath) {
|
|
6
6
|
return TEST_DATA_IMPORT_EXTENSIONS.includes(path.extname(String(filePath ?? "")).toLowerCase());
|
|
7
7
|
}
|
|
8
8
|
export function assertSupportedTestDataImportPath(filePath) {
|
|
9
9
|
if (!isSupportedTestDataImportPath(filePath)) {
|
|
10
|
-
throw new Error("
|
|
10
|
+
throw new Error("Choose a CSV, TXT, JSON, XML, or Markdown file.");
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
function parseTestDataImportContent(content) {
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { buildActionEventMeta } from "../device/action-event-meta.js";
|
|
2
|
+
import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitive-action.js";
|
|
3
|
+
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
4
|
+
const SCREENSHOT_WIDTH = 1440;
|
|
5
|
+
const ACCESSIBILITY_HIT_TOLERANCE = 12;
|
|
6
|
+
const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
|
|
7
|
+
const SUPPORTED_PLATFORMS = new Set(["darwin", "win32"]);
|
|
8
|
+
const INPUT_MODES = new Set(["accessibility", "physical"]);
|
|
9
|
+
function parseJsonResult(result, label) {
|
|
10
|
+
if (result?.isError) {
|
|
11
|
+
throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
|
|
12
|
+
}
|
|
13
|
+
if (result?.structuredContent) {
|
|
14
|
+
return result.structuredContent;
|
|
15
|
+
}
|
|
16
|
+
const text = result?.content?.find((item) => item.type === "text")?.text;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(text);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`${label} returned an invalid response.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function assertResult(result, label) {
|
|
25
|
+
if (result?.isError) {
|
|
26
|
+
throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function sameBounds(left, right) {
|
|
30
|
+
return left && right && left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
|
|
31
|
+
}
|
|
32
|
+
function getScreenshotContentBounds(width, height, windowBounds, platform) {
|
|
33
|
+
const fullScreenshot = { x: 0, y: 0, width, height };
|
|
34
|
+
if (platform !== "darwin")
|
|
35
|
+
return fullScreenshot;
|
|
36
|
+
const { left, right, top, bottom } = MACOS_CAPTURE_INSETS;
|
|
37
|
+
const scaleX = width / (windowBounds.width + left + right);
|
|
38
|
+
const scaleY = height / (windowBounds.height + top + bottom);
|
|
39
|
+
if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > 0.02)
|
|
40
|
+
return fullScreenshot;
|
|
41
|
+
// ponytail: computer-use-mcp v7 omits capture bounds; remove this calibration when upstream exposes them.
|
|
42
|
+
return {
|
|
43
|
+
x: left * scaleX,
|
|
44
|
+
y: top * scaleY,
|
|
45
|
+
width: windowBounds.width * scaleX,
|
|
46
|
+
height: windowBounds.height * scaleY,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function formatPermissionError(error, platform) {
|
|
50
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
+
if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
|
|
52
|
+
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
|
+
}
|
|
54
|
+
if (platform === "win32" && /access denied|elevat|integrity level|could not focus/i.test(message)) {
|
|
55
|
+
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
|
+
}
|
|
57
|
+
return message;
|
|
58
|
+
}
|
|
59
|
+
async function createLibraryClient() {
|
|
60
|
+
const [{ createComputerUseServer }, { connectInProcess }] = await Promise.all([
|
|
61
|
+
import("@zavora-ai/computer-use-mcp"),
|
|
62
|
+
import("@zavora-ai/computer-use-mcp/client"),
|
|
63
|
+
]);
|
|
64
|
+
return connectInProcess(createComputerUseServer({ profile: "ax", vision: true }));
|
|
65
|
+
}
|
|
66
|
+
async function loadAccessibilityDriver() {
|
|
67
|
+
const { loadNative } = await import("@zavora-ai/computer-use-mcp/host-native");
|
|
68
|
+
return loadNative();
|
|
69
|
+
}
|
|
70
|
+
function walkAccessibilityTree(root, depth = 0, elements = []) {
|
|
71
|
+
if (!root || typeof root !== "object")
|
|
72
|
+
return elements;
|
|
73
|
+
elements.push({ ...root, depth });
|
|
74
|
+
for (const child of Array.isArray(root.children) ? root.children : []) {
|
|
75
|
+
walkAccessibilityTree(child, depth + 1, elements);
|
|
76
|
+
}
|
|
77
|
+
return elements;
|
|
78
|
+
}
|
|
79
|
+
function distanceToBounds(bounds, x, y) {
|
|
80
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0)
|
|
81
|
+
return Infinity;
|
|
82
|
+
const dx = Math.max(bounds.x - x, 0, x - bounds.x - bounds.width);
|
|
83
|
+
const dy = Math.max(bounds.y - y, 0, y - bounds.y - bounds.height);
|
|
84
|
+
return Math.hypot(dx, dy);
|
|
85
|
+
}
|
|
86
|
+
function actionFor(element, actionType, direction = null) {
|
|
87
|
+
const actions = Array.isArray(element.actions) ? element.actions : [];
|
|
88
|
+
if (actionType === "click")
|
|
89
|
+
return actions.find((action) => /press|invoke|click/i.test(action));
|
|
90
|
+
if (actionType === "double_click")
|
|
91
|
+
return actions.find((action) => /open|double.?click/i.test(action));
|
|
92
|
+
if (actionType === "scroll") {
|
|
93
|
+
const expected = direction === "up" ? /scroll.?up/i
|
|
94
|
+
: direction === "down" ? /scroll.?down/i
|
|
95
|
+
: direction === "left" ? /scroll.?left/i
|
|
96
|
+
: /scroll.?right/i;
|
|
97
|
+
return actions.find((action) => expected.test(action));
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function chooseAccessibilityElement(root, x, y, actionType, direction = null) {
|
|
102
|
+
const elements = walkAccessibilityTree(root);
|
|
103
|
+
const candidates = elements
|
|
104
|
+
.map((element) => ({ element, action: actionFor(element, actionType, direction), distance: distanceToBounds(element.bounds, x, y) }))
|
|
105
|
+
.filter(({ element, action, distance }) => action && element.role && String(element.label || "").trim() && distance <= ACCESSIBILITY_HIT_TOLERANCE)
|
|
106
|
+
.sort((left, right) => {
|
|
107
|
+
const leftArea = left.element.bounds.width * left.element.bounds.height;
|
|
108
|
+
const rightArea = right.element.bounds.width * right.element.bounds.height;
|
|
109
|
+
return left.distance - right.distance || leftArea - rightArea || right.element.depth - left.element.depth;
|
|
110
|
+
});
|
|
111
|
+
const selected = candidates[0];
|
|
112
|
+
if (!selected)
|
|
113
|
+
return null;
|
|
114
|
+
if (selected.distance > 0 && candidates[1]?.distance === selected.distance)
|
|
115
|
+
return null;
|
|
116
|
+
const duplicates = elements.filter((element) => element.role === selected.element.role && element.label === selected.element.label);
|
|
117
|
+
return duplicates.length === 1 ? selected : null;
|
|
118
|
+
}
|
|
119
|
+
function cursorFreeError(actionType) {
|
|
120
|
+
return `Cursor-free desktop ${actionType.replaceAll("_", " ")} is unavailable at this location because the application did not expose a unique accessibility action. No mouse input was sent. Re-run with --desktop-input physical to allow OS pointer control.`;
|
|
121
|
+
}
|
|
122
|
+
function cursorFreeKeyboardError(actionType) {
|
|
123
|
+
return `Cursor-free desktop ${actionType.replaceAll("_", " ")} is unavailable because keyboard input requires application focus. No keyboard input was sent. Re-run with --desktop-input physical to allow focused OS input.`;
|
|
124
|
+
}
|
|
125
|
+
function readApplications(result) {
|
|
126
|
+
const data = parseJsonResult(result, "Application discovery");
|
|
127
|
+
return Array.isArray(data) ? data : [];
|
|
128
|
+
}
|
|
129
|
+
async function discoverApplications(client) {
|
|
130
|
+
let applications = readApplications(await client.listRunningApps());
|
|
131
|
+
if (applications.length === 0) {
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
133
|
+
applications = readApplications(await client.listRunningApps());
|
|
134
|
+
}
|
|
135
|
+
return applications;
|
|
136
|
+
}
|
|
137
|
+
function readWindows(result) {
|
|
138
|
+
const data = parseJsonResult(result, "Window discovery");
|
|
139
|
+
return Array.isArray(data) ? data : Array.isArray(data.windows) ? data.windows : [];
|
|
140
|
+
}
|
|
141
|
+
function chooseApplication(apps, requestedApp) {
|
|
142
|
+
const needle = String(requestedApp || "").trim().toLowerCase();
|
|
143
|
+
const matches = apps.filter((app) => app.bundleId?.toLowerCase() === needle || app.displayName?.trim().toLowerCase() === needle);
|
|
144
|
+
if (matches.length === 1)
|
|
145
|
+
return matches[0];
|
|
146
|
+
if (matches.length > 1) {
|
|
147
|
+
throw new Error(`Desktop application "${requestedApp}" is ambiguous. Use its application ID instead.`);
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`Desktop application "${requestedApp}" is not running. Use --list-desktop-apps to see available applications.`);
|
|
150
|
+
}
|
|
151
|
+
function chooseWindow(windows, requestedWindowId) {
|
|
152
|
+
const visible = windows.filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
|
|
153
|
+
if (requestedWindowId != null) {
|
|
154
|
+
const selected = visible.find((window) => window.windowId === requestedWindowId);
|
|
155
|
+
if (!selected)
|
|
156
|
+
throw new Error(`Desktop window ${requestedWindowId} was not found for the selected application.`);
|
|
157
|
+
return selected;
|
|
158
|
+
}
|
|
159
|
+
const focused = visible.filter((window) => window.isFocused);
|
|
160
|
+
if (focused.length === 1)
|
|
161
|
+
return focused[0];
|
|
162
|
+
if (visible.length === 1)
|
|
163
|
+
return visible[0];
|
|
164
|
+
if (visible.length === 0)
|
|
165
|
+
throw new Error("The selected desktop application has no visible windows.");
|
|
166
|
+
const choices = visible.map((window) => `${window.windowId}: ${window.title || "Untitled"}`).join("\n");
|
|
167
|
+
throw new Error(`The selected desktop application has multiple windows. Choose one with --desktop-window <id>:\n${choices}`);
|
|
168
|
+
}
|
|
169
|
+
function normalizeShortcut(keys, platform) {
|
|
170
|
+
if (!Array.isArray(keys) || keys.length === 0)
|
|
171
|
+
throw new Error("Keypress action is missing keys.");
|
|
172
|
+
const aliases = {
|
|
173
|
+
CTRL: "control",
|
|
174
|
+
CONTROL: "control",
|
|
175
|
+
CMD: "command",
|
|
176
|
+
COMMAND: "command",
|
|
177
|
+
META: platform === "darwin" ? "command" : "win",
|
|
178
|
+
OPTION: "alt",
|
|
179
|
+
ALT: "alt",
|
|
180
|
+
ESC: "escape",
|
|
181
|
+
RETURN: "enter",
|
|
182
|
+
};
|
|
183
|
+
return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", client?: any, accessibility?: any }} options
|
|
187
|
+
*/
|
|
188
|
+
export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", client: injectedClient = null, accessibility: injectedAccessibility = null, } = {}) {
|
|
189
|
+
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
190
|
+
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
191
|
+
}
|
|
192
|
+
if (!requestedApp)
|
|
193
|
+
throw new Error("--desktop-app is required with --target desktop.");
|
|
194
|
+
if (!INPUT_MODES.has(inputMode))
|
|
195
|
+
throw new Error('Desktop input mode must be "accessibility" or "physical".');
|
|
196
|
+
const client = injectedClient || await createLibraryClient();
|
|
197
|
+
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));
|
|
202
|
+
let capture = null;
|
|
203
|
+
let deviceInfo = null;
|
|
204
|
+
let accessibility = injectedAccessibility;
|
|
205
|
+
async function getWindow() {
|
|
206
|
+
return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
|
|
207
|
+
}
|
|
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;
|
|
216
|
+
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");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
throw new Error(formatPermissionError(error, platform));
|
|
228
|
+
}
|
|
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
|
+
}
|
|
250
|
+
function scaledPoint(x, y) {
|
|
251
|
+
if (!capture)
|
|
252
|
+
throw new Error("Capture the desktop window before executing an action.");
|
|
253
|
+
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),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
async function prepareAction(requireFocus) {
|
|
260
|
+
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)) {
|
|
264
|
+
throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
|
|
265
|
+
}
|
|
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.");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
|
|
275
|
+
async function performCursorFreeAction(actionType, x, y, direction = null) {
|
|
276
|
+
const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
|
|
277
|
+
const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
|
|
278
|
+
if (!selected)
|
|
279
|
+
throw new Error(cursorFreeError(actionType));
|
|
280
|
+
accessibility ||= await loadAccessibilityDriver();
|
|
281
|
+
const result = accessibility.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
|
|
282
|
+
if (!result?.performed)
|
|
283
|
+
throw new Error(`${cursorFreeError(actionType)}${result?.reason ? ` Reason: ${result.reason}.` : ""}`);
|
|
284
|
+
}
|
|
285
|
+
async function handleAction(_deviceId, action, _scale = 1, context = null) {
|
|
286
|
+
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
287
|
+
const meta = (payload = {}) => buildActionEventMeta({ context, action, platform: "desktop", payload });
|
|
288
|
+
try {
|
|
289
|
+
if (context?.shouldStop?.())
|
|
290
|
+
return { success: false, error: "Stopped by user" };
|
|
291
|
+
if (inputMode === "accessibility" && (action.type === "type" || action.type === "keypress")) {
|
|
292
|
+
throw new Error(cursorFreeKeyboardError(action.type));
|
|
293
|
+
}
|
|
294
|
+
const requiresFocus = inputMode === "physical";
|
|
295
|
+
if (action.type !== "wait")
|
|
296
|
+
await prepareAction(requiresFocus);
|
|
297
|
+
switch (action.type) {
|
|
298
|
+
case "click":
|
|
299
|
+
case "double_click": {
|
|
300
|
+
const [x, y] = scaledPoint(action.x, action.y);
|
|
301
|
+
addOutput({ type: "action", text: action.type === "click" ? "Click" : `Double-clicking at (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
|
|
302
|
+
if (inputMode === "accessibility") {
|
|
303
|
+
await performCursorFreeAction(action.type, x, y);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const result = action.type === "click"
|
|
307
|
+
? await client.click(x, y, application.bundleId, targetOptions())
|
|
308
|
+
: await client.doubleClick(x, y, application.bundleId, targetOptions());
|
|
309
|
+
assertResult(result, "Desktop click");
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "move": {
|
|
314
|
+
const [x, y] = scaledPoint(action.x, action.y);
|
|
315
|
+
addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
|
|
316
|
+
if (inputMode === "accessibility")
|
|
317
|
+
throw new Error(cursorFreeError(action.type));
|
|
318
|
+
assertResult(await client.moveMouse(x, y, application.bundleId, targetOptions()), "Desktop mouse move");
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "drag": {
|
|
322
|
+
if (!Array.isArray(action.path) || action.path.length < 2)
|
|
323
|
+
throw new Error("Drag action requires a path with at least two points.");
|
|
324
|
+
const from = scaledPoint(action.path[0].x, action.path[0].y);
|
|
325
|
+
const last = action.path[action.path.length - 1];
|
|
326
|
+
const to = scaledPoint(last.x, last.y);
|
|
327
|
+
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
|
+
if (inputMode === "accessibility")
|
|
329
|
+
throw new Error(cursorFreeError(action.type));
|
|
330
|
+
assertResult(await client.drag(to, from, application.bundleId, targetOptions()), "Desktop drag");
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "scroll": {
|
|
334
|
+
const [x, y] = scaledPoint(action.x ?? capture.width / 2, action.y ?? capture.height / 2);
|
|
335
|
+
const scrollX = Number(action.scroll_x) || 0;
|
|
336
|
+
const scrollY = Number(action.scroll_y) || 0;
|
|
337
|
+
const horizontal = Math.abs(scrollX) > Math.abs(scrollY);
|
|
338
|
+
const direction = horizontal ? (scrollX > 0 ? "right" : "left") : (scrollY > 0 ? "down" : "up");
|
|
339
|
+
const amount = Math.max(1, Math.min(10, Math.round(Math.abs(horizontal ? scrollX : scrollY) / 100)));
|
|
340
|
+
addOutput({ type: "action", text: `Scrolling ${direction}`, ...meta({ scrollX, scrollY, x, y, unit: "px" }) });
|
|
341
|
+
if (inputMode === "accessibility") {
|
|
342
|
+
await performCursorFreeAction(action.type, x, y, direction);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
assertResult(await client.scroll(x, y, direction, amount, application.bundleId, targetOptions()), "Desktop scroll");
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "type": {
|
|
350
|
+
const text = typeof action.text === "string" ? action.text : "";
|
|
351
|
+
addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
|
|
352
|
+
assertResult(await client.type(text, application.bundleId, targetOptions()), "Desktop typing");
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case "keypress": {
|
|
356
|
+
const shortcut = normalizeShortcut(action.keys, platform);
|
|
357
|
+
addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
|
|
358
|
+
assertResult(await client.key(shortcut, application.bundleId, targetOptions()), "Desktop keypress");
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "wait": {
|
|
362
|
+
addOutput({ type: "action", text: "Waiting...", ...meta() });
|
|
363
|
+
const timeoutMs = Number.isFinite(action.ms) && action.ms >= 0 ? Math.round(action.ms) : getConfiguredStepDelayMs();
|
|
364
|
+
await new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
default:
|
|
368
|
+
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
369
|
+
}
|
|
370
|
+
return { success: true };
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
const message = formatPermissionError(error, platform);
|
|
374
|
+
addOutput({ type: "error", text: message, eventType: "error", actionType: action?.type, payload: { message, platform: "desktop" } });
|
|
375
|
+
return { success: false, error: message };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
await screenshot();
|
|
379
|
+
deviceInfo = {
|
|
380
|
+
platform: "desktop",
|
|
381
|
+
source: "local",
|
|
382
|
+
device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
|
|
383
|
+
device_width: targetWindow.bounds.width,
|
|
384
|
+
device_height: targetWindow.bounds.height,
|
|
385
|
+
scaled_width: capture.width,
|
|
386
|
+
scaled_height: capture.height,
|
|
387
|
+
scale: capture.width / targetWindow.bounds.width,
|
|
388
|
+
desktopAppId: application.bundleId,
|
|
389
|
+
desktopWindowId: targetWindow.windowId,
|
|
390
|
+
desktopInputMode: inputMode,
|
|
391
|
+
};
|
|
392
|
+
return {
|
|
393
|
+
application,
|
|
394
|
+
window: targetWindow,
|
|
395
|
+
deviceId: `desktop:${targetWindow.windowId}`,
|
|
396
|
+
deviceInfo,
|
|
397
|
+
getScreenshotAsBase64: screenshot,
|
|
398
|
+
handleModelAction: handleAction,
|
|
399
|
+
close: () => client.close(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
await client.close().catch(() => { });
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* @param {{ excludePids?: number[], platform?: string, client?: any }} options
|
|
409
|
+
*/
|
|
410
|
+
export async function listDesktopApplications({ excludePids = [], platform = process.platform, client: injectedClient = null } = {}) {
|
|
411
|
+
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
412
|
+
throw new Error("Desktop control is currently supported only on macOS and Windows.");
|
|
413
|
+
}
|
|
414
|
+
const client = injectedClient || await createLibraryClient();
|
|
415
|
+
try {
|
|
416
|
+
const apps = (await discoverApplications(client))
|
|
417
|
+
.filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid))
|
|
418
|
+
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
419
|
+
const entries = [];
|
|
420
|
+
for (const app of apps) {
|
|
421
|
+
const windows = readWindows(await client.listWindows(app.bundleId))
|
|
422
|
+
.filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
|
|
423
|
+
if (windows.length > 0)
|
|
424
|
+
entries.push({ ...app, windows });
|
|
425
|
+
}
|
|
426
|
+
return entries;
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
if (!injectedClient)
|
|
430
|
+
await client.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { extractLoadmillSessionUser } from "./session-user.js";
|
|
5
5
|
const DEFAULT_BASE_URL = "https://app.loadmill.com/api";
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{
|
|
8
|
+
* id: string,
|
|
9
|
+
* email: string | null,
|
|
10
|
+
* displayName: string | null,
|
|
11
|
+
* teamId: string | null,
|
|
12
|
+
* }} LoadmillSessionUser
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {
|
|
16
|
+
* | { valid: true, user: LoadmillSessionUser, status: number, requestId?: string }
|
|
17
|
+
* | { valid: false, reason: "missing" | "invalid" | "unavailable" | "malformed", status?: number, requestId?: string }
|
|
18
|
+
* } ApiTokenValidationResult
|
|
19
|
+
*/
|
|
6
20
|
function normalizeApiBaseUrl(rawBaseUrl) {
|
|
7
21
|
const candidate = (rawBaseUrl || DEFAULT_BASE_URL).trim();
|
|
8
22
|
if (!candidate) {
|
|
@@ -150,6 +164,7 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
150
164
|
body: JSON.stringify(payload),
|
|
151
165
|
});
|
|
152
166
|
}
|
|
167
|
+
/** @returns {Promise<ApiTokenValidationResult>} */
|
|
153
168
|
async function validateApiToken() {
|
|
154
169
|
const resolvedToken = getApiToken();
|
|
155
170
|
if (!resolvedToken) {
|
|
@@ -162,20 +177,25 @@ export function createLoadmillApiClient({ token = null, baseUrl = null, onUnauth
|
|
|
162
177
|
Authorization: `Bearer ${resolvedToken}`,
|
|
163
178
|
},
|
|
164
179
|
});
|
|
180
|
+
const requestId = response.headers.get("x-request-id") || response.headers.get("request-id") || undefined;
|
|
181
|
+
const responseMetadata = {
|
|
182
|
+
status: response.status,
|
|
183
|
+
...(requestId ? { requestId } : {}),
|
|
184
|
+
};
|
|
165
185
|
if (response.status === 401 || response.status === 403) {
|
|
166
|
-
return { valid: false, reason: "invalid",
|
|
186
|
+
return { valid: false, reason: "invalid", ...responseMetadata };
|
|
167
187
|
}
|
|
168
188
|
if (!response.ok) {
|
|
169
|
-
return { valid: false, reason: "unavailable",
|
|
189
|
+
return { valid: false, reason: "unavailable", ...responseMetadata };
|
|
170
190
|
}
|
|
171
191
|
try {
|
|
172
192
|
const user = extractLoadmillSessionUser(await response.json());
|
|
173
193
|
return user
|
|
174
|
-
? { valid: true, user }
|
|
175
|
-
: { valid: false, reason: "
|
|
194
|
+
? { valid: true, user, ...responseMetadata }
|
|
195
|
+
: { valid: false, reason: "malformed", ...responseMetadata };
|
|
176
196
|
}
|
|
177
197
|
catch {
|
|
178
|
-
return { valid: false, reason: "
|
|
198
|
+
return { valid: false, reason: "malformed", ...responseMetadata };
|
|
179
199
|
}
|
|
180
200
|
}
|
|
181
201
|
async function getCurrentUser() {
|