Skip to main content

macos_agent/backend/
applescript.rs

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}