@forsion/tangu-computer-use 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/LICENSE +26 -0
  3. package/LICENSE.upstream +21 -0
  4. package/README.md +169 -0
  5. package/UPSTREAM.md +277 -0
  6. package/check.mjs +369 -0
  7. package/icon.png +0 -0
  8. package/install.sh +112 -0
  9. package/main.js +541 -0
  10. package/manifest.json +47 -0
  11. package/native/linux/bridge-rs/Cargo.lock +1204 -0
  12. package/native/linux/bridge-rs/Cargo.toml +18 -0
  13. package/native/linux/bridge-rs/src/atspi.rs +640 -0
  14. package/native/linux/bridge-rs/src/error.rs +65 -0
  15. package/native/linux/bridge-rs/src/lib.rs +11 -0
  16. package/native/linux/bridge-rs/src/main.rs +1074 -0
  17. package/native/linux/bridge-rs/src/protocol.rs +49 -0
  18. package/native/linux/bridge-rs/src/state.rs +293 -0
  19. package/native/linux/bridge-rs/src/wayland.rs +89 -0
  20. package/native/linux/bridge-rs/src/x11.rs +909 -0
  21. package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
  22. package/native/macos/agent_cursor.swift +184 -0
  23. package/native/macos/agent_cursor_motion.swift +252 -0
  24. package/native/macos/agent_cursor_tests.swift +128 -0
  25. package/native/macos/agent_highlight.swift +229 -0
  26. package/native/macos/agent_highlight_tests.swift +99 -0
  27. package/native/macos/bridge.swift +3852 -0
  28. package/native/macos/foreground_activity.swift +57 -0
  29. package/native/macos/foreground_activity_tests.swift +31 -0
  30. package/native/macos/live_stream.swift +289 -0
  31. package/native/windows/bridge-rs/Cargo.lock +396 -0
  32. package/native/windows/bridge-rs/Cargo.toml +30 -0
  33. package/native/windows/bridge-rs/src/capture.rs +518 -0
  34. package/native/windows/bridge-rs/src/error.rs +81 -0
  35. package/native/windows/bridge-rs/src/input.rs +501 -0
  36. package/native/windows/bridge-rs/src/lib.rs +16 -0
  37. package/native/windows/bridge-rs/src/main.rs +1683 -0
  38. package/native/windows/bridge-rs/src/protocol.rs +53 -0
  39. package/native/windows/bridge-rs/src/refs.rs +237 -0
  40. package/native/windows/bridge-rs/src/state.rs +55 -0
  41. package/native/windows/bridge-rs/src/uia.rs +1252 -0
  42. package/native/windows/bridge-rs/src/window.rs +718 -0
  43. package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
  44. package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
  45. package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
  46. package/package.json +74 -0
  47. package/prebuilt/linux/arm64/linux-bridge +0 -0
  48. package/prebuilt/linux/x64/linux-bridge +0 -0
  49. package/prebuilt/macos/arm64/bridge +0 -0
  50. package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
  51. package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
  52. package/prebuilt/macos/x64/bridge +0 -0
  53. package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
  54. package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
  55. package/prebuilt/windows/windows-bridge.exe +0 -0
  56. package/scripts/blind-click.check.mjs +128 -0
  57. package/scripts/build-native.mjs +302 -0
  58. package/scripts/build.mjs +84 -0
  59. package/scripts/calc-fixture.mjs +78 -0
  60. package/scripts/cli-exit.check.mjs +49 -0
  61. package/scripts/helper-path.check.mjs +82 -0
  62. package/scripts/helper-refresh.check.mjs +62 -0
  63. package/scripts/helper-signal.check.mjs +59 -0
  64. package/scripts/highlight.check.mjs +32 -0
  65. package/scripts/keychain-free-install.check.mjs +140 -0
  66. package/scripts/live-view.check.mjs +124 -0
  67. package/scripts/macos-bundle.d.mts +1 -0
  68. package/scripts/macos-bundle.mjs +119 -0
  69. package/scripts/make-signing-cert.sh +57 -0
  70. package/scripts/mini-foreground.check.mjs +11 -0
  71. package/scripts/no-foreground.check.mjs +81 -0
  72. package/scripts/overlay-visible.check.mjs +221 -0
  73. package/scripts/package-macos-app.mjs +39 -0
  74. package/scripts/permissions.check.mjs +199 -0
  75. package/scripts/platform-contract.check.mjs +30 -0
  76. package/scripts/setup-helper.mjs +154 -0
  77. package/scripts/tangu-computer-use.entitlements +10 -0
  78. package/scripts/verify-macos-bundles.mjs +25 -0
  79. package/scripts/verify-package.mjs +74 -0
  80. package/skills/computer-use/SKILL.md +110 -0
  81. package/src/foregroundNote.ts +43 -0
  82. package/src/helperState.ts +31 -0
  83. package/src/index.ts +63 -0
  84. package/src/onboarding.ts +198 -0
  85. package/src/pi-compat.ts +49 -0
  86. package/src/settings.ts +11 -0
  87. package/src/setup.ts +97 -0
  88. package/src/tools.ts +289 -0
  89. package/src/vendor/actions.ts +130 -0
  90. package/src/vendor/bridge.ts +2405 -0
  91. package/src/vendor/cdp.ts +658 -0
  92. package/src/vendor/config.ts +113 -0
  93. package/src/vendor/contract.ts +104 -0
  94. package/src/vendor/note.ts +195 -0
  95. package/src/vendor/outline.ts +651 -0
  96. package/src/vendor/output.ts +134 -0
  97. package/src/vendor/permissions.ts +111 -0
  98. package/src/vendor/platform/architecture.ts +23 -0
  99. package/src/vendor/platform/coerce.ts +16 -0
  100. package/src/vendor/platform/index.ts +59 -0
  101. package/src/vendor/platform/linux/backend.ts +186 -0
  102. package/src/vendor/platform/linux/helper.ts +238 -0
  103. package/src/vendor/platform/macos/backend.ts +131 -0
  104. package/src/vendor/platform/macos/browser.ts +110 -0
  105. package/src/vendor/platform/macos/helper-path.d.mts +9 -0
  106. package/src/vendor/platform/macos/helper-path.mjs +34 -0
  107. package/src/vendor/platform/macos/helper.ts +291 -0
  108. package/src/vendor/platform/macos/permissions.ts +146 -0
  109. package/src/vendor/platform/types.ts +222 -0
  110. package/src/vendor/platform/windows/backend.ts +142 -0
  111. package/src/vendor/platform/windows/helper.ts +140 -0
  112. package/src/vendor/root-selection.ts +25 -0
  113. package/src/vendor/runtime.ts +129 -0
  114. package/src/vendor/state.ts +146 -0
  115. package/src/vendor/view.ts +147 -0
  116. package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
  117. package/tangu-plugins/computer-use/dist/index.js +5672 -0
  118. package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
  119. package/tsconfig.json +22 -0
  120. package/types/tangu-agent.d.ts +142 -0
