1use serde::Serialize;
2use serde::de::DeserializeOwned;
3use serde_json::{Map, Value};
4
5use crate::backend::process::{ProcessFailure, ProcessRequest, ProcessRunner, map_failure};
6use crate::error::CliError;
7use crate::model::{
8 AxClickRequest, AxClickResult, AxListRequest, AxListResult, AxSelector, AxTypeRequest,
9 AxTypeResult,
10};
11use crate::test_mode;
12
13const FRONTMOST_APP_SCRIPT: &str = r#"tell application "System Events" to get name of first application process whose frontmost is true"#;
14const FRONTMOST_BUNDLE_ID_SCRIPT: &str = r#"tell application "System Events" to get bundle identifier of first application process whose frontmost is true"#;
15
16const AX_LIST_JXA_SCRIPT: &str = r#"function run(argv) {
17 function safe(callable, fallbackValue) {
18 try { return callable(); } catch (_err) { return fallbackValue; }
19 }
20
21 function normalize(value) {
22 if (value === null || value === undefined) { return ""; }
23 return String(value);
24 }
25
26 function attr(element, name, fallbackValue) {
27 return safe(function () { return element.attributes.byName(name).value(); }, fallbackValue);
28 }
29
30 function boolAttr(element, name, fallbackValue) {
31 var value = attr(element, name, fallbackValue);
32 if (typeof value === "boolean") { return value; }
33 if (value === null || value === undefined) { return !!fallbackValue; }
34 return String(value).toLowerCase() === "true";
35 }
36
37 function actionNames(element) {
38 var out = [];
39 var actions = safe(function () { return element.actions(); }, []);
40 for (var i = 0; i < actions.length; i += 1) {
41 var name = safe(function () { return actions[i].name(); }, null);
42 if (name !== null && name !== undefined && String(name).length > 0) {
43 out.push(String(name));
44 }
45 }
46 return out;
47 }
48
49 function frameFor(element) {
50 var pos = attr(element, "AXPosition", null);
51 var size = attr(element, "AXSize", null);
52 if (!pos || !size || pos.length < 2 || size.length < 2) { return null; }
53 return {
54 x: Number(pos[0]),
55 y: Number(pos[1]),
56 width: Number(size[0]),
57 height: Number(size[1])
58 };
59 }
60
61 function valuePreview(element) {
62 var value = attr(element, "AXValue", null);
63 if (value === null || value === undefined) { return null; }
64 var text = String(value);
65 if (text.length > 160) { text = text.slice(0, 160) + "..."; }
66 return text;
67 }
68
69 function resolveProcess(systemEvents, target) {
70 if (target && target.app) {
71 var byName = systemEvents.applicationProcesses.whose({ name: { _equals: String(target.app) } })();
72 if (byName.length > 0) { return byName[0]; }
73 }
74 if (target && target.bundle_id) {
75 var byBundle = systemEvents.applicationProcesses.whose({ bundleIdentifier: { _equals: String(target.bundle_id) } })();
76 if (byBundle.length > 0) { return byBundle[0]; }
77 }
78 var frontmost = systemEvents.applicationProcesses.whose({ frontmost: true })();
79 if (frontmost.length > 0) { return frontmost[0]; }
80 return null;
81 }
82
83 var payload = {};
84 if (argv.length > 0 && argv[0]) { payload = JSON.parse(argv[0]); }
85 var roleFilter = payload.role ? String(payload.role).toLowerCase() : null;
86 var titleFilter = payload.title_contains ? String(payload.title_contains).toLowerCase() : null;
87 var maxDepth = payload.max_depth === null || payload.max_depth === undefined ? null : Number(payload.max_depth);
88 var limit = payload.limit === null || payload.limit === undefined ? null : Number(payload.limit);
89
90 var systemEvents = Application("System Events");
91 var process = resolveProcess(systemEvents, payload.target || {});
92 if (!process) { throw new Error("unable to resolve target app process for ax.list"); }
93
94 var roots = safe(function () { return process.windows(); }, []);
95 if (!roots || roots.length === 0) {
96 roots = safe(function () { return process.uiElements(); }, []);
97 }
98
99 var nodes = [];
100
101 function matches(node) {
102 if (roleFilter && node.role.toLowerCase() !== roleFilter) { return false; }
103 if (titleFilter) {
104 var title = (node.title || "").toLowerCase();
105 var identifier = (node.identifier || "").toLowerCase();
106 if (title.indexOf(titleFilter) === -1 && identifier.indexOf(titleFilter) === -1) {
107 return false;
108 }
109 }
110 return true;
111 }
112
113 function visit(element, path, depth) {
114 if (limit !== null && nodes.length >= limit) { return; }
115 var role = normalize(attr(element, "AXRole", safe(function () { return element.role(); }, "")));
116 var title = normalize(attr(element, "AXTitle", safe(function () { return element.title(); }, "")));
117 var identifier = normalize(attr(element, "AXIdentifier", null));
118 var subrole = normalize(attr(element, "AXSubrole", null));
119 var node = {
120 node_id: path.join("."),
121 role: role,
122 subrole: subrole.length > 0 ? subrole : null,
123 title: title.length > 0 ? title : null,
124 identifier: identifier.length > 0 ? identifier : null,
125 value_preview: valuePreview(element),
126 enabled: boolAttr(element, "AXEnabled", true),
127 focused: boolAttr(element, "AXFocused", false),
128 frame: frameFor(element),
129 actions: actionNames(element),
130 path: path
131 };
132 if (matches(node)) { nodes.push(node); }
133 if (maxDepth !== null && depth >= maxDepth) { return; }
134
135 var children = safe(function () { return element.uiElements(); }, []);
136 for (var i = 0; i < children.length; i += 1) {
137 visit(children[i], path.concat([String(i + 1)]), depth + 1);
138 if (limit !== null && nodes.length >= limit) { return; }
139 }
140 }
141
142 for (var rootIdx = 0; rootIdx < roots.length; rootIdx += 1) {
143 visit(roots[rootIdx], [String(rootIdx + 1)], 0);
144 if (limit !== null && nodes.length >= limit) { break; }
145 }
146
147 return JSON.stringify({ nodes: nodes, warnings: [] });
148}"#;
149
150const AX_CLICK_JXA_SCRIPT: &str = r#"function run(argv) {
151 function safe(callable, fallbackValue) {
152 try { return callable(); } catch (_err) { return fallbackValue; }
153 }
154
155 function normalize(value) {
156 if (value === null || value === undefined) { return ""; }
157 return String(value);
158 }
159
160 function attr(element, name, fallbackValue) {
161 return safe(function () { return element.attributes.byName(name).value(); }, fallbackValue);
162 }
163
164 function frameFor(element) {
165 var pos = attr(element, "AXPosition", null);
166 var size = attr(element, "AXSize", null);
167 if (!pos || !size || pos.length < 2 || size.length < 2) { return null; }
168 return {
169 x: Math.round(Number(pos[0]) + Number(size[0]) / 2),
170 y: Math.round(Number(pos[1]) + Number(size[1]) / 2)
171 };
172 }
173
174 function resolveProcess(systemEvents, target) {
175 if (target && target.app) {
176 var byName = systemEvents.applicationProcesses.whose({ name: { _equals: String(target.app) } })();
177 if (byName.length > 0) { return byName[0]; }
178 }
179 if (target && target.bundle_id) {
180 var byBundle = systemEvents.applicationProcesses.whose({ bundleIdentifier: { _equals: String(target.bundle_id) } })();
181 if (byBundle.length > 0) { return byBundle[0]; }
182 }
183 var frontmost = systemEvents.applicationProcesses.whose({ frontmost: true })();
184 if (frontmost.length > 0) { return frontmost[0]; }
185 return null;
186 }
187
188 function nodeFrom(element, path) {
189 var role = normalize(attr(element, "AXRole", safe(function () { return element.role(); }, "")));
190 var title = normalize(attr(element, "AXTitle", safe(function () { return element.title(); }, "")));
191 var identifier = normalize(attr(element, "AXIdentifier", null));
192 return {
193 node_id: path.join("."),
194 role: role,
195 title: title,
196 identifier: identifier
197 };
198 }
199
200 function resolveByNodeId(roots, nodeId) {
201 var segments = String(nodeId).split(".");
202 if (segments.length === 0) { return null; }
203 var rootIndex = Number(segments[0]);
204 if (!rootIndex || rootIndex < 1 || rootIndex > roots.length) { return null; }
205 var element = roots[rootIndex - 1];
206 var path = [String(rootIndex)];
207 for (var i = 1; i < segments.length; i += 1) {
208 var index = Number(segments[i]);
209 if (!index || index < 1) { return null; }
210 var children = safe(function () { return element.uiElements(); }, []);
211 if (!children || index > children.length) { return null; }
212 element = children[index - 1];
213 path.push(String(index));
214 }
215 return { element: element, node: nodeFrom(element, path) };
216 }
217
218 var payload = {};
219 if (argv.length > 0 && argv[0]) { payload = JSON.parse(argv[0]); }
220 var selector = payload.selector || {};
221 var roleFilter = selector.role ? String(selector.role).toLowerCase() : null;
222 var titleFilter = selector.title_contains ? String(selector.title_contains).toLowerCase() : null;
223 var nth = selector.nth === null || selector.nth === undefined ? null : Number(selector.nth);
224 var allowCoordinateFallback = !!payload.allow_coordinate_fallback;
225
226 var systemEvents = Application("System Events");
227 var process = resolveProcess(systemEvents, payload.target || {});
228 if (!process) { throw new Error("unable to resolve target app process for ax.click"); }
229
230 var roots = safe(function () { return process.windows(); }, []);
231 if (!roots || roots.length === 0) { roots = safe(function () { return process.uiElements(); }, []); }
232
233 var matches = [];
234 if (selector.node_id) {
235 var byId = resolveByNodeId(roots, selector.node_id);
236 if (byId !== null) { matches = [byId]; }
237 } else {
238 function walk(element, path) {
239 var node = nodeFrom(element, path);
240 var roleMatch = roleFilter === null || node.role.toLowerCase() === roleFilter;
241 var titleLower = node.title.toLowerCase();
242 var identifierLower = node.identifier.toLowerCase();
243 var titleMatch = titleFilter === null || titleLower.indexOf(titleFilter) !== -1 || identifierLower.indexOf(titleFilter) !== -1;
244 if (roleMatch && titleMatch) { matches.push({ element: element, node: node }); }
245 var children = safe(function () { return element.uiElements(); }, []);
246 for (var i = 0; i < children.length; i += 1) {
247 walk(children[i], path.concat([String(i + 1)]));
248 }
249 }
250
251 for (var rootIdx = 0; rootIdx < roots.length; rootIdx += 1) {
252 walk(roots[rootIdx], [String(rootIdx + 1)]);
253 }
254 }
255
256 if (matches.length === 0) { throw new Error("selector returned zero AX matches"); }
257 var matchedCount = matches.length;
258
259 var selected = null;
260 if (selector.node_id) {
261 selected = matches[0];
262 } else if (nth !== null) {
263 if (nth < 1 || nth > matches.length) {
264 throw new Error("selector nth is out of range");
265 }
266 selected = matches[nth - 1];
267 } else {
268 if (matches.length !== 1) {
269 throw new Error("selector is ambiguous; add --nth or narrow role/title filters");
270 }
271 selected = matches[0];
272 }
273
274 var result = {
275 node_id: selected.node.node_id,
276 matched_count: matchedCount,
277 action: "ax-press",
278 used_coordinate_fallback: false
279 };
280
281 var actions = safe(function () { return selected.element.actions(); }, []);
282 var pressAction = null;
283 for (var idx = 0; idx < actions.length; idx += 1) {
284 var actionName = normalize(safe(function () { return actions[idx].name(); }, ""));
285 if (actionName === "AXPress" || actionName === "AXConfirm") {
286 pressAction = actions[idx];
287 break;
288 }
289 }
290
291 try {
292 if (!pressAction) { throw new Error("AXPress action unavailable"); }
293 pressAction.perform();
294 } catch (err) {
295 if (!allowCoordinateFallback) { throw err; }
296 var center = frameFor(selected.element);
297 if (!center) {
298 throw new Error("coordinate fallback requested but AXPosition/AXSize unavailable");
299 }
300 result.action = "ax-press-fallback";
301 result.used_coordinate_fallback = true;
302 result.fallback_x = center.x;
303 result.fallback_y = center.y;
304 }
305
306 return JSON.stringify(result);
307}"#;
308
309const AX_TYPE_JXA_SCRIPT: &str = r#"function run(argv) {
310 function safe(callable, fallbackValue) {
311 try { return callable(); } catch (_err) { return fallbackValue; }
312 }
313
314 function normalize(value) {
315 if (value === null || value === undefined) { return ""; }
316 return String(value);
317 }
318
319 function attr(element, name, fallbackValue) {
320 return safe(function () { return element.attributes.byName(name).value(); }, fallbackValue);
321 }
322
323 function setAttr(element, name, value) {
324 var attribute = element.attributes.byName(name);
325 attribute.value = value;
326 }
327
328 function resolveProcess(systemEvents, target) {
329 if (target && target.app) {
330 var byName = systemEvents.applicationProcesses.whose({ name: { _equals: String(target.app) } })();
331 if (byName.length > 0) { return byName[0]; }
332 }
333 if (target && target.bundle_id) {
334 var byBundle = systemEvents.applicationProcesses.whose({ bundleIdentifier: { _equals: String(target.bundle_id) } })();
335 if (byBundle.length > 0) { return byBundle[0]; }
336 }
337 var frontmost = systemEvents.applicationProcesses.whose({ frontmost: true })();
338 if (frontmost.length > 0) { return frontmost[0]; }
339 return null;
340 }
341
342 function nodeFrom(element, path) {
343 var role = normalize(attr(element, "AXRole", safe(function () { return element.role(); }, "")));
344 var title = normalize(attr(element, "AXTitle", safe(function () { return element.title(); }, "")));
345 var identifier = normalize(attr(element, "AXIdentifier", null));
346 return {
347 node_id: path.join("."),
348 role: role,
349 title: title,
350 identifier: identifier
351 };
352 }
353
354 function resolveByNodeId(roots, nodeId) {
355 var segments = String(nodeId).split(".");
356 if (segments.length === 0) { return null; }
357 var rootIndex = Number(segments[0]);
358 if (!rootIndex || rootIndex < 1 || rootIndex > roots.length) { return null; }
359 var element = roots[rootIndex - 1];
360 var path = [String(rootIndex)];
361 for (var i = 1; i < segments.length; i += 1) {
362 var index = Number(segments[i]);
363 if (!index || index < 1) { return null; }
364 var children = safe(function () { return element.uiElements(); }, []);
365 if (!children || index > children.length) { return null; }
366 element = children[index - 1];
367 path.push(String(index));
368 }
369 return { element: element, node: nodeFrom(element, path) };
370 }
371
372 var payload = {};
373 if (argv.length > 0 && argv[0]) { payload = JSON.parse(argv[0]); }
374 var selector = payload.selector || {};
375 var roleFilter = selector.role ? String(selector.role).toLowerCase() : null;
376 var titleFilter = selector.title_contains ? String(selector.title_contains).toLowerCase() : null;
377 var nth = selector.nth === null || selector.nth === undefined ? null : Number(selector.nth);
378 var text = payload.text === null || payload.text === undefined ? "" : String(payload.text);
379 var allowKeyboardFallback = !!payload.allow_keyboard_fallback;
380 var paste = !!payload.paste;
381 var clearFirst = !!payload.clear_first;
382 var submit = !!payload.submit;
383
384 if (text.length === 0) { throw new Error("text cannot be empty"); }
385
386 var systemEvents = Application("System Events");
387 var currentApp = Application.currentApplication();
388 currentApp.includeStandardAdditions = true;
389
390 var process = resolveProcess(systemEvents, payload.target || {});
391 if (!process) { throw new Error("unable to resolve target app process for ax.type"); }
392
393 var roots = safe(function () { return process.windows(); }, []);
394 if (!roots || roots.length === 0) { roots = safe(function () { return process.uiElements(); }, []); }
395
396 var matches = [];
397 if (selector.node_id) {
398 var byId = resolveByNodeId(roots, selector.node_id);
399 if (byId !== null) { matches = [byId]; }
400 } else {
401 function walk(element, path) {
402 var node = nodeFrom(element, path);
403 var roleMatch = roleFilter === null || node.role.toLowerCase() === roleFilter;
404 var titleLower = node.title.toLowerCase();
405 var identifierLower = node.identifier.toLowerCase();
406 var titleMatch = titleFilter === null || titleLower.indexOf(titleFilter) !== -1 || identifierLower.indexOf(titleFilter) !== -1;
407 if (roleMatch && titleMatch) { matches.push({ element: element, node: node }); }
408 var children = safe(function () { return element.uiElements(); }, []);
409 for (var i = 0; i < children.length; i += 1) {
410 walk(children[i], path.concat([String(i + 1)]));
411 }
412 }
413
414 for (var rootIdx = 0; rootIdx < roots.length; rootIdx += 1) {
415 walk(roots[rootIdx], [String(rootIdx + 1)]);
416 }
417 }
418
419 if (matches.length === 0) { throw new Error("selector returned zero AX matches"); }
420 var matchedCount = matches.length;
421
422 var selected = null;
423 if (selector.node_id) {
424 selected = matches[0];
425 } else if (nth !== null) {
426 if (nth < 1 || nth > matches.length) {
427 throw new Error("selector nth is out of range");
428 }
429 selected = matches[nth - 1];
430 } else {
431 if (matches.length !== 1) {
432 throw new Error("selector is ambiguous; add --nth or narrow role/title filters");
433 }
434 selected = matches[0];
435 }
436
437 var appliedVia = "ax-set-value";
438 var usedKeyboardFallback = false;
439
440 try {
441 safe(function () { setAttr(selected.element, "AXFocused", true); return true; }, false);
442 if (clearFirst) {
443 safe(function () { setAttr(selected.element, "AXValue", ""); return true; }, false);
444 }
445 if (paste) {
446 currentApp.setTheClipboardTo(text);
447 systemEvents.keystroke("v", { using: ["command down"] });
448 appliedVia = "ax-paste";
449 } else {
450 setAttr(selected.element, "AXValue", text);
451 appliedVia = "ax-set-value";
452 }
453 } catch (err) {
454 if (!allowKeyboardFallback) { throw err; }
455 usedKeyboardFallback = true;
456 if (paste) {
457 currentApp.setTheClipboardTo(text);
458 systemEvents.keystroke("v", { using: ["command down"] });
459 appliedVia = "keyboard-paste-fallback";
460 } else {
461 systemEvents.keystroke(text);
462 appliedVia = "keyboard-keystroke-fallback";
463 }
464 }
465
466 if (submit) {
467 systemEvents.keyCode(36);
468 }
469
470 return JSON.stringify({
471 node_id: selected.node.node_id,
472 matched_count: matchedCount,
473 applied_via: appliedVia,
474 text_length: text.length,
475 submitted: submit,
476 used_keyboard_fallback: usedKeyboardFallback
477 });
478}"#;
479
480const AX_LIST_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_LIST_JSON";
481const AX_CLICK_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_CLICK_JSON";
482const AX_TYPE_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_TYPE_JSON";
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub enum ActivationTarget {
486 App(String),
487 BundleId(String),
488}
489
490pub fn activate(
491 runner: &dyn ProcessRunner,
492 target: &ActivationTarget,
493 timeout_ms: u64,
494) -> Result<(), CliError> {
495 let script = match target {
496 ActivationTarget::App(app) => {
497 format!(
498 r#"tell application "{}" to activate"#,
499 escape_applescript(app)
500 )
501 }
502 ActivationTarget::BundleId(bundle_id) => {
503 format!(
504 r#"tell application id "{}" to activate"#,
505 escape_applescript(bundle_id)
506 )
507 }
508 };
509
510 run_osascript(runner, "window.activate", script, timeout_ms).map(|_| ())
511}
512
513pub fn reopen(
514 runner: &dyn ProcessRunner,
515 target: &ActivationTarget,
516 timeout_ms: u64,
517) -> Result<(), CliError> {
518 let script = match target {
519 ActivationTarget::App(app) => {
520 let escaped = escape_applescript(app);
521 format!(
522 r#"try
523 tell application "{escaped}" to quit
524on error
525end try
526delay 0.25
527tell application "{escaped}" to activate"#
528 )
529 }
530 ActivationTarget::BundleId(bundle_id) => {
531 let escaped = escape_applescript(bundle_id);
532 format!(
533 r#"try
534 tell application id "{escaped}" to quit
535on error
536end try
537delay 0.25
538tell application id "{escaped}" to activate"#
539 )
540 }
541 };
542 run_osascript(runner, "window.activate.reopen", script, timeout_ms).map(|_| ())
543}
544
545pub fn type_text(
546 runner: &dyn ProcessRunner,
547 text: &str,
548 delay_ms: Option<u64>,
549 enter: bool,
550 timeout_ms: u64,
551) -> Result<(), CliError> {
552 let escaped = escape_applescript(text);
553 let mut lines = vec![
554 "tell application \"System Events\"".to_string(),
555 format!(" keystroke \"{escaped}\""),
556 ];
557
558 if let Some(delay) = delay_ms {
559 lines.push(format!(" delay {}", (delay as f64) / 1000.0));
560 }
561 if enter {
562 lines.push(" key code 36".to_string());
563 }
564
565 lines.push("end tell".to_string());
566 run_osascript(runner, "input.type", lines.join("\n"), timeout_ms).map(|_| ())
567}
568
569pub fn send_hotkey(
570 runner: &dyn ProcessRunner,
571 mods: &[Modifier],
572 key: &str,
573 timeout_ms: u64,
574) -> Result<(), CliError> {
575 if key.trim().is_empty() {
576 return Err(CliError::usage("--key cannot be empty"));
577 }
578
579 let modifiers = if mods.is_empty() {
580 String::new()
581 } else {
582 let joined = mods
583 .iter()
584 .map(|modifier| modifier.applescript_token())
585 .collect::<Vec<_>>()
586 .join(", ");
587 format!(" using {{{joined}}}")
588 };
589
590 let script = format!(
591 "tell application \"System Events\"\n keystroke \"{}\"{}\nend tell",
592 escape_applescript(key),
593 modifiers
594 );
595
596 run_osascript(runner, "input.hotkey", script, timeout_ms).map(|_| ())
597}
598
599pub fn frontmost_app_name(runner: &dyn ProcessRunner, timeout_ms: u64) -> Result<String, CliError> {
600 run_osascript(
601 runner,
602 "wait.app-active",
603 FRONTMOST_APP_SCRIPT.to_string(),
604 timeout_ms,
605 )
606 .map(|out| out.trim().to_string())
607}
608
609pub fn frontmost_bundle_id(
610 runner: &dyn ProcessRunner,
611 timeout_ms: u64,
612) -> Result<String, CliError> {
613 run_osascript(
614 runner,
615 "wait.app-active",
616 FRONTMOST_BUNDLE_ID_SCRIPT.to_string(),
617 timeout_ms,
618 )
619 .map(|out| out.trim().to_string())
620}
621
622pub fn ax_list(
623 runner: &dyn ProcessRunner,
624 request: &AxListRequest,
625 timeout_ms: u64,
626) -> Result<AxListResult, CliError> {
627 run_jxa_json(
628 runner,
629 "ax.list",
630 request,
631 AX_LIST_JXA_SCRIPT,
632 timeout_ms.max(1),
633 )
634}
635
636pub fn ax_click(
637 runner: &dyn ProcessRunner,
638 request: &AxClickRequest,
639 timeout_ms: u64,
640) -> Result<AxClickResult, CliError> {
641 if selector_is_empty(&request.selector) {
642 return Err(
643 CliError::ax_contract_failure("ax.click", "selector is empty")
644 .with_hint("Provide --node-id or selector filters (--role/--title-contains)."),
645 );
646 }
647
648 run_jxa_json(
649 runner,
650 "ax.click",
651 request,
652 AX_CLICK_JXA_SCRIPT,
653 timeout_ms.max(1),
654 )
655}
656
657pub fn ax_type(
658 runner: &dyn ProcessRunner,
659 request: &AxTypeRequest,
660 timeout_ms: u64,
661) -> Result<AxTypeResult, CliError> {
662 if request.text.trim().is_empty() {
663 return Err(CliError::usage("--text cannot be empty").with_operation("ax.type"));
664 }
665 if selector_is_empty(&request.selector) {
666 return Err(
667 CliError::ax_contract_failure("ax.type", "selector is empty")
668 .with_hint("Provide --node-id or selector filters (--role/--title-contains)."),
669 );
670 }
671
672 run_jxa_json(
673 runner,
674 "ax.type",
675 request,
676 AX_TYPE_JXA_SCRIPT,
677 timeout_ms.max(1),
678 )
679}
680
681pub fn parse_modifiers(raw: &str) -> Result<Vec<Modifier>, CliError> {
682 let mut mods = Vec::new();
683 for token in raw.split(',') {
684 let token = token.trim();
685 if token.is_empty() {
686 continue;
687 }
688 let modifier = Modifier::parse(token).ok_or_else(|| {
689 CliError::usage(format!(
690 "invalid modifier `{token}`; expected cmd,ctrl,alt,shift,fn"
691 ))
692 })?;
693 if !mods.contains(&modifier) {
694 mods.push(modifier);
695 }
696 }
697
698 if mods.is_empty() {
699 return Err(CliError::usage(
700 "--mods cannot be empty; expected cmd,ctrl,alt,shift,fn",
701 ));
702 }
703
704 Ok(mods)
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub enum Modifier {
709 Cmd,
710 Ctrl,
711 Alt,
712 Shift,
713 Fn,
714}
715
716impl Modifier {
717 fn parse(token: &str) -> Option<Self> {
718 match token.to_ascii_lowercase().as_str() {
719 "cmd" | "command" => Some(Self::Cmd),
720 "ctrl" | "control" => Some(Self::Ctrl),
721 "alt" | "option" => Some(Self::Alt),
722 "shift" => Some(Self::Shift),
723 "fn" | "function" => Some(Self::Fn),
724 _ => None,
725 }
726 }
727
728 pub fn canonical(self) -> &'static str {
729 match self {
730 Self::Cmd => "cmd",
731 Self::Ctrl => "ctrl",
732 Self::Alt => "alt",
733 Self::Shift => "shift",
734 Self::Fn => "fn",
735 }
736 }
737
738 fn applescript_token(self) -> &'static str {
739 match self {
740 Self::Cmd => "command down",
741 Self::Ctrl => "control down",
742 Self::Alt => "option down",
743 Self::Shift => "shift down",
744 Self::Fn => "fn down",
745 }
746 }
747}
748
749fn run_osascript(
750 runner: &dyn ProcessRunner,
751 operation: &str,
752 script: String,
753 timeout_ms: u64,
754) -> Result<String, CliError> {
755 let request = ProcessRequest::new(
756 "osascript",
757 vec!["-e".to_string(), script],
758 timeout_ms.max(1),
759 );
760 runner
761 .run(&request)
762 .map(|output| output.stdout)
763 .map_err(|failure| map_failure(operation, failure))
764}
765
766fn run_jxa_json<Request, Response>(
767 runner: &dyn ProcessRunner,
768 operation: &'static str,
769 payload: &Request,
770 script: &'static str,
771 timeout_ms: u64,
772) -> Result<Response, CliError>
773where
774 Request: Serialize,
775 Response: DeserializeOwned,
776{
777 if let Some(override_json) = test_mode_override_json(operation) {
778 return parse_jxa_output(operation, &override_json);
779 }
780
781 let payload_json = serde_json::to_string(payload)
782 .map_err(|err| CliError::ax_payload_encode(operation, err.to_string()))?;
783 let request = ProcessRequest::new(
784 "osascript",
785 vec![
786 "-l".to_string(),
787 "JavaScript".to_string(),
788 "-e".to_string(),
789 script.to_string(),
790 payload_json,
791 ],
792 timeout_ms.max(1),
793 );
794 let stdout = runner
795 .run(&request)
796 .map(|output| output.stdout)
797 .map_err(|failure| map_ax_failure(operation, failure))?;
798
799 if stdout.trim().is_empty()
800 && test_mode::enabled()
801 && let Some(fallback_json) = test_mode_default_json(operation)
802 {
803 return parse_jxa_output(operation, fallback_json);
804 }
805
806 parse_jxa_output(operation, &stdout)
807}
808
809fn parse_jxa_output<Response>(operation: &str, raw: &str) -> Result<Response, CliError>
810where
811 Response: DeserializeOwned,
812{
813 let trimmed = raw.trim();
814 if trimmed.is_empty() {
815 return Err(ax_parse_error(operation, "stdout was empty"));
816 }
817
818 let value: Value = serde_json::from_str(trimmed).map_err(|err| {
819 ax_parse_error(
820 operation,
821 format!("{err}; stdout preview=`{}`", output_preview(trimmed, 120)),
822 )
823 })?;
824
825 validate_jxa_contract(operation, &value)?;
826
827 serde_json::from_value(value).map_err(|err| {
828 ax_parse_error(
829 operation,
830 format!("{err}; stdout preview=`{}`", output_preview(trimmed, 120)),
831 )
832 })
833}
834
835fn map_ax_failure(operation: &str, failure: ProcessFailure) -> CliError {
836 map_failure(operation, failure)
837 .with_hint("Run `macos-agent preflight --include-probes --strict` before AX operations.")
838 .with_hint(
839 "If this persists, rerun with --trace and inspect osascript stderr/stdout artifacts.",
840 )
841}
842
843fn selector_is_empty(selector: &AxSelector) -> bool {
844 selector.node_id.is_none() && selector.role.is_none() && selector.title_contains.is_none()
845}
846
847fn ax_parse_error(operation: &str, detail: impl Into<String>) -> CliError {
848 let mut err = CliError::ax_parse_failure(operation, detail);
849 if let Some(hint) = ax_contract_hint(operation) {
850 err = err.with_hint(hint);
851 }
852 err
853}
854
855fn ax_contract_error(operation: &str, detail: impl Into<String>) -> CliError {
856 let mut err = CliError::runtime(format!(
857 "{operation} failed: AX backend contract violation ({})",
858 detail.into().trim()
859 ))
860 .with_operation(operation)
861 .with_hint("Run `macos-agent preflight --include-probes --strict` to verify Accessibility/Automation access.")
862 .with_hint("Use --trace to capture raw backend output for diagnosis.");
863 if let Some(hint) = ax_contract_hint(operation) {
864 err = err.with_hint(hint);
865 }
866 err
867}
868
869fn ax_contract_hint(operation: &str) -> Option<&'static str> {
870 match operation {
871 "ax.list" => {
872 Some("Expected object contract: { nodes: [...], warnings: [...] } (warnings optional).")
873 }
874 "ax.click" => Some(
875 "Expected object contract: { matched_count, action, node_id?, used_coordinate_fallback?, fallback_x?, fallback_y? }.",
876 ),
877 "ax.type" => Some(
878 "Expected object contract: { matched_count, applied_via, text_length, node_id?, submitted?, used_keyboard_fallback? }.",
879 ),
880 _ => None,
881 }
882}
883
884fn validate_jxa_contract(operation: &str, value: &Value) -> Result<(), CliError> {
885 match operation {
886 "ax.list" => validate_ax_list_contract(operation, value),
887 "ax.click" => validate_ax_click_contract(operation, value),
888 "ax.type" => validate_ax_type_contract(operation, value),
889 _ => Ok(()),
890 }
891}
892
893fn expect_object<'a>(
894 operation: &str,
895 value: &'a Value,
896) -> Result<&'a Map<String, Value>, CliError> {
897 value
898 .as_object()
899 .ok_or_else(|| ax_contract_error(operation, "top-level payload must be a JSON object"))
900}
901
902fn json_type_name(value: &Value) -> &'static str {
903 match value {
904 Value::Null => "null",
905 Value::Bool(_) => "boolean",
906 Value::Number(_) => "number",
907 Value::String(_) => "string",
908 Value::Array(_) => "array",
909 Value::Object(_) => "object",
910 }
911}
912
913fn ensure_required_array<'a>(
914 operation: &str,
915 object: &'a Map<String, Value>,
916 field: &str,
917) -> Result<&'a Vec<Value>, CliError> {
918 let value = object
919 .get(field)
920 .ok_or_else(|| ax_contract_error(operation, format!("missing required `{field}` array")))?;
921 value.as_array().ok_or_else(|| {
922 ax_contract_error(
923 operation,
924 format!(
925 "`{field}` must be an array (received {})",
926 json_type_name(value)
927 ),
928 )
929 })
930}
931
932fn ensure_optional_array<'a>(
933 operation: &str,
934 object: &'a Map<String, Value>,
935 field: &str,
936) -> Result<Option<&'a Vec<Value>>, CliError> {
937 match object.get(field) {
938 None => Ok(None),
939 Some(value) => value.as_array().map(Some).ok_or_else(|| {
940 ax_contract_error(
941 operation,
942 format!(
943 "`{field}` must be an array when present (received {})",
944 json_type_name(value)
945 ),
946 )
947 }),
948 }
949}
950
951fn ensure_required_u64(
952 operation: &str,
953 object: &Map<String, Value>,
954 field: &str,
955) -> Result<u64, CliError> {
956 let value = object.get(field).ok_or_else(|| {
957 ax_contract_error(operation, format!("missing required `{field}` number"))
958 })?;
959 value.as_u64().ok_or_else(|| {
960 ax_contract_error(
961 operation,
962 format!(
963 "`{field}` must be a non-negative integer (received {})",
964 json_type_name(value)
965 ),
966 )
967 })
968}
969
970fn ensure_required_non_empty_string(
971 operation: &str,
972 object: &Map<String, Value>,
973 field: &str,
974) -> Result<(), CliError> {
975 let value = object.get(field).ok_or_else(|| {
976 ax_contract_error(operation, format!("missing required `{field}` string"))
977 })?;
978 let text = value.as_str().ok_or_else(|| {
979 ax_contract_error(
980 operation,
981 format!(
982 "`{field}` must be a string (received {})",
983 json_type_name(value)
984 ),
985 )
986 })?;
987 if text.trim().is_empty() {
988 return Err(ax_contract_error(
989 operation,
990 format!("`{field}` must be a non-empty string"),
991 ));
992 }
993 Ok(())
994}
995
996fn ensure_optional_bool(
997 operation: &str,
998 object: &Map<String, Value>,
999 field: &str,
1000) -> Result<(), CliError> {
1001 if let Some(value) = object.get(field)
1002 && !value.is_boolean()
1003 {
1004 return Err(ax_contract_error(
1005 operation,
1006 format!(
1007 "`{field}` must be a boolean when present (received {})",
1008 json_type_name(value)
1009 ),
1010 ));
1011 }
1012 Ok(())
1013}
1014
1015fn ensure_optional_string_or_null(
1016 operation: &str,
1017 object: &Map<String, Value>,
1018 field: &str,
1019) -> Result<(), CliError> {
1020 if let Some(value) = object.get(field)
1021 && !value.is_null()
1022 && !value.is_string()
1023 {
1024 return Err(ax_contract_error(
1025 operation,
1026 format!(
1027 "`{field}` must be a string or null when present (received {})",
1028 json_type_name(value)
1029 ),
1030 ));
1031 }
1032 Ok(())
1033}
1034
1035fn ensure_optional_i64(
1036 operation: &str,
1037 object: &Map<String, Value>,
1038 field: &str,
1039) -> Result<Option<i64>, CliError> {
1040 match object.get(field) {
1041 None => Ok(None),
1042 Some(value) => value.as_i64().map(Some).ok_or_else(|| {
1043 ax_contract_error(
1044 operation,
1045 format!(
1046 "`{field}` must be an integer when present (received {})",
1047 json_type_name(value)
1048 ),
1049 )
1050 }),
1051 }
1052}
1053
1054fn validate_ax_list_contract(operation: &str, value: &Value) -> Result<(), CliError> {
1055 let object = expect_object(operation, value)?;
1056 let nodes = ensure_required_array(operation, object, "nodes")?;
1057 for (index, node) in nodes.iter().enumerate() {
1058 if !node.is_object() {
1059 return Err(ax_contract_error(
1060 operation,
1061 format!(
1062 "`nodes[{index}]` must be an object (received {})",
1063 json_type_name(node)
1064 ),
1065 ));
1066 }
1067 }
1068
1069 if let Some(warnings) = ensure_optional_array(operation, object, "warnings")? {
1070 for (index, warning) in warnings.iter().enumerate() {
1071 if !warning.is_string() {
1072 return Err(ax_contract_error(
1073 operation,
1074 format!(
1075 "`warnings[{index}]` must be a string (received {})",
1076 json_type_name(warning)
1077 ),
1078 ));
1079 }
1080 }
1081 }
1082 Ok(())
1083}
1084
1085fn validate_ax_click_contract(operation: &str, value: &Value) -> Result<(), CliError> {
1086 let object = expect_object(operation, value)?;
1087 ensure_required_u64(operation, object, "matched_count")?;
1088 ensure_required_non_empty_string(operation, object, "action")?;
1089 ensure_optional_bool(operation, object, "used_coordinate_fallback")?;
1090 ensure_optional_string_or_null(operation, object, "node_id")?;
1091 let fallback_x = ensure_optional_i64(operation, object, "fallback_x")?;
1092 let fallback_y = ensure_optional_i64(operation, object, "fallback_y")?;
1093
1094 let used_coordinate_fallback = object
1095 .get("used_coordinate_fallback")
1096 .and_then(Value::as_bool)
1097 .unwrap_or(false);
1098
1099 if fallback_x.is_some() ^ fallback_y.is_some() {
1100 return Err(ax_contract_error(
1101 operation,
1102 "`fallback_x` and `fallback_y` must either both be present or both be omitted",
1103 ));
1104 }
1105
1106 if used_coordinate_fallback && (fallback_x.is_none() || fallback_y.is_none()) {
1107 return Err(ax_contract_error(
1108 operation,
1109 "`fallback_x` and `fallback_y` are required when `used_coordinate_fallback` is true",
1110 ));
1111 }
1112
1113 Ok(())
1114}
1115
1116fn validate_ax_type_contract(operation: &str, value: &Value) -> Result<(), CliError> {
1117 let object = expect_object(operation, value)?;
1118 ensure_required_u64(operation, object, "matched_count")?;
1119 ensure_required_u64(operation, object, "text_length")?;
1120 ensure_required_non_empty_string(operation, object, "applied_via")?;
1121 ensure_optional_bool(operation, object, "submitted")?;
1122 ensure_optional_bool(operation, object, "used_keyboard_fallback")?;
1123 ensure_optional_string_or_null(operation, object, "node_id")?;
1124 Ok(())
1125}
1126
1127fn test_mode_override_json(operation: &str) -> Option<String> {
1128 if !test_mode::enabled() {
1129 return None;
1130 }
1131
1132 let env_name = match operation {
1133 "ax.list" => AX_LIST_TEST_MODE_ENV,
1134 "ax.click" => AX_CLICK_TEST_MODE_ENV,
1135 "ax.type" => AX_TYPE_TEST_MODE_ENV,
1136 _ => return None,
1137 };
1138
1139 std::env::var(env_name).ok().and_then(|raw| {
1140 let trimmed = raw.trim();
1141 if trimmed.is_empty() {
1142 None
1143 } else {
1144 Some(trimmed.to_string())
1145 }
1146 })
1147}
1148
1149fn test_mode_default_json(operation: &str) -> Option<&'static str> {
1150 match operation {
1151 "ax.list" => Some(r#"{"nodes":[],"warnings":[]}"#),
1152 "ax.click" => Some(
1153 r#"{"node_id":"test-node","matched_count":1,"action":"ax-press","used_coordinate_fallback":false}"#,
1154 ),
1155 "ax.type" => Some(
1156 r#"{"node_id":"test-node","matched_count":1,"applied_via":"ax-set-value","text_length":0,"submitted":false,"used_keyboard_fallback":false}"#,
1157 ),
1158 _ => None,
1159 }
1160}
1161
1162fn output_preview(raw: &str, max_chars: usize) -> String {
1163 let mut preview = raw.chars().take(max_chars).collect::<String>();
1164 if raw.chars().count() > max_chars {
1165 preview.push_str("...");
1166 }
1167 preview
1168}
1169
1170fn escape_applescript(raw: &str) -> String {
1171 raw.replace('\\', "\\\\").replace('"', "\\\"")
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176 use nils_test_support::{EnvGuard, GlobalStateLock};
1177 use pretty_assertions::assert_eq;
1178
1179 use crate::backend::process::{ProcessFailure, ProcessOutput, ProcessRequest, ProcessRunner};
1180 use crate::model::{AxClickRequest, AxListRequest, AxSelector, AxTypeRequest};
1181
1182 use super::{
1183 AX_CLICK_JXA_SCRIPT, AX_TYPE_JXA_SCRIPT, Modifier, ax_click, ax_list, ax_type,
1184 escape_applescript, parse_modifiers,
1185 };
1186
1187 struct FixedOutputRunner {
1188 stdout: String,
1189 }
1190
1191 impl FixedOutputRunner {
1192 fn new(stdout: impl Into<String>) -> Self {
1193 Self {
1194 stdout: stdout.into(),
1195 }
1196 }
1197 }
1198
1199 impl ProcessRunner for FixedOutputRunner {
1200 fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessFailure> {
1201 Ok(ProcessOutput {
1202 stdout: self.stdout.clone(),
1203 stderr: String::new(),
1204 })
1205 }
1206 }
1207
1208 struct PanicRunner;
1209
1210 impl ProcessRunner for PanicRunner {
1211 fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessFailure> {
1212 panic!("runner should not be invoked");
1213 }
1214 }
1215
1216 #[test]
1217 fn escapes_applescript_string_literals() {
1218 assert_eq!(escape_applescript("a\\\"b"), "a\\\\\\\"b".to_string());
1219 }
1220
1221 #[test]
1222 fn parses_modifiers_deduped_and_canonicalized() {
1223 let mods = parse_modifiers("cmd,shift,command").expect("modifiers should parse");
1224 assert_eq!(mods, vec![Modifier::Cmd, Modifier::Shift]);
1225 let canonical = mods
1226 .iter()
1227 .map(|m| m.canonical().to_string())
1228 .collect::<Vec<_>>();
1229 assert_eq!(canonical, vec!["cmd".to_string(), "shift".to_string()]);
1230 }
1231
1232 #[test]
1233 fn rejects_unknown_modifier() {
1234 let err = parse_modifiers("cmd,nope").expect_err("unknown modifiers should fail");
1235 assert_eq!(err.exit_code(), 2);
1236 assert!(err.to_string().contains("invalid modifier"));
1237 }
1238
1239 #[test]
1240 fn ax_list_uses_test_mode_default_when_stdout_is_empty() {
1241 let lock = GlobalStateLock::new();
1242 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
1243 let _override = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_AX_LIST_JSON");
1244 let runner = FixedOutputRunner::new("");
1245
1246 let result =
1247 ax_list(&runner, &AxListRequest::default(), 250).expect("ax list should parse");
1248 assert!(result.nodes.is_empty());
1249 }
1250
1251 #[test]
1252 fn ax_click_parse_failure_includes_operation_context() {
1253 let lock = GlobalStateLock::new();
1254 let _mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
1255 let _override = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_AX_CLICK_JSON");
1256 let runner = FixedOutputRunner::new("not-json");
1257 let request = AxClickRequest {
1258 selector: AxSelector {
1259 node_id: Some("node-1".to_string()),
1260 ..AxSelector::default()
1261 },
1262 ..AxClickRequest::default()
1263 };
1264
1265 let err = ax_click(&runner, &request, 250).expect_err("invalid json should fail");
1266 assert_eq!(err.operation(), Some("ax.click"));
1267 assert!(err.message().contains("invalid AX backend JSON response"));
1268 assert!(
1269 err.hints()
1270 .iter()
1271 .any(|hint| hint.contains("preflight") || hint.contains("--trace"))
1272 );
1273 }
1274
1275 #[test]
1276 fn ax_click_script_declares_node_id_helper_once() {
1277 assert_eq!(
1278 AX_CLICK_JXA_SCRIPT
1279 .matches("function resolveByNodeId")
1280 .count(),
1281 1
1282 );
1283 }
1284
1285 #[test]
1286 fn ax_type_script_declares_node_id_helper_once() {
1287 assert_eq!(
1288 AX_TYPE_JXA_SCRIPT
1289 .matches("function resolveByNodeId")
1290 .count(),
1291 1
1292 );
1293 }
1294
1295 #[test]
1296 fn ax_list_contract_failure_reports_missing_nodes_array() {
1297 let lock = GlobalStateLock::new();
1298 let _mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
1299 let _override = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_AX_LIST_JSON");
1300 let runner = FixedOutputRunner::new(r#"{"warnings":[]}"#);
1301
1302 let err = ax_list(&runner, &AxListRequest::default(), 250)
1303 .expect_err("missing nodes contract field should fail");
1304 assert_eq!(err.operation(), Some("ax.list"));
1305 assert!(err.message().contains("AX backend contract violation"));
1306 assert!(err.message().contains("nodes"));
1307 assert!(err.hints().iter().any(|hint| hint.contains("nodes")));
1308 }
1309
1310 #[test]
1311 fn ax_click_contract_failure_requires_fallback_coordinate_pair() {
1312 let lock = GlobalStateLock::new();
1313 let _mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
1314 let _override = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_AX_CLICK_JSON");
1315 let runner = FixedOutputRunner::new(
1316 r#"{"node_id":"1.1","matched_count":1,"action":"ax-press-fallback","used_coordinate_fallback":true,"fallback_x":42}"#,
1317 );
1318 let request = AxClickRequest {
1319 selector: AxSelector {
1320 node_id: Some("1.1".to_string()),
1321 ..AxSelector::default()
1322 },
1323 ..AxClickRequest::default()
1324 };
1325
1326 let err = ax_click(&runner, &request, 250)
1327 .expect_err("fallback coordinates without pair should fail");
1328 assert_eq!(err.operation(), Some("ax.click"));
1329 assert!(err.message().contains("AX backend contract violation"));
1330 assert!(err.message().contains("fallback_x"));
1331 assert!(err.message().contains("fallback_y"));
1332 }
1333
1334 #[test]
1335 fn ax_type_contract_failure_requires_text_length() {
1336 let lock = GlobalStateLock::new();
1337 let _mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
1338 let _override = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_AX_TYPE_JSON");
1339 let runner = FixedOutputRunner::new(
1340 r#"{"node_id":"1.2","matched_count":1,"applied_via":"ax-set-value"}"#,
1341 );
1342 let request = AxTypeRequest {
1343 selector: AxSelector {
1344 node_id: Some("1.2".to_string()),
1345 ..AxSelector::default()
1346 },
1347 text: "hello".to_string(),
1348 ..AxTypeRequest::default()
1349 };
1350
1351 let err = ax_type(&runner, &request, 250).expect_err("missing text_length should fail");
1352 assert_eq!(err.operation(), Some("ax.type"));
1353 assert!(err.message().contains("AX backend contract violation"));
1354 assert!(err.message().contains("text_length"));
1355 }
1356
1357 #[test]
1358 fn ax_type_uses_test_mode_override_without_invoking_runner() {
1359 let lock = GlobalStateLock::new();
1360 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
1361 let _override = EnvGuard::set(
1362 &lock,
1363 "AGENTS_MACOS_AGENT_AX_TYPE_JSON",
1364 r#"{"node_id":"node-9","matched_count":1,"applied_via":"ax-set-value","text_length":5,"submitted":true,"used_keyboard_fallback":false}"#,
1365 );
1366 let request = AxTypeRequest {
1367 selector: AxSelector {
1368 node_id: Some("node-1".to_string()),
1369 ..AxSelector::default()
1370 },
1371 text: "hello".to_string(),
1372 ..AxTypeRequest::default()
1373 };
1374
1375 let result = ax_type(&PanicRunner, &request, 250).expect("override json should parse");
1376 assert_eq!(result.node_id, Some("node-9".to_string()));
1377 assert_eq!(result.text_length, 5);
1378 assert!(result.submitted);
1379 }
1380}