@loadmill/droid-cua 2.33.0 → 2.35.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.
@@ -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
+ }
@@ -77,12 +77,22 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
77
77
  break;
78
78
  }
79
79
  case "type": {
80
+ const typingSlowly = platform === "ios" && action.typingMode === "slow";
81
+ const actionText = buildTypeActionText(action, action.text);
80
82
  addOutput({
81
83
  type: "action",
82
- text: buildTypeActionText(action, action.text),
83
- ...meta(buildTypeActionPayload(action, action.text))
84
+ text: typingSlowly ? actionText.replace(":", " slowly:") : actionText,
85
+ ...meta({
86
+ ...buildTypeActionPayload(action, action.text),
87
+ ...(typingSlowly ? { typingMode: "slow" } : {})
88
+ })
84
89
  });
85
- await session.client.type(session.sessionId, action.text);
90
+ if (typingSlowly) {
91
+ await session.client.typeSlowly(session.sessionId, action.text);
92
+ }
93
+ else {
94
+ await session.client.type(session.sessionId, action.text);
95
+ }
86
96
  break;
87
97
  }
88
98
  case "scroll": {
@@ -119,6 +119,12 @@ export class CloudAppiumClient {
119
119
  ]
120
120
  });
121
121
  }
122
+ async typeSlowly(sessionId, text) {
123
+ await this.request("POST", `/session/${sessionId}/execute/sync`, {
124
+ script: "mobile: keys",
125
+ args: [{ keys: [...text] }]
126
+ });
127
+ }
122
128
  async isKeyboardShown(sessionId) {
123
129
  const response = await this.request("POST", `/session/${sessionId}/execute/sync`, {
124
130
  script: "mobile: isKeyboardShown",
@@ -12,17 +12,19 @@ import { logger } from "../../utils/logger.js";
12
12
  import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
13
13
  import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
14
14
  import { resolveScrollGesture } from "../scroll-gesture.js";
15
- import { closeAppiumKeyboard } from "../keyboard.js";
16
- export async function closeKeyboard() {
15
+ import { closeAppiumKeyboardWithTap } from "../keyboard.js";
16
+ export async function closeKeyboard(_deviceId, { dismissalPoint = null, scale = 1 } = {}) {
17
17
  const session = getActiveSession();
18
18
  if (!session) {
19
19
  throw new Error("No active iOS session");
20
20
  }
21
- // The local simulator supports Appium's keyboard command reliably, so it
22
- // needs neither a model-selected tap nor a direct Return keypress.
23
- return closeAppiumKeyboard({
21
+ const dpr = getDevicePixelRatio();
22
+ const tap = dismissalPoint
23
+ ? () => appium.tap(session.sessionId, Math.round((dismissalPoint.x / scale) / dpr), Math.round((dismissalPoint.y / scale) / dpr))
24
+ : null;
25
+ return closeAppiumKeyboardWithTap({
24
26
  isKeyboardShown: () => appium.isKeyboardShown(session.sessionId),
25
- hideKeyboard: () => appium.hideKeyboard(session.sessionId),
27
+ tap,
26
28
  });
27
29
  }
28
30
  /**
@@ -74,12 +76,22 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
74
76
  break;
75
77
  }
76
78
  case "type": {
79
+ const typingSlowly = action.typingMode === "slow";
80
+ const actionText = buildTypeActionText(action, action.text);
77
81
  addOutput({
78
82
  type: "action",
79
- text: buildTypeActionText(action, action.text),
80
- ...meta(buildTypeActionPayload(action, action.text))
83
+ text: typingSlowly ? actionText.replace(":", " slowly:") : actionText,
84
+ ...meta({
85
+ ...buildTypeActionPayload(action, action.text),
86
+ ...(typingSlowly ? { typingMode: "slow" } : {})
87
+ })
81
88
  });
82
- await appium.type(session.sessionId, action.text);
89
+ if (typingSlowly) {
90
+ await appium.typeSlowly(session.sessionId, action.text);
91
+ }
92
+ else {
93
+ await appium.type(session.sessionId, action.text);
94
+ }
83
95
  break;
84
96
  }
85
97
  case "scroll": {
@@ -123,6 +123,18 @@ export async function type(sessionId, text) {
123
123
  ],
124
124
  });
125
125
  }
126
+ /**
127
+ * Type text one key at a time through XCUITest's keyboard input API.
128
+ * @param {string} sessionId
129
+ * @param {string} text
130
+ * @returns {Promise<void>}
131
+ */
132
+ export async function typeSlowly(sessionId, text) {
133
+ await appiumRequest("POST", `/session/${sessionId}/execute/sync`, {
134
+ script: "mobile: keys",
135
+ args: [{ keys: [...text] }],
136
+ });
137
+ }
126
138
  export async function isKeyboardShown(sessionId) {
127
139
  const response = await appiumRequest("POST", `/session/${sessionId}/execute/sync`, {
128
140
  script: "mobile: isKeyboardShown",
@@ -131,9 +143,8 @@ export async function isKeyboardShown(sessionId) {
131
143
  return response.value === true;
132
144
  }
133
145
  export async function hideKeyboard(sessionId) {
134
- // This XCUITest form is used only by the locally validated simulator path.
135
- // Real cloud iOS devices use a guarded tap instead because this command was
136
- // unreliable there and sending Return directly can submit the current form.
146
+ // The CUA close_keyboard path uses a model-selected tap because this command
147
+ // may send Return. Keep the low-level command available for direct callers.
137
148
  await appiumRequest("POST", `/session/${sessionId}/execute/sync`, {
138
149
  script: "mobile: hideKeyboard",
139
150
  args: [{ keys: ["return"] }],
@@ -6,10 +6,9 @@ import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
6
6
  * navigate away. Android's mInputShown flag stays true when a hardware
7
7
  * keyboard suppresses the software keyboard, so that configuration is
8
8
  * treated as already closed before Back is considered.
9
- * - Local iOS and cloud Android use Appium's native keyboard commands, which
10
- * worked reliably in device testing and do not require choosing a tap target.
11
- * - Cloud iOS uses a model-selected tap. XCUITest's hideKeyboard command was
12
- * unreliable on the tested real devices, while Return/newline could submit a
9
+ * - Cloud Android uses Appium's native keyboard command.
10
+ * - Local and cloud iOS use a model-selected tap. XCUITest's hideKeyboard
11
+ * command was unreliable in testing, while Return/newline could submit a
13
12
  * form. The caller therefore supplies a trusted dismissal point instead.
14
13
  *
15
14
  * Every strategy follows the same query -> dismiss -> wait -> verify shape and