@@ -0,0 +1,1252 @@
1
+ //! Read-only UIA (UI Automation) element extraction for Windows.
2
+ //!
3
+ //! On Windows, uses the [`windows`] crate to walk the UIA accessibility tree
4
+ //! of a given top-level window and extract semantic elements with their
5
+ //! properties. On non-Windows platforms all entry points are stubbed out
6
+ //! and return empty results.
7
+ //!
8
+ //! UIA element extraction for model-visible outlines and element metadata.
9
+
10
+ use serde_json::Value;
11
+
12
+ use crate::refs::RefStore;
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Public interface
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /// Extract UIA accessible elements from the window identified by `hwnd`.
19
+ ///
20
+ /// Returns a `Vec` of JSON objects, each with shape:
21
+ /// ```json
22
+ /// {
23
+ /// "ref": "@e1",
24
+ /// "role": "edit",
25
+ /// "label": "Address bar",
26
+ /// "automationId": "1001",
27
+ /// "className": "Edit",
28
+ /// "bounds": { "x": 0, "y": 0, "width": 100, "height": 20 },
29
+ /// "capabilities": { "isEnabled": true, "isOffscreen": false }
30
+ /// }
31
+ /// ```
32
+ ///
33
+ /// On non-Windows this always returns an empty `Vec`.
34
+ pub fn extract_elements(store: &mut RefStore, hwnd: isize) -> Vec<Value> {
35
+ #[cfg(not(windows))]
36
+ {
37
+ let _ = store;
38
+ let _ = hwnd;
39
+ Vec::new()
40
+ }
41
+
42
+ #[cfg(windows)]
43
+ {
44
+ match uia_extract(store, hwnd) {
45
+ Ok(v) => v,
46
+ Err(e) => {
47
+ eprintln!("[uia] WARN extraction skipped: {e}");
48
+ Vec::new()
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /// Extract the subtree rooted at a previously observed element.
55
+ pub fn extract_elements_from(
56
+ store: &mut RefStore,
57
+ hwnd: isize,
58
+ runtime_id: &[i32],
59
+ automation_id: &str,
60
+ ) -> Result<Vec<Value>, String> {
61
+ #[cfg(not(windows))]
62
+ {
63
+ let _ = (store, hwnd, runtime_id, automation_id);
64
+ Ok(Vec::new())
65
+ }
66
+ #[cfg(windows)]
67
+ {
68
+ native::uia_extract_from(store, hwnd, runtime_id, automation_id)
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // UIA control type → semantic role mapping
74
+ //
75
+ // These constants and the mapping function are always compiled because
76
+ // they are exercised by cross-platform unit tests, but on non-Windows the
77
+ // compiler flags them as dead code since they are only called from the
78
+ // `#[cfg(windows)] native` module. We suppress the lint for that case.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ #[cfg_attr(not(windows), allow(dead_code))]
82
+ const UIA_WINDOW_CONTROL: u32 = 50032;
83
+ #[cfg_attr(not(windows), allow(dead_code))]
84
+ const UIA_PANE_CONTROL: u32 = 50033;
85
+ #[cfg_attr(not(windows), allow(dead_code))]
86
+ const UIA_DOCUMENT_CONTROL: u32 = 50030;
87
+ #[cfg_attr(not(windows), allow(dead_code))]
88
+ const UIA_EDIT_CONTROL: u32 = 50004;
89
+ #[cfg_attr(not(windows), allow(dead_code))]
90
+ const UIA_BUTTON_CONTROL: u32 = 50000;
91
+ #[cfg_attr(not(windows), allow(dead_code))]
92
+ const UIA_CHECKBOX_CONTROL: u32 = 50002;
93
+ #[cfg_attr(not(windows), allow(dead_code))]
94
+ const UIA_RADIOBUTTON_CONTROL: u32 = 50007;
95
+ #[cfg_attr(not(windows), allow(dead_code))]
96
+ const UIA_COMBOBOX_CONTROL: u32 = 50003;
97
+ #[cfg_attr(not(windows), allow(dead_code))]
98
+ const UIA_LIST_CONTROL: u32 = 50008;
99
+ #[cfg_attr(not(windows), allow(dead_code))]
100
+ const UIA_LISTITEM_CONTROL: u32 = 50009;
101
+ #[cfg_attr(not(windows), allow(dead_code))]
102
+ const UIA_TREE_CONTROL: u32 = 50020;
103
+ #[cfg_attr(not(windows), allow(dead_code))]
104
+ const UIA_TREEITEM_CONTROL: u32 = 50021;
105
+ #[cfg_attr(not(windows), allow(dead_code))]
106
+ const UIA_MENUITEM_CONTROL: u32 = 50010;
107
+ #[cfg_attr(not(windows), allow(dead_code))]
108
+ const UIA_TEXT_CONTROL: u32 = 50019;
109
+ #[cfg_attr(not(windows), allow(dead_code))]
110
+ const UIA_HYPERLINK_CONTROL: u32 = 50005;
111
+ #[cfg_attr(not(windows), allow(dead_code))]
112
+ const UIA_TAB_CONTROL: u32 = 50018;
113
+ #[cfg_attr(not(windows), allow(dead_code))]
114
+ const UIA_TABITEM_CONTROL: u32 = 50022;
115
+ #[cfg_attr(not(windows), allow(dead_code))]
116
+ const UIA_HEADER_CONTROL: u32 = 50034;
117
+ #[cfg_attr(not(windows), allow(dead_code))]
118
+ const UIA_HEADERITEM_CONTROL: u32 = 50035;
119
+ #[cfg_attr(not(windows), allow(dead_code))]
120
+ const UIA_TABLE_CONTROL: u32 = 50036;
121
+ #[cfg_attr(not(windows), allow(dead_code))]
122
+ const UIA_IMAGE_CONTROL: u32 = 50031;
123
+ #[cfg_attr(not(windows), allow(dead_code))]
124
+ const UIA_SLIDER_CONTROL: u32 = 50013;
125
+ #[cfg_attr(not(windows), allow(dead_code))]
126
+ const UIA_PROGRESSBAR_CONTROL: u32 = 50006;
127
+ #[cfg_attr(not(windows), allow(dead_code))]
128
+ const UIA_TOOLBAR_CONTROL: u32 = 50016;
129
+ #[cfg_attr(not(windows), allow(dead_code))]
130
+ const UIA_STATUSBAR_CONTROL: u32 = 50014;
131
+ #[cfg_attr(not(windows), allow(dead_code))]
132
+ const UIA_TOOLTIP_CONTROL: u32 = 50015;
133
+ #[cfg_attr(not(windows), allow(dead_code))]
134
+ const UIA_SCROLLBAR_CONTROL: u32 = 50011;
135
+ #[cfg_attr(not(windows), allow(dead_code))]
136
+ const UIA_GROUP_CONTROL: u32 = 50026;
137
+ #[cfg_attr(not(windows), allow(dead_code))]
138
+ const UIA_SEPARATOR_CONTROL: u32 = 50039;
139
+
140
+ /// Map a UIA control type ID to a semantic role string.
141
+ ///
142
+ /// Returns `"unknown"` for unrecognised control type IDs.
143
+ #[cfg_attr(not(windows), allow(dead_code))]
144
+ fn control_type_to_role(ctrl_type: u32) -> &'static str {
145
+ match ctrl_type {
146
+ UIA_WINDOW_CONTROL => "window",
147
+ UIA_PANE_CONTROL => "pane",
148
+ UIA_DOCUMENT_CONTROL => "document",
149
+ UIA_EDIT_CONTROL => "edit",
150
+ UIA_BUTTON_CONTROL => "button",
151
+ UIA_CHECKBOX_CONTROL => "checkbox",
152
+ UIA_RADIOBUTTON_CONTROL => "radio",
153
+ UIA_COMBOBOX_CONTROL => "comboBox",
154
+ UIA_LIST_CONTROL => "list",
155
+ UIA_LISTITEM_CONTROL => "listItem",
156
+ UIA_TREE_CONTROL => "tree",
157
+ UIA_TREEITEM_CONTROL => "treeItem",
158
+ UIA_MENUITEM_CONTROL => "menuItem",
159
+ UIA_TEXT_CONTROL => "text",
160
+ UIA_HYPERLINK_CONTROL => "link",
161
+ UIA_TAB_CONTROL => "tab",
162
+ UIA_TABITEM_CONTROL => "tabItem",
163
+ UIA_HEADER_CONTROL => "header",
164
+ UIA_HEADERITEM_CONTROL => "headerItem",
165
+ UIA_TABLE_CONTROL => "table",
166
+ UIA_IMAGE_CONTROL => "image",
167
+ UIA_SLIDER_CONTROL => "slider",
168
+ UIA_PROGRESSBAR_CONTROL => "progressBar",
169
+ UIA_TOOLBAR_CONTROL => "toolBar",
170
+ UIA_STATUSBAR_CONTROL => "statusBar",
171
+ UIA_TOOLTIP_CONTROL => "toolTip",
172
+ UIA_SCROLLBAR_CONTROL => "scrollBar",
173
+ UIA_GROUP_CONTROL => "group",
174
+ UIA_SEPARATOR_CONTROL => "separator",
175
+ _ => "unknown",
176
+ }
177
+ }
178
+
179
+ #[derive(Clone, Debug, Default)]
180
+ pub struct ElementAnnotationSignals {
181
+ pub invoke: bool,
182
+ pub toggle: bool,
183
+ pub selection_item: bool,
184
+ pub expand_collapse: bool,
185
+ pub legacy_default_action: bool,
186
+ pub value: bool,
187
+ pub text: bool,
188
+ pub value_read_only: Option<bool>,
189
+ }
190
+
191
+ pub fn annotation_can_press(signals: &ElementAnnotationSignals) -> bool {
192
+ signals.invoke
193
+ || signals.toggle
194
+ || signals.selection_item
195
+ || signals.expand_collapse
196
+ || signals.legacy_default_action
197
+ }
198
+
199
+ pub fn annotation_can_set_text(signals: &ElementAnnotationSignals) -> bool {
200
+ signals.value && !signals.value_read_only.unwrap_or(false) || signals.text
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Windows implementation (windows crate)
205
+ // ---------------------------------------------------------------------------
206
+
207
+ #[cfg(windows)]
208
+ mod native {
209
+ use serde_json::{json, Value};
210
+ use std::collections::HashSet;
211
+
212
+ use super::{
213
+ annotation_can_press, annotation_can_set_text, control_type_to_role,
214
+ ElementAnnotationSignals,
215
+ };
216
+ use crate::refs::{NativeHandle, RefStore};
217
+
218
+ use windows::core::{BSTR, VARIANT};
219
+ use windows::Win32::Foundation::*;
220
+ use windows::Win32::System::Com::*;
221
+ use windows::Win32::System::Ole::{
222
+ SafeArrayGetElement, SafeArrayGetLBound, SafeArrayGetUBound,
223
+ };
224
+ use windows::Win32::UI::Accessibility::*;
225
+
226
+ const MAX_ELEMENTS: usize = 200;
227
+ const MAX_TRUNCATION_SCAN: usize = 1_000;
228
+
229
+ /// Entry point called from the public stub on cfg(windows).
230
+ pub fn uia_extract(store: &mut RefStore, hwnd: isize) -> Result<Vec<Value>, String> {
231
+ let _com = ComGuard::new()?;
232
+
233
+ let uia: IUIAutomation = unsafe {
234
+ CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER)
235
+ .map_err(|e| format!("CoCreateInstance IUIAutomation: {e}"))?
236
+ };
237
+
238
+ let root = unsafe {
239
+ uia.ElementFromHandle(HWND(hwnd as *mut _))
240
+ .map_err(|e| format!("ElementFromHandle: {e}"))?
241
+ };
242
+
243
+ extract_from_root(store, &uia, &root)
244
+ }
245
+
246
+ pub fn uia_extract_from(
247
+ store: &mut RefStore,
248
+ hwnd: isize,
249
+ runtime_id_target: &[i32],
250
+ automation_id: &str,
251
+ ) -> Result<Vec<Value>, String> {
252
+ let (_com, uia, root) = resolve(hwnd, runtime_id_target, automation_id)?;
253
+ extract_from_root(store, &uia, &root)
254
+ }
255
+
256
+ fn extract_from_root(
257
+ store: &mut RefStore,
258
+ uia: &IUIAutomation,
259
+ root: &IUIAutomationElement,
260
+ ) -> Result<Vec<Value>, String> {
261
+ let condition = unsafe {
262
+ uia.CreateTrueCondition()
263
+ .map_err(|e| format!("CreateTrueCondition: {e}"))?
264
+ };
265
+
266
+ let found = unsafe {
267
+ root.FindAll(TreeScope_Subtree, &condition)
268
+ .map_err(|e| format!("FindAll: {e}"))?
269
+ };
270
+
271
+ let count = unsafe {
272
+ found
273
+ .Length()
274
+ .map_err(|e| format!("ElementArray.Length: {e}"))?
275
+ } as usize;
276
+
277
+ let limit = count.min(MAX_ELEMENTS);
278
+ let mut elements = Vec::with_capacity(limit);
279
+ let walker = unsafe {
280
+ uia.ControlViewWalker()
281
+ .map_err(|e| format!("ControlViewWalker: {e}"))?
282
+ };
283
+
284
+ for i in 0..limit {
285
+ let element = unsafe {
286
+ found
287
+ .GetElement(i as _)
288
+ .map_err(|e| format!("GetElement({i}): {e}"))?
289
+ };
290
+ let parent_runtime_id = unsafe { walker.GetParentElement(&element).ok() }
291
+ .and_then(|parent| runtime_id(&parent))
292
+ .unwrap_or_default();
293
+ if let Some(json_val) = element_to_json(store, &element, parent_runtime_id) {
294
+ elements.push(json_val);
295
+ }
296
+ }
297
+
298
+ if count > limit {
299
+ let retained = elements
300
+ .iter()
301
+ .filter_map(|element| element.get("runtimeId").and_then(Value::as_array))
302
+ .map(|runtime_id| {
303
+ runtime_id
304
+ .iter()
305
+ .filter_map(Value::as_i64)
306
+ .map(|value| value.to_string())
307
+ .collect::<Vec<_>>()
308
+ .join(".")
309
+ })
310
+ .collect::<HashSet<_>>();
311
+ let mut truncated = HashSet::new();
312
+ for i in limit..count.min(limit + MAX_TRUNCATION_SCAN) {
313
+ let mut candidate = unsafe { found.GetElement(i as _).ok() };
314
+ for _ in 0..64 {
315
+ let Some(element) = candidate else { break };
316
+ let Some(parent) = (unsafe { walker.GetParentElement(&element).ok() }) else {
317
+ break;
318
+ };
319
+ let key = runtime_id(&parent)
320
+ .unwrap_or_default()
321
+ .iter()
322
+ .map(i32::to_string)
323
+ .collect::<Vec<_>>()
324
+ .join(".");
325
+ if retained.contains(&key) {
326
+ truncated.insert(key);
327
+ break;
328
+ }
329
+ candidate = Some(parent);
330
+ }
331
+ }
332
+ // If the omitted tail is larger than the bounded ancestry scan,
333
+ // mark the retained extraction root as an honest coarse boundary.
334
+ if count > limit + MAX_TRUNCATION_SCAN {
335
+ let root_key = runtime_id(root)
336
+ .unwrap_or_default()
337
+ .iter()
338
+ .map(i32::to_string)
339
+ .collect::<Vec<_>>()
340
+ .join(".");
341
+ truncated.insert(root_key);
342
+ }
343
+ for element in &mut elements {
344
+ let key = element
345
+ .get("runtimeId")
346
+ .and_then(Value::as_array)
347
+ .map(|runtime_id| {
348
+ runtime_id
349
+ .iter()
350
+ .filter_map(Value::as_i64)
351
+ .map(|value| value.to_string())
352
+ .collect::<Vec<_>>()
353
+ .join(".")
354
+ })
355
+ .unwrap_or_default();
356
+ if truncated.contains(&key) {
357
+ element["truncated"] = json!(true);
358
+ }
359
+ }
360
+ }
361
+
362
+ Ok(elements)
363
+ }
364
+
365
+ fn runtime_id(element: &IUIAutomationElement) -> Option<Vec<i32>> {
366
+ let array = unsafe { element.GetRuntimeId().ok()? };
367
+ if array.is_null() {
368
+ return None;
369
+ }
370
+ let lower = unsafe { SafeArrayGetLBound(array, 1).ok()? };
371
+ let upper = unsafe { SafeArrayGetUBound(array, 1).ok()? };
372
+ let mut values = Vec::with_capacity((upper - lower + 1).max(0) as usize);
373
+ for index in lower..=upper {
374
+ let mut value = 0i32;
375
+ unsafe {
376
+ SafeArrayGetElement(
377
+ array,
378
+ &index,
379
+ &mut value as *mut i32 as *mut core::ffi::c_void,
380
+ )
381
+ .ok()?;
382
+ }
383
+ values.push(value);
384
+ }
385
+ Some(values)
386
+ }
387
+
388
+ /// Convert a single UIA element to its JSON representation.
389
+ ///
390
+ /// Returns `None` for elements that are offscreen, zero-sized, or
391
+ /// otherwise uninteresting.
392
+ fn element_to_json(
393
+ store: &mut RefStore,
394
+ element: &IUIAutomationElement,
395
+ parent_runtime_id: Vec<i32>,
396
+ ) -> Option<Value> {
397
+ let ctrl_type = unsafe { element.CurrentControlType().ok()? };
398
+ let role = control_type_to_role(ctrl_type.0 as u32);
399
+
400
+ let name = unsafe { element.CurrentName().unwrap_or_default().to_string() };
401
+ let automation_id = unsafe {
402
+ element
403
+ .CurrentAutomationId()
404
+ .unwrap_or_default()
405
+ .to_string()
406
+ };
407
+ let runtime_id = runtime_id(element).unwrap_or_default();
408
+ let class_name = unsafe { element.CurrentClassName().unwrap_or_default().to_string() };
409
+
410
+ // Bounding rectangle.
411
+ let rect = unsafe { element.CurrentBoundingRectangle().ok()? };
412
+
413
+ // Skip invisible / offscreen elements.
414
+ let w = rect.right - rect.left;
415
+ let h = rect.bottom - rect.top;
416
+ if w <= 0 || h <= 0 {
417
+ return None;
418
+ }
419
+
420
+ let is_offscreen = unsafe {
421
+ element
422
+ .CurrentIsOffscreen()
423
+ .map(|value| value.as_bool())
424
+ .unwrap_or(true)
425
+ };
426
+ if is_offscreen {
427
+ return None;
428
+ }
429
+
430
+ // Capabilities.
431
+ let is_enabled = unsafe {
432
+ element
433
+ .CurrentIsEnabled()
434
+ .map(|value| value.as_bool())
435
+ .unwrap_or(true)
436
+ };
437
+ let is_keyboard_focusable = unsafe {
438
+ element
439
+ .CurrentIsKeyboardFocusable()
440
+ .map(|value| value.as_bool())
441
+ .unwrap_or(false)
442
+ };
443
+ let is_password = unsafe {
444
+ element
445
+ .CurrentIsPassword()
446
+ .map(|value| value.as_bool())
447
+ .unwrap_or(false)
448
+ };
449
+ let value_pattern = unsafe {
450
+ element
451
+ .GetCurrentPatternAs::<IUIAutomationValuePattern>(UIA_ValuePatternId)
452
+ .ok()
453
+ };
454
+ let value = value_pattern
455
+ .as_ref()
456
+ .and_then(|pattern| unsafe { pattern.CurrentValue().ok() })
457
+ .map(|value| value.to_string())
458
+ .unwrap_or_default();
459
+ let value_read_only = value_pattern
460
+ .as_ref()
461
+ .and_then(|pattern| unsafe { pattern.CurrentIsReadOnly().ok() })
462
+ .map(|read_only| read_only.as_bool());
463
+ let signals = ElementAnnotationSignals {
464
+ invoke: pattern_available(element, UIA_IsInvokePatternAvailablePropertyId),
465
+ toggle: pattern_available(element, UIA_IsTogglePatternAvailablePropertyId),
466
+ selection_item: pattern_available(
467
+ element,
468
+ UIA_IsSelectionItemPatternAvailablePropertyId,
469
+ ),
470
+ expand_collapse: pattern_available(
471
+ element,
472
+ UIA_IsExpandCollapsePatternAvailablePropertyId,
473
+ ),
474
+ legacy_default_action: legacy_default_action_available(element),
475
+ value: pattern_available(element, UIA_IsValuePatternAvailablePropertyId),
476
+ text: pattern_available(element, UIA_IsTextPatternAvailablePropertyId),
477
+ value_read_only,
478
+ };
479
+ let can_press = annotation_can_press(&signals);
480
+ let can_set_value = annotation_can_set_text(&signals);
481
+ let can_scroll = pattern_available(element, UIA_IsScrollPatternAvailablePropertyId);
482
+
483
+ let eref = store.insert_element(NativeHandle::new(0));
484
+
485
+ Some(json!({
486
+ "ref": eref.to_string(),
487
+ "role": role,
488
+ "label": name,
489
+ "automationId": automation_id,
490
+ "runtimeId": runtime_id,
491
+ "parentRuntimeId": parent_runtime_id,
492
+ "className": class_name,
493
+ "value": value,
494
+ "isPassword": is_password,
495
+ "bounds": {
496
+ "x": rect.left,
497
+ "y": rect.top,
498
+ "width": w,
499
+ "height": h,
500
+ },
501
+ "capabilities": {
502
+ "isEnabled": is_enabled,
503
+ "isOffscreen": is_offscreen,
504
+ "isKeyboardFocusable": is_keyboard_focusable,
505
+ "canInvoke": signals.invoke,
506
+ "canPress": can_press,
507
+ "canSetValue": can_set_value,
508
+ "canScroll": can_scroll,
509
+ },
510
+ }))
511
+ }
512
+
513
+ pub fn read_live_text(
514
+ hwnd: isize,
515
+ runtime_id_target: &[i32],
516
+ automation_id: &str,
517
+ ) -> Result<String, String> {
518
+ let (_com, _uia, element) = resolve(hwnd, runtime_id_target, automation_id)?;
519
+ Ok(read_text_from_element(&element))
520
+ }
521
+
522
+ pub fn live_elements(hwnd: isize) -> Result<Vec<Value>, String> {
523
+ let mut store = RefStore::new();
524
+ uia_extract(&mut store, hwnd)
525
+ }
526
+
527
+ pub struct ElementSnapshot {
528
+ pub rect: (f64, f64, f64, f64),
529
+ pub runtime_id: Vec<i32>,
530
+ }
531
+
532
+ pub enum PressResult {
533
+ Invoked,
534
+ Toggled(bool),
535
+ Selected(bool),
536
+ Expanded,
537
+ LegacyDefaultAction,
538
+ NoPattern,
539
+ }
540
+
541
+ pub enum SetTextResult {
542
+ Set { value: String },
543
+ NoPattern,
544
+ }
545
+
546
+ pub enum ScrollResult {
547
+ Scrolled,
548
+ NoPattern,
549
+ }
550
+
551
+ pub fn snapshot(
552
+ hwnd: isize,
553
+ runtime_id_target: &[i32],
554
+ automation_id: &str,
555
+ ) -> Result<ElementSnapshot, String> {
556
+ let (_com, _uia, element) = resolve(hwnd, runtime_id_target, automation_id)?;
557
+ let rect = unsafe {
558
+ element
559
+ .CurrentBoundingRectangle()
560
+ .map_err(|e| format!("CurrentBoundingRectangle: {e}"))?
561
+ };
562
+ Ok(ElementSnapshot {
563
+ rect: (
564
+ f64::from(rect.left),
565
+ f64::from(rect.top),
566
+ f64::from(rect.right - rect.left),
567
+ f64::from(rect.bottom - rect.top),
568
+ ),
569
+ runtime_id: runtime_id(&element).unwrap_or_default(),
570
+ })
571
+ }
572
+
573
+ pub fn press(
574
+ hwnd: isize,
575
+ runtime_id_target: &[i32],
576
+ automation_id: &str,
577
+ ) -> Result<PressResult, String> {
578
+ let (_com, _uia, element) = resolve(hwnd, runtime_id_target, automation_id)?;
579
+ if let Ok(pattern) = unsafe {
580
+ element.GetCurrentPatternAs::<IUIAutomationInvokePattern>(UIA_InvokePatternId)
581
+ } {
582
+ unsafe {
583
+ pattern.Invoke().map_err(|e| format!("Invoke: {e}"))?;
584
+ }
585
+ return Ok(PressResult::Invoked);
586
+ }
587
+ if let Ok(pattern) = unsafe {
588
+ element.GetCurrentPatternAs::<IUIAutomationTogglePattern>(UIA_TogglePatternId)
589
+ } {
590
+ unsafe {
591
+ pattern.Toggle().map_err(|e| format!("Toggle: {e}"))?;
592
+ }
593
+ let state = unsafe {
594
+ pattern
595
+ .CurrentToggleState()
596
+ .map(|s| s.0 != 0)
597
+ .unwrap_or(false)
598
+ };
599
+ return Ok(PressResult::Toggled(state));
600
+ }
601
+ if let Ok(pattern) = unsafe {
602
+ element.GetCurrentPatternAs::<IUIAutomationSelectionItemPattern>(
603
+ UIA_SelectionItemPatternId,
604
+ )
605
+ } {
606
+ unsafe {
607
+ pattern.Select().map_err(|e| format!("Select: {e}"))?;
608
+ }
609
+ let selected = unsafe {
610
+ pattern
611
+ .CurrentIsSelected()
612
+ .map(|b| b.as_bool())
613
+ .unwrap_or(false)
614
+ };
615
+ return Ok(PressResult::Selected(selected));
616
+ }
617
+ if let Ok(pattern) = unsafe {
618
+ element.GetCurrentPatternAs::<IUIAutomationExpandCollapsePattern>(
619
+ UIA_ExpandCollapsePatternId,
620
+ )
621
+ } {
622
+ unsafe {
623
+ pattern.Expand().map_err(|e| format!("Expand: {e}"))?;
624
+ }
625
+ return Ok(PressResult::Expanded);
626
+ }
627
+ if let Ok(pattern) = unsafe {
628
+ element.GetCurrentPatternAs::<IUIAutomationLegacyIAccessiblePattern>(
629
+ UIA_LegacyIAccessiblePatternId,
630
+ )
631
+ } {
632
+ let default_action = unsafe {
633
+ pattern
634
+ .CurrentDefaultAction()
635
+ .map(|s| s.to_string())
636
+ .unwrap_or_default()
637
+ };
638
+ if !default_action.trim().is_empty() {
639
+ unsafe {
640
+ pattern
641
+ .DoDefaultAction()
642
+ .map_err(|e| format!("DoDefaultAction: {e}"))?;
643
+ }
644
+ return Ok(PressResult::LegacyDefaultAction);
645
+ }
646
+ }
647
+ Ok(PressResult::NoPattern)
648
+ }
649
+
650
+ fn pattern_available(element: &IUIAutomationElement, property_id: UIA_PROPERTY_ID) -> bool {
651
+ unsafe {
652
+ element
653
+ .GetCurrentPropertyValue(property_id)
654
+ .ok()
655
+ .and_then(|value| bool::try_from(&value).ok())
656
+ .unwrap_or(false)
657
+ }
658
+ }
659
+
660
+ fn legacy_default_action_available(element: &IUIAutomationElement) -> bool {
661
+ if !pattern_available(element, UIA_IsLegacyIAccessiblePatternAvailablePropertyId) {
662
+ return false;
663
+ }
664
+ unsafe {
665
+ element
666
+ .GetCurrentPatternAs::<IUIAutomationLegacyIAccessiblePattern>(
667
+ UIA_LegacyIAccessiblePatternId,
668
+ )
669
+ .ok()
670
+ .and_then(|pattern| pattern.CurrentDefaultAction().ok())
671
+ .map(|action| !action.to_string().trim().is_empty())
672
+ .unwrap_or(false)
673
+ }
674
+ }
675
+
676
+ pub fn set_text(
677
+ hwnd: isize,
678
+ runtime_id_target: &[i32],
679
+ automation_id: &str,
680
+ text: &str,
681
+ ) -> Result<SetTextResult, String> {
682
+ let (_com, _uia, element) = resolve(hwnd, runtime_id_target, automation_id)?;
683
+ if let Ok(pattern) =
684
+ unsafe { element.GetCurrentPatternAs::<IUIAutomationValuePattern>(UIA_ValuePatternId) }
685
+ {
686
+ let value = BSTR::from(text);
687
+ unsafe {
688
+ pattern
689
+ .SetValue(&value)
690
+ .map_err(|e| format!("SetValue: {e}"))?;
691
+ }
692
+ let value = unsafe {
693
+ pattern
694
+ .CurrentValue()
695
+ .map(|s| s.to_string())
696
+ .unwrap_or_default()
697
+ };
698
+ return Ok(SetTextResult::Set { value });
699
+ }
700
+ Ok(SetTextResult::NoPattern)
701
+ }
702
+
703
+ pub fn focus(
704
+ hwnd: isize,
705
+ runtime_id_target: &[i32],
706
+ automation_id: &str,
707
+ ) -> Result<(), String> {
708
+ let (_com, _uia, element) = resolve(hwnd, runtime_id_target, automation_id)?;
709
+ unsafe { element.SetFocus().map_err(|e| format!("SetFocus: {e}")) }
710
+ }
711
+
712
+ pub fn scroll(
713
+ hwnd: isize,
714
+ runtime_id_target: &[i32],
715
+ automation_id: &str,
716
+ x: f64,
717
+ y: f64,
718
+ ) -> Result<ScrollResult, String> {
719
+ let (_com, _uia, element) = resolve(hwnd, runtime_id_target, automation_id)?;
720
+ if let Ok(pattern) = unsafe {
721
+ element.GetCurrentPatternAs::<IUIAutomationScrollPattern>(UIA_ScrollPatternId)
722
+ } {
723
+ let horizontal = if x > 0.0 {
724
+ ScrollAmount_SmallIncrement
725
+ } else if x < 0.0 {
726
+ ScrollAmount_SmallDecrement
727
+ } else {
728
+ ScrollAmount_NoAmount
729
+ };
730
+ let vertical = if y > 0.0 {
731
+ ScrollAmount_SmallIncrement
732
+ } else if y < 0.0 {
733
+ ScrollAmount_SmallDecrement
734
+ } else {
735
+ ScrollAmount_NoAmount
736
+ };
737
+ unsafe {
738
+ pattern
739
+ .Scroll(horizontal, vertical)
740
+ .map_err(|e| format!("Scroll: {e}"))?;
741
+ }
742
+ return Ok(ScrollResult::Scrolled);
743
+ }
744
+ Ok(ScrollResult::NoPattern)
745
+ }
746
+
747
+ pub fn occlusion_ok(
748
+ hwnd: isize,
749
+ runtime_id_target: &[i32],
750
+ automation_id: &str,
751
+ x: f64,
752
+ y: f64,
753
+ ) -> Result<bool, String> {
754
+ let (_com, uia, target) = resolve(hwnd, runtime_id_target, automation_id)?;
755
+ let hit = unsafe {
756
+ uia.ElementFromPoint(POINT {
757
+ x: x.round() as i32,
758
+ y: y.round() as i32,
759
+ })
760
+ .map_err(|e| format!("ElementFromPoint: {e}"))?
761
+ };
762
+ let target_id = runtime_id(&target).unwrap_or_default();
763
+ let hit_id = runtime_id(&hit).unwrap_or_default();
764
+ if !target_id.is_empty() && target_id == hit_id {
765
+ return Ok(true);
766
+ }
767
+ let walker = unsafe {
768
+ uia.ControlViewWalker()
769
+ .map_err(|e| format!("ControlViewWalker: {e}"))?
770
+ };
771
+ Ok(is_ancestor(&walker, &target_id, hit) || is_ancestor(&walker, &hit_id, target))
772
+ }
773
+
774
+ fn is_ancestor(
775
+ walker: &IUIAutomationTreeWalker,
776
+ ancestor_id: &[i32],
777
+ mut element: IUIAutomationElement,
778
+ ) -> bool {
779
+ if ancestor_id.is_empty() {
780
+ return false;
781
+ }
782
+ for _ in 0..64 {
783
+ let Some(parent) = (unsafe { walker.GetParentElement(&element).ok() }) else {
784
+ return false;
785
+ };
786
+ let parent_id = runtime_id(&parent).unwrap_or_default();
787
+ if parent_id == ancestor_id {
788
+ return true;
789
+ }
790
+ element = parent;
791
+ }
792
+ false
793
+ }
794
+
795
+ fn resolve(
796
+ hwnd: isize,
797
+ runtime_id_target: &[i32],
798
+ automation_id: &str,
799
+ ) -> Result<(ComGuard, IUIAutomation, IUIAutomationElement), String> {
800
+ let com = ComGuard::new()?;
801
+ let uia: IUIAutomation = unsafe {
802
+ CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER)
803
+ .map_err(|e| format!("CoCreateInstance IUIAutomation: {e}"))?
804
+ };
805
+ let root = unsafe {
806
+ uia.ElementFromHandle(HWND(hwnd as *mut _))
807
+ .map_err(|e| format!("ElementFromHandle: {e}"))?
808
+ };
809
+ if runtime_id(&root).as_deref() == Some(runtime_id_target) {
810
+ return Ok((com, uia, root));
811
+ }
812
+ // UIA RuntimeId is exposed as a SAFEARRAY and is not reliably accepted by
813
+ // CreatePropertyCondition across providers/windows-rs VARIANT conversion,
814
+ // so use AutomationId as the fast server-side lookup when available and
815
+ // keep RuntimeId as the authoritative equality check/fallback scan.
816
+ if !automation_id.is_empty() {
817
+ let value = VARIANT::from(automation_id);
818
+ if let Ok(condition) =
819
+ unsafe { uia.CreatePropertyCondition(UIA_AutomationIdPropertyId, &value) }
820
+ {
821
+ if let Ok(element) = unsafe { root.FindFirst(TreeScope_Subtree, &condition) } {
822
+ if runtime_id(&element).as_deref() == Some(runtime_id_target)
823
+ || runtime_id_target.is_empty()
824
+ {
825
+ return Ok((com, uia, element));
826
+ }
827
+ let candidate_id = unsafe {
828
+ element
829
+ .CurrentAutomationId()
830
+ .unwrap_or_default()
831
+ .to_string()
832
+ };
833
+ if candidate_id == automation_id && runtime_id_target.is_empty() {
834
+ return Ok((com, uia, element));
835
+ }
836
+ }
837
+ }
838
+ }
839
+
840
+ let condition = unsafe {
841
+ uia.CreateTrueCondition()
842
+ .map_err(|e| format!("CreateTrueCondition: {e}"))?
843
+ };
844
+ let found = unsafe {
845
+ root.FindAll(TreeScope_Subtree, &condition)
846
+ .map_err(|e| format!("FindAll: {e}"))?
847
+ };
848
+ let count = unsafe {
849
+ found
850
+ .Length()
851
+ .map_err(|e| format!("ElementArray.Length: {e}"))?
852
+ };
853
+ let mut automation_fallback = None;
854
+ for i in 0..count {
855
+ let element = unsafe {
856
+ found
857
+ .GetElement(i)
858
+ .map_err(|e| format!("GetElement({i}): {e}"))?
859
+ };
860
+ if runtime_id(&element).as_deref() == Some(runtime_id_target) {
861
+ return Ok((com, uia, element));
862
+ }
863
+ if !automation_id.is_empty() {
864
+ let candidate_id = unsafe {
865
+ element
866
+ .CurrentAutomationId()
867
+ .unwrap_or_default()
868
+ .to_string()
869
+ };
870
+ if candidate_id == automation_id && automation_fallback.is_none() {
871
+ automation_fallback = Some(element);
872
+ }
873
+ }
874
+ }
875
+ if let Some(element) = automation_fallback {
876
+ return Ok((com, uia, element));
877
+ }
878
+ Err("Element reference is stale".to_owned())
879
+ }
880
+
881
+ fn read_text_from_element(element: &IUIAutomationElement) -> String {
882
+ if let Ok(pattern) =
883
+ unsafe { element.GetCurrentPatternAs::<IUIAutomationTextPattern>(UIA_TextPatternId) }
884
+ {
885
+ if let Ok(range) = unsafe { pattern.DocumentRange() } {
886
+ if let Ok(value) = unsafe { range.GetText(-1) } {
887
+ let text = value.to_string();
888
+ if !text.is_empty() {
889
+ return text;
890
+ }
891
+ }
892
+ }
893
+ }
894
+ if let Ok(pattern) =
895
+ unsafe { element.GetCurrentPatternAs::<IUIAutomationValuePattern>(UIA_ValuePatternId) }
896
+ {
897
+ if let Ok(value) = unsafe { pattern.CurrentValue() } {
898
+ let text = value.to_string();
899
+ if !text.is_empty() {
900
+ return text;
901
+ }
902
+ }
903
+ }
904
+ unsafe { element.CurrentName().unwrap_or_default().to_string() }
905
+ }
906
+
907
+ // -----------------------------------------------------------------------
908
+ // COM lifetime guard
909
+ // -----------------------------------------------------------------------
910
+
911
+ /// Calls `CoInitializeEx` on construction and `CoUninitialize` on drop.
912
+ struct ComGuard;
913
+
914
+ impl ComGuard {
915
+ fn new() -> Result<Self, String> {
916
+ // SAFETY: COM must be initialised for the calling thread before
917
+ // any UIA calls. S_OK (0) and S_FALSE (1) are both success
918
+ // indicators; only a negative HRESULT means failure.
919
+ let hr = unsafe { CoInitializeEx(Some(std::ptr::null()), COINIT_APARTMENTTHREADED) };
920
+ if hr.0 < 0 {
921
+ return Err(format!("CoInitializeEx failed: {:#010x}", hr.0));
922
+ }
923
+ Ok(Self)
924
+ }
925
+ }
926
+
927
+ impl Drop for ComGuard {
928
+ fn drop(&mut self) {
929
+ // SAFETY: each successful CoInitializeEx (including S_FALSE) must
930
+ // be balanced with a CoUninitialize.
931
+ unsafe {
932
+ CoUninitialize();
933
+ }
934
+ }
935
+ }
936
+ }
937
+
938
+ #[cfg(windows)]
939
+ pub use native::{
940
+ focus, live_elements, occlusion_ok, press, read_live_text, scroll, set_text, snapshot,
941
+ uia_extract, ElementSnapshot, PressResult, ScrollResult, SetTextResult,
942
+ };
943
+
944
+ #[cfg(not(windows))]
945
+ #[derive(Clone, Debug)]
946
+ pub struct ElementSnapshot {
947
+ pub rect: (f64, f64, f64, f64),
948
+ pub runtime_id: Vec<i32>,
949
+ }
950
+ #[cfg(not(windows))]
951
+ pub enum PressResult {
952
+ Invoked,
953
+ Toggled(bool),
954
+ Selected(bool),
955
+ Expanded,
956
+ LegacyDefaultAction,
957
+ NoPattern,
958
+ }
959
+ #[cfg(not(windows))]
960
+ pub enum SetTextResult {
961
+ Set { value: String },
962
+ NoPattern,
963
+ }
964
+ #[cfg(not(windows))]
965
+ pub enum ScrollResult {
966
+ Scrolled,
967
+ NoPattern,
968
+ }
969
+ #[cfg(not(windows))]
970
+ pub fn read_live_text(
971
+ _hwnd: isize,
972
+ _runtime_id: &[i32],
973
+ _automation_id: &str,
974
+ ) -> Result<String, String> {
975
+ Err("UIA is only supported on Windows".to_owned())
976
+ }
977
+ #[cfg(not(windows))]
978
+ pub fn live_elements(_hwnd: isize) -> Result<Vec<Value>, String> {
979
+ Ok(Vec::new())
980
+ }
981
+ #[cfg(not(windows))]
982
+ pub fn snapshot(
983
+ _hwnd: isize,
984
+ _runtime_id: &[i32],
985
+ _automation_id: &str,
986
+ ) -> Result<ElementSnapshot, String> {
987
+ Err("UIA is only supported on Windows".to_owned())
988
+ }
989
+ #[cfg(not(windows))]
990
+ pub fn press(
991
+ _hwnd: isize,
992
+ _runtime_id: &[i32],
993
+ _automation_id: &str,
994
+ ) -> Result<PressResult, String> {
995
+ Err("UIA is only supported on Windows".to_owned())
996
+ }
997
+ #[cfg(not(windows))]
998
+ pub fn set_text(
999
+ _hwnd: isize,
1000
+ _runtime_id: &[i32],
1001
+ _automation_id: &str,
1002
+ _text: &str,
1003
+ ) -> Result<SetTextResult, String> {
1004
+ Err("UIA is only supported on Windows".to_owned())
1005
+ }
1006
+ #[cfg(not(windows))]
1007
+ pub fn focus(_hwnd: isize, _runtime_id: &[i32], _automation_id: &str) -> Result<(), String> {
1008
+ Err("UIA is only supported on Windows".to_owned())
1009
+ }
1010
+ #[cfg(not(windows))]
1011
+ pub fn scroll(
1012
+ _hwnd: isize,
1013
+ _runtime_id: &[i32],
1014
+ _automation_id: &str,
1015
+ _x: f64,
1016
+ _y: f64,
1017
+ ) -> Result<ScrollResult, String> {
1018
+ Err("UIA is only supported on Windows".to_owned())
1019
+ }
1020
+ #[cfg(not(windows))]
1021
+ pub fn occlusion_ok(
1022
+ _hwnd: isize,
1023
+ _runtime_id: &[i32],
1024
+ _automation_id: &str,
1025
+ _x: f64,
1026
+ _y: f64,
1027
+ ) -> Result<bool, String> {
1028
+ Err("UIA is only supported on Windows".to_owned())
1029
+ }
1030
+
1031
+ // ---------------------------------------------------------------------------
1032
+ // Tests
1033
+ // ---------------------------------------------------------------------------
1034
+
1035
+ #[cfg(test)]
1036
+ mod unit_tests {
1037
+ use super::*;
1038
+ use crate::refs::NativeHandle;
1039
+ use serde_json::json;
1040
+
1041
+ // -- Platform support check (non-Windows) -------------------------------
1042
+
1043
+ #[test]
1044
+ #[cfg(not(windows))]
1045
+ fn test_extract_elements_empty_on_non_windows() {
1046
+ let mut store = RefStore::new();
1047
+ let result = extract_elements(&mut store, 0);
1048
+ assert!(result.is_empty());
1049
+ }
1050
+
1051
+ // -- Role mapping (cross-platform) --------------------------------------
1052
+
1053
+ #[test]
1054
+ fn test_control_type_to_role_edit() {
1055
+ assert_eq!(control_type_to_role(50004), "edit");
1056
+ }
1057
+
1058
+ #[test]
1059
+ fn test_control_type_to_role_button() {
1060
+ assert_eq!(control_type_to_role(50000), "button");
1061
+ }
1062
+
1063
+ #[test]
1064
+ fn test_control_type_to_role_checkbox() {
1065
+ assert_eq!(control_type_to_role(50002), "checkbox");
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn test_control_type_to_role_radio() {
1070
+ assert_eq!(control_type_to_role(50007), "radio");
1071
+ }
1072
+
1073
+ #[test]
1074
+ fn test_control_type_to_role_window() {
1075
+ assert_eq!(control_type_to_role(50032), "window");
1076
+ }
1077
+
1078
+ #[test]
1079
+ fn test_control_type_to_role_pane() {
1080
+ assert_eq!(control_type_to_role(50033), "pane");
1081
+ }
1082
+
1083
+ #[test]
1084
+ fn test_control_type_to_role_menu_item() {
1085
+ assert_eq!(control_type_to_role(50010), "menuItem");
1086
+ }
1087
+
1088
+ #[test]
1089
+ fn test_control_type_to_role_list_item() {
1090
+ assert_eq!(control_type_to_role(50009), "listItem");
1091
+ }
1092
+
1093
+ #[test]
1094
+ fn test_control_type_to_role_document() {
1095
+ assert_eq!(control_type_to_role(50030), "document");
1096
+ }
1097
+
1098
+ #[test]
1099
+ fn test_control_type_to_role_unknown() {
1100
+ assert_eq!(control_type_to_role(99999), "unknown");
1101
+ assert_eq!(control_type_to_role(0), "unknown");
1102
+ }
1103
+
1104
+ // -- Element JSON shape (cross-platform) --------------------------------
1105
+
1106
+ #[test]
1107
+ fn test_element_json_shape() {
1108
+ let mut store = RefStore::new();
1109
+ let eref = store.insert_element(NativeHandle::new(0));
1110
+ let ref_str = eref.to_string();
1111
+
1112
+ let element = json!({
1113
+ "ref": ref_str,
1114
+ "role": "edit",
1115
+ "label": "Test Label",
1116
+ "automationId": "1001",
1117
+ "runtimeId": [42, 7, 9],
1118
+ "className": "Edit",
1119
+ "bounds": {
1120
+ "x": 10,
1121
+ "y": 20,
1122
+ "width": 100,
1123
+ "height": 30,
1124
+ },
1125
+ "capabilities": {
1126
+ "isEnabled": true,
1127
+ "isOffscreen": false,
1128
+ "isKeyboardFocusable": true,
1129
+ },
1130
+ });
1131
+
1132
+ assert_eq!(element["ref"].as_str(), Some("@e1"));
1133
+ assert_eq!(element["role"].as_str(), Some("edit"));
1134
+ assert_eq!(element["label"].as_str(), Some("Test Label"));
1135
+ assert_eq!(element["automationId"].as_str(), Some("1001"));
1136
+ let runtime_id = element["runtimeId"]
1137
+ .as_array()
1138
+ .unwrap()
1139
+ .iter()
1140
+ .map(|value| value.as_i64().unwrap() as i32)
1141
+ .collect::<Vec<_>>();
1142
+ assert_eq!(runtime_id, vec![42, 7, 9]);
1143
+ assert_eq!(element["className"].as_str(), Some("Edit"));
1144
+ assert!(element["bounds"].is_object());
1145
+ assert_eq!(element["bounds"]["x"], 10);
1146
+ assert_eq!(element["bounds"]["y"], 20);
1147
+ assert_eq!(element["bounds"]["width"], 100);
1148
+ assert_eq!(element["bounds"]["height"], 30);
1149
+ assert!(element["capabilities"].is_object());
1150
+ assert_eq!(element["capabilities"]["isEnabled"], true);
1151
+ assert_eq!(element["capabilities"]["isOffscreen"], false);
1152
+ assert_eq!(element["capabilities"]["isKeyboardFocusable"], true);
1153
+ }
1154
+
1155
+ #[test]
1156
+ fn pressability_annotation_matrix_matches_grounding_ladder() {
1157
+ let base = ElementAnnotationSignals::default();
1158
+ assert!(!annotation_can_press(&base));
1159
+
1160
+ let cases = [
1161
+ ElementAnnotationSignals {
1162
+ invoke: true,
1163
+ ..base.clone()
1164
+ },
1165
+ ElementAnnotationSignals {
1166
+ toggle: true,
1167
+ ..base.clone()
1168
+ },
1169
+ ElementAnnotationSignals {
1170
+ selection_item: true,
1171
+ ..base.clone()
1172
+ },
1173
+ ElementAnnotationSignals {
1174
+ expand_collapse: true,
1175
+ ..base.clone()
1176
+ },
1177
+ ElementAnnotationSignals {
1178
+ legacy_default_action: true,
1179
+ ..base.clone()
1180
+ },
1181
+ ];
1182
+ for signals in cases {
1183
+ assert!(annotation_can_press(&signals));
1184
+ }
1185
+ }
1186
+
1187
+ #[test]
1188
+ fn text_editable_annotation_matrix_matches_set_text_grounding() {
1189
+ assert!(!annotation_can_set_text(
1190
+ &ElementAnnotationSignals::default()
1191
+ ));
1192
+ assert!(annotation_can_set_text(&ElementAnnotationSignals {
1193
+ value: true,
1194
+ value_read_only: Some(false),
1195
+ ..Default::default()
1196
+ }));
1197
+ assert!(!annotation_can_set_text(&ElementAnnotationSignals {
1198
+ value: true,
1199
+ value_read_only: Some(true),
1200
+ ..Default::default()
1201
+ }));
1202
+ assert!(annotation_can_set_text(&ElementAnnotationSignals {
1203
+ text: true,
1204
+ ..Default::default()
1205
+ }));
1206
+ }
1207
+
1208
+ #[test]
1209
+ fn test_ax_targets_response_shape() {
1210
+ // Simulate the screenshot response with axTargets.
1211
+ let mut store = RefStore::new();
1212
+ let eref = store.insert_element(NativeHandle::new(0));
1213
+ let ref_str = eref.to_string();
1214
+
1215
+ let response = json!({
1216
+ "target": "@w1",
1217
+ "capture": {
1218
+ "stateId": "s-0",
1219
+ "width": 800,
1220
+ "height": 600,
1221
+ "imageFormat": "png",
1222
+ "imageBase64": "dummy",
1223
+ },
1224
+ "axTargets": [
1225
+ {
1226
+ "ref": ref_str,
1227
+ "role": "edit",
1228
+ "label": "Address bar",
1229
+ "automationId": "1001",
1230
+ "className": "Edit",
1231
+ "bounds": { "x": 0, "y": 0, "width": 800, "height": 30 },
1232
+ "capabilities": {
1233
+ "isEnabled": true,
1234
+ "isOffscreen": false,
1235
+ "isKeyboardFocusable": true,
1236
+ },
1237
+ }
1238
+ ],
1239
+ "warnings": [],
1240
+ });
1241
+
1242
+ assert_eq!(response["target"].as_str(), Some("@w1"));
1243
+ assert!(response["capture"].is_object());
1244
+ assert!(response["warnings"].is_array());
1245
+ assert!(response["axTargets"].is_array());
1246
+
1247
+ let targets = response["axTargets"].as_array().unwrap();
1248
+ assert_eq!(targets.len(), 1);
1249
+ assert_eq!(targets[0]["ref"].as_str(), Some("@e1"));
1250
+ assert_eq!(targets[0]["role"].as_str(), Some("edit"));
1251
+ }
1252
+ }