@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,18 @@
1
+ [package]
2
+ name = "linux-bridge"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "AT-SPI native helper for tangu-computer-use on Linux"
6
+
7
+ [dependencies]
8
+ base64 = "0.22"
9
+ png = "0.17"
10
+ serde = { version = "1", features = ["derive"] }
11
+ serde_json = "1"
12
+ tokio = { version = "1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] }
13
+ x11rb = { version = "0.13", features = ["composite", "xtest"] }
14
+ zbus = { version = "4", default-features = false, features = ["tokio"] }
15
+
16
+ [lib]
17
+ name = "linux_bridge"
18
+ path = "src/lib.rs"
@@ -0,0 +1,640 @@
1
+ //! Minimal, dependency-light AT-SPI2 client built on zbus.
2
+ //!
3
+ //! Linux desktop accessibility is intentionally used in preference to global
4
+ //! pointer/keyboard injection: Action and EditableText calls are delivered to
5
+ //! applications in the background and do not steal focus.
6
+
7
+ use std::collections::{HashMap, HashSet, VecDeque};
8
+
9
+ use serde_json::{json, Value};
10
+ use zbus::zvariant::OwnedObjectPath;
11
+ use zbus::{Connection, Proxy};
12
+
13
+ use crate::{ErrorCode, ProtocolError};
14
+
15
+ const DBUS_DESTINATION: &str = "org.freedesktop.DBus";
16
+ const DBUS_ROOT: &str = "/org/freedesktop/DBus";
17
+ const DBUS_INTERFACE: &str = "org.freedesktop.DBus";
18
+ const REGISTRY_DESTINATION: &str = "org.a11y.atspi.Registry";
19
+ const REGISTRY_ROOT: &str = "/org/a11y/atspi/accessible/root";
20
+ const ACCESSIBLE: &str = "org.a11y.atspi.Accessible";
21
+ const COMPONENT: &str = "org.a11y.atspi.Component";
22
+ const ACTION: &str = "org.a11y.atspi.Action";
23
+ const MAX_ATSPI_ACTIONS: usize = 64;
24
+ const EDITABLE_TEXT: &str = "org.a11y.atspi.EditableText";
25
+ const TEXT: &str = "org.a11y.atspi.Text";
26
+
27
+ #[derive(Debug, Clone, PartialEq, Eq, Hash)]
28
+ pub struct AccessibleRef {
29
+ pub destination: String,
30
+ pub path: String,
31
+ }
32
+
33
+ #[derive(Debug, Clone, PartialEq)]
34
+ pub struct Rect {
35
+ pub x: i32,
36
+ pub y: i32,
37
+ pub width: i32,
38
+ pub height: i32,
39
+ }
40
+
41
+ #[derive(Debug, Clone)]
42
+ pub struct RootSnapshot {
43
+ pub accessible: AccessibleRef,
44
+ pub pid: u64,
45
+ pub name: String,
46
+ pub app_name: String,
47
+ pub role: String,
48
+ pub frame: Option<Rect>,
49
+ pub x11_window: Option<u32>,
50
+ pub is_focused: bool,
51
+ pub is_minimized: bool,
52
+ pub z_order: Option<usize>,
53
+ }
54
+
55
+ #[derive(Debug, Clone)]
56
+ pub struct NodeSnapshot {
57
+ pub reference: String,
58
+ pub accessible: AccessibleRef,
59
+ pub parent: Option<AccessibleRef>,
60
+ pub name: String,
61
+ pub role: String,
62
+ pub description: String,
63
+ pub value: String,
64
+ pub interfaces: HashSet<String>,
65
+ pub bounds: Option<Rect>,
66
+ pub is_secure: bool,
67
+ pub depth: usize,
68
+ }
69
+
70
+ impl NodeSnapshot {
71
+ #[cfg(test)]
72
+ pub(crate) fn minimal(accessible: AccessibleRef) -> Self {
73
+ Self {
74
+ reference: String::new(),
75
+ accessible,
76
+ parent: None,
77
+ name: String::new(),
78
+ role: String::new(),
79
+ description: String::new(),
80
+ value: String::new(),
81
+ interfaces: HashSet::new(),
82
+ bounds: None,
83
+ is_secure: false,
84
+ depth: 0,
85
+ }
86
+ }
87
+
88
+ pub fn searchable_text(&self) -> String {
89
+ format!("{} {} {}", self.name, self.description, self.value).to_lowercase()
90
+ }
91
+ }
92
+
93
+ #[derive(Clone)]
94
+ pub struct AtspiClient {
95
+ connection: Connection,
96
+ }
97
+
98
+ impl AtspiClient {
99
+ pub async fn connect() -> Result<Self, ProtocolError> {
100
+ let session = Connection::session().await.map_err(unavailable)?;
101
+ let bus = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
102
+ .await
103
+ .map_err(unavailable)?;
104
+ let address: String = bus.call("GetAddress", &()).await.map_err(unavailable)?;
105
+ let connection = zbus::connection::Builder::address(address.as_str())
106
+ .map_err(unavailable)?
107
+ .build()
108
+ .await
109
+ .map_err(unavailable)?;
110
+ Ok(Self { connection })
111
+ }
112
+
113
+ pub async fn available() -> bool {
114
+ Self::connect().await.is_ok()
115
+ }
116
+
117
+ async fn proxy<'a>(
118
+ &'a self,
119
+ accessible: &'a AccessibleRef,
120
+ interface: &'a str,
121
+ ) -> Result<Proxy<'a>, ProtocolError> {
122
+ Proxy::new(
123
+ &self.connection,
124
+ accessible.destination.as_str(),
125
+ accessible.path.as_str(),
126
+ interface,
127
+ )
128
+ .await
129
+ .map_err(atspi_error)
130
+ }
131
+
132
+ async fn children(
133
+ &self,
134
+ accessible: &AccessibleRef,
135
+ ) -> Result<Vec<AccessibleRef>, ProtocolError> {
136
+ let proxy = self.proxy(accessible, ACCESSIBLE).await?;
137
+ let children: Vec<(String, OwnedObjectPath)> =
138
+ proxy.call("GetChildren", &()).await.map_err(atspi_error)?;
139
+ Ok(children
140
+ .into_iter()
141
+ .filter(|(destination, path)| !destination.is_empty() && path.as_str() != "/")
142
+ .map(|(destination, path)| AccessibleRef {
143
+ destination,
144
+ path: path.to_string(),
145
+ })
146
+ .collect())
147
+ }
148
+
149
+ async fn string_property(&self, accessible: &AccessibleRef, property: &str) -> String {
150
+ match self.proxy(accessible, ACCESSIBLE).await {
151
+ Ok(proxy) => proxy.get_property(property).await.unwrap_or_default(),
152
+ Err(_) => String::new(),
153
+ }
154
+ }
155
+
156
+ async fn role(&self, accessible: &AccessibleRef) -> String {
157
+ match self.proxy(accessible, ACCESSIBLE).await {
158
+ Ok(proxy) => proxy.call("GetRoleName", &()).await.unwrap_or_default(),
159
+ Err(_) => String::new(),
160
+ }
161
+ }
162
+
163
+ async fn interfaces(&self, accessible: &AccessibleRef) -> HashSet<String> {
164
+ match self.proxy(accessible, ACCESSIBLE).await {
165
+ Ok(proxy) => proxy
166
+ .call::<_, _, Vec<String>>("GetInterfaces", &())
167
+ .await
168
+ .unwrap_or_default()
169
+ .into_iter()
170
+ .collect(),
171
+ Err(_) => HashSet::new(),
172
+ }
173
+ }
174
+
175
+ async fn bounds(&self, accessible: &AccessibleRef) -> Option<Rect> {
176
+ let proxy = self.proxy(accessible, COMPONENT).await.ok()?;
177
+ let (x, y, width, height): (i32, i32, i32, i32) =
178
+ proxy.call("GetExtents", &(0u32)).await.ok()?;
179
+ (width > 0 && height > 0).then_some(Rect {
180
+ x,
181
+ y,
182
+ width,
183
+ height,
184
+ })
185
+ }
186
+
187
+ async fn application_pid(&self, accessible: &AccessibleRef) -> u64 {
188
+ let application_id = match self.proxy(accessible, "org.a11y.atspi.Application").await {
189
+ Ok(proxy) => proxy.get_property::<i32>("Id").await.unwrap_or(0),
190
+ Err(_) => 0,
191
+ };
192
+ if let Some(pid) = positive_application_pid(application_id) {
193
+ return pid;
194
+ }
195
+ let Ok(bus) = Proxy::new(
196
+ &self.connection,
197
+ DBUS_DESTINATION,
198
+ DBUS_ROOT,
199
+ DBUS_INTERFACE,
200
+ )
201
+ .await
202
+ else {
203
+ return 0;
204
+ };
205
+ let connection_pid: Option<u32> = bus
206
+ .call(
207
+ "GetConnectionUnixProcessID",
208
+ &(accessible.destination.as_str()),
209
+ )
210
+ .await
211
+ .ok();
212
+ fallback_application_pid(application_id, connection_pid)
213
+ }
214
+
215
+ pub async fn list_roots(
216
+ &self,
217
+ filter_pid: Option<u64>,
218
+ ) -> Result<Vec<RootSnapshot>, ProtocolError> {
219
+ let registry = AccessibleRef {
220
+ destination: REGISTRY_DESTINATION.to_owned(),
221
+ path: REGISTRY_ROOT.to_owned(),
222
+ };
223
+ let applications = self.children(&registry).await?;
224
+ let mut roots = Vec::new();
225
+ for app in applications {
226
+ let pid = self.application_pid(&app).await;
227
+ if filter_pid.is_some_and(|wanted| wanted != pid) {
228
+ continue;
229
+ }
230
+ let app_name = self.string_property(&app, "Name").await;
231
+ let windows = self.children(&app).await.unwrap_or_default();
232
+ for window in windows {
233
+ let role = self.role(&window).await;
234
+ let name = self.string_property(&window, "Name").await;
235
+ roots.push(RootSnapshot {
236
+ frame: self.bounds(&window).await,
237
+ accessible: window,
238
+ pid,
239
+ name: if name.is_empty() {
240
+ app_name.clone()
241
+ } else {
242
+ name
243
+ },
244
+ app_name: app_name.clone(),
245
+ role,
246
+ x11_window: None,
247
+ is_focused: false,
248
+ is_minimized: false,
249
+ z_order: None,
250
+ });
251
+ }
252
+ }
253
+ Ok(roots)
254
+ }
255
+
256
+ pub async fn snapshot(
257
+ &self,
258
+ root: &RootSnapshot,
259
+ max_nodes: usize,
260
+ max_depth: usize,
261
+ ) -> Result<Vec<NodeSnapshot>, ProtocolError> {
262
+ self.snapshot_from(&root.accessible, max_nodes, max_depth)
263
+ .await
264
+ }
265
+
266
+ pub async fn snapshot_from(
267
+ &self,
268
+ start: &AccessibleRef,
269
+ max_nodes: usize,
270
+ max_depth: usize,
271
+ ) -> Result<Vec<NodeSnapshot>, ProtocolError> {
272
+ let mut result = Vec::new();
273
+ let mut seen = HashSet::new();
274
+ let mut queue = VecDeque::from([(start.clone(), None, 0usize)]);
275
+ while let Some((accessible, parent, depth)) = queue.pop_front() {
276
+ if result.len() >= max_nodes || depth > max_depth || !seen.insert(accessible.clone()) {
277
+ continue;
278
+ }
279
+ let interfaces = self.interfaces(&accessible).await;
280
+ let role = self.role(&accessible).await;
281
+ let name = self.string_property(&accessible, "Name").await;
282
+ let description = self.string_property(&accessible, "Description").await;
283
+ let is_secure =
284
+ role.eq_ignore_ascii_case("password text") || role.eq_ignore_ascii_case("password");
285
+ let value = if !is_secure && interfaces.contains(TEXT) {
286
+ self.read_text(&accessible).await.unwrap_or_default()
287
+ } else {
288
+ String::new()
289
+ };
290
+ let children = self.children(&accessible).await.unwrap_or_default();
291
+ for child in children {
292
+ queue.push_back((child, Some(accessible.clone()), depth + 1));
293
+ }
294
+ result.push(NodeSnapshot {
295
+ reference: String::new(),
296
+ bounds: self.bounds(&accessible).await,
297
+ accessible,
298
+ parent,
299
+ name,
300
+ role,
301
+ description,
302
+ value,
303
+ interfaces,
304
+ is_secure,
305
+ depth,
306
+ });
307
+ }
308
+ Ok(result)
309
+ }
310
+
311
+ pub async fn read_text(&self, accessible: &AccessibleRef) -> Result<String, ProtocolError> {
312
+ let proxy = self.proxy(accessible, TEXT).await?;
313
+ proxy
314
+ .call("GetText", &(0i32, -1i32))
315
+ .await
316
+ .map_err(atspi_error)
317
+ }
318
+
319
+ pub async fn press(&self, node: &NodeSnapshot) -> Result<bool, ProtocolError> {
320
+ if !node.interfaces.contains(ACTION) {
321
+ return Err(ProtocolError::new(
322
+ "Target does not expose the AT-SPI Action interface",
323
+ ErrorCode::CapabilityDeferred,
324
+ ));
325
+ }
326
+ let proxy = self.proxy(&node.accessible, ACTION).await?;
327
+ let action_count = bounded_action_count(proxy.get_property::<i32>("NActions").await.ok())?;
328
+ let mut names = Vec::new();
329
+ if action_count > 1 {
330
+ names.reserve(action_count);
331
+ for index in 0..action_count {
332
+ let name: String = proxy
333
+ .call("GetName", &(index as i32))
334
+ .await
335
+ .unwrap_or_default();
336
+ names.push(name);
337
+ }
338
+ }
339
+ let Some(action_index) = selected_action_index(action_count, &names) else {
340
+ return Ok(false);
341
+ };
342
+ proxy
343
+ .call("DoAction", &(action_index))
344
+ .await
345
+ .map_err(atspi_error)
346
+ }
347
+
348
+ pub async fn set_text(&self, node: &NodeSnapshot, text: &str) -> Result<bool, ProtocolError> {
349
+ if !node.interfaces.contains(EDITABLE_TEXT) {
350
+ return Err(ProtocolError::new(
351
+ "Target does not expose the AT-SPI EditableText interface",
352
+ ErrorCode::CapabilityDeferred,
353
+ ));
354
+ }
355
+ let proxy = self.proxy(&node.accessible, EDITABLE_TEXT).await?;
356
+ proxy
357
+ .call("SetTextContents", &(text))
358
+ .await
359
+ .map_err(atspi_error)
360
+ }
361
+ }
362
+
363
+ pub fn root_json(reference: &str, root: &RootSnapshot, z_order: usize) -> Value {
364
+ let kind = if root.role.to_lowercase().contains("dialog") {
365
+ "dialog"
366
+ } else {
367
+ "window"
368
+ };
369
+ let frame = root
370
+ .frame
371
+ .as_ref()
372
+ .map(|r| json!({"x":r.x,"y":r.y,"width":r.width,"height":r.height}))
373
+ .unwrap_or_else(|| json!({"x":0,"y":0,"width":0,"height":0}));
374
+ json!({
375
+ "kind": kind,
376
+ "rootRef": reference,
377
+ "windowRef": reference,
378
+ "ref": reference,
379
+ "windowId": root.x11_window,
380
+ "title": root.name,
381
+ "pid": root.pid,
382
+ "appName": root.app_name,
383
+ "processName": root.app_name,
384
+ "role": root.role,
385
+ "subrole": "",
386
+ "zOrder": root.z_order.unwrap_or(z_order),
387
+ "framePoints": frame,
388
+ "bounds": frame,
389
+ "scaleFactor": 1.0,
390
+ "isFocused": root.is_focused,
391
+ "isMain": root.is_focused || z_order == 0,
392
+ "isMinimized": root.is_minimized,
393
+ "isOnscreen": true,
394
+ "isModal": kind == "dialog",
395
+ "metadata": {"backend":"at-spi2","busName":root.accessible.destination,"objectPath":root.accessible.path},
396
+ "isBrowser": false,
397
+ "browserFamily": Value::Null
398
+ })
399
+ }
400
+
401
+ pub fn outline_json(root: &RootSnapshot, nodes: &[NodeSnapshot], max_nodes: usize) -> Value {
402
+ let mut descendants: HashMap<AccessibleRef, Vec<Value>> = HashMap::new();
403
+ let mut roots = Vec::new();
404
+ for node in nodes.iter().rev() {
405
+ let rect = node
406
+ .bounds
407
+ .as_ref()
408
+ .map(|r| {
409
+ let origin_x = root.frame.as_ref().map(|f| f.x).unwrap_or(0);
410
+ let origin_y = root.frame.as_ref().map(|f| f.y).unwrap_or(0);
411
+ json!({"x":relative_coordinate(r.x, origin_x),"y":relative_coordinate(r.y, origin_y),"w":r.width,"h":r.height})
412
+ })
413
+ .unwrap_or_else(|| json!({"x":0,"y":0,"w":0,"h":0}));
414
+ let can_press = node.interfaces.contains(ACTION);
415
+ let can_set_value = node.interfaces.contains(EDITABLE_TEXT);
416
+ let mut actions = Vec::new();
417
+ if can_press {
418
+ actions.push("press");
419
+ }
420
+ if can_set_value {
421
+ actions.push("setValue");
422
+ }
423
+ let mut children = descendants.remove(&node.accessible).unwrap_or_default();
424
+ children.reverse();
425
+ let value = json!({
426
+ "ref": node.reference,
427
+ "role": node.role,
428
+ "subrole": "",
429
+ "identifier": node.accessible.path,
430
+ "title": node.name,
431
+ "description": node.description,
432
+ "value": if node.is_secure { "" } else { &node.value },
433
+ "actions": actions,
434
+ "canPress": can_press,
435
+ "canFocus": false,
436
+ "canSetValue": can_set_value,
437
+ "canScroll": false,
438
+ "canIncrement": false,
439
+ "canDecrement": false,
440
+ "isTextInput": can_set_value,
441
+ "rect": rect,
442
+ "focused": false,
443
+ "offscreen": node.bounds.is_none(),
444
+ "pictureOnly": false,
445
+ "truncated": nodes.len() >= max_nodes && node.depth == 0,
446
+ "text": if node.value.is_empty() || node.is_secure { json!([]) } else {
447
+ json!([{"string":node.value,"confidence":1.0,"rect":rect}])
448
+ },
449
+ "children": children
450
+ });
451
+ if let Some(parent) = &node.parent {
452
+ descendants.entry(parent.clone()).or_default().push(value);
453
+ } else {
454
+ roots.push(value);
455
+ }
456
+ }
457
+ roots.reverse();
458
+ if roots.is_empty() {
459
+ json!({"role":"window","title":"","children":[],"truncated":false})
460
+ } else {
461
+ let mut root = roots.remove(0);
462
+ if !roots.is_empty() {
463
+ root["children"]
464
+ .as_array_mut()
465
+ .expect("children array")
466
+ .extend(roots);
467
+ }
468
+ root
469
+ }
470
+ }
471
+
472
+ fn normalized_action_name(name: &str) -> String {
473
+ name.trim()
474
+ .to_ascii_lowercase()
475
+ .replace(['-', '_'], " ")
476
+ .split_whitespace()
477
+ .collect::<Vec<_>>()
478
+ .join(" ")
479
+ }
480
+
481
+ fn activation_action_index(names: &[String]) -> usize {
482
+ const PRIORITY: [&str; 5] = ["activate", "click", "press", "invoke", "open"];
483
+ PRIORITY
484
+ .iter()
485
+ .find_map(|wanted| {
486
+ names
487
+ .iter()
488
+ .position(|name| normalized_action_name(name) == *wanted)
489
+ })
490
+ .unwrap_or(0)
491
+ }
492
+
493
+ fn selected_action_index(action_count: usize, names: &[String]) -> Option<i32> {
494
+ match action_count {
495
+ 0 => None,
496
+ 1 => Some(0),
497
+ _ => Some(activation_action_index(names) as i32),
498
+ }
499
+ }
500
+
501
+ fn bounded_action_count(raw_count: Option<i32>) -> Result<usize, ProtocolError> {
502
+ let count = raw_count.unwrap_or(0);
503
+ if count <= 1 {
504
+ return Ok(count.max(0) as usize);
505
+ }
506
+ let count = usize::try_from(count).map_err(|_| {
507
+ ProtocolError::new(
508
+ "AT-SPI action count is invalid",
509
+ ErrorCode::CapabilityDeferred,
510
+ )
511
+ })?;
512
+ if count > MAX_ATSPI_ACTIONS {
513
+ return Err(ProtocolError::new(
514
+ format!(
515
+ "AT-SPI target exposes {count} actions; maximum supported is {MAX_ATSPI_ACTIONS}"
516
+ ),
517
+ ErrorCode::CapabilityDeferred,
518
+ ));
519
+ }
520
+ Ok(count)
521
+ }
522
+
523
+ fn relative_coordinate(value: i32, origin: i32) -> i32 {
524
+ value.saturating_sub(origin)
525
+ }
526
+
527
+ fn positive_application_pid(application_id: i32) -> Option<u64> {
528
+ u64::try_from(application_id).ok().filter(|pid| *pid > 0)
529
+ }
530
+
531
+ fn fallback_application_pid(application_id: i32, connection_pid: Option<u32>) -> u64 {
532
+ positive_application_pid(application_id)
533
+ .or_else(|| connection_pid.filter(|pid| *pid > 0).map(u64::from))
534
+ .unwrap_or(0)
535
+ }
536
+
537
+ fn unavailable(error: impl std::fmt::Display) -> ProtocolError {
538
+ ProtocolError::new(
539
+ format!("AT-SPI2 is unavailable: {error}"),
540
+ ErrorCode::CapabilityDeferred,
541
+ )
542
+ }
543
+
544
+ fn atspi_error(error: impl std::fmt::Display) -> ProtocolError {
545
+ ProtocolError::new(
546
+ format!("AT-SPI2 request failed: {error}"),
547
+ ErrorCode::InternalError,
548
+ )
549
+ }
550
+
551
+ #[cfg(test)]
552
+ mod tests {
553
+ use super::*;
554
+
555
+ #[test]
556
+ fn activation_action_selection_prefers_semantics_over_index_zero() {
557
+ let row = ["expand or contract", "edit", "activate"].map(str::to_owned);
558
+ assert_eq!(activation_action_index(&row), 2);
559
+ let mixed = ["open", "press", "click"].map(str::to_owned);
560
+ assert_eq!(activation_action_index(&mixed), 2);
561
+ let unsupported = ["expand or contract", "edit"].map(str::to_owned);
562
+ assert_eq!(activation_action_index(&unsupported), 0);
563
+ assert_eq!(activation_action_index(&[" InVoKe ".into()]), 0);
564
+ }
565
+
566
+ #[test]
567
+ fn action_count_and_dispatch_boundaries_are_explicit() {
568
+ assert_eq!(bounded_action_count(Some(0)).unwrap(), 0);
569
+ assert_eq!(selected_action_index(0, &[]), None);
570
+ assert_eq!(bounded_action_count(Some(1)).unwrap(), 1);
571
+ assert_eq!(selected_action_index(1, &[]), Some(0));
572
+
573
+ let mut names = vec!["edit".to_owned(); 64];
574
+ names[63] = "activate".to_owned();
575
+ assert_eq!(bounded_action_count(Some(64)).unwrap(), 64);
576
+ assert_eq!(selected_action_index(64, &names), Some(63));
577
+ assert!(bounded_action_count(Some(65)).is_err());
578
+ assert!(bounded_action_count(Some(i32::MAX)).is_err());
579
+ }
580
+
581
+ #[test]
582
+ fn relative_coordinates_saturate_instead_of_overflowing() {
583
+ assert_eq!(relative_coordinate(i32::MIN, i32::MAX), i32::MIN);
584
+ assert_eq!(relative_coordinate(i32::MAX, i32::MIN), i32::MAX);
585
+ assert_eq!(relative_coordinate(355, 100), 255);
586
+ }
587
+
588
+ #[test]
589
+ fn application_pid_prefers_positive_atspi_id_then_bus_fallback() {
590
+ assert_eq!(fallback_application_pid(42, Some(99)), 42);
591
+ assert_eq!(fallback_application_pid(0, Some(99)), 99);
592
+ assert_eq!(fallback_application_pid(-1, Some(99)), 99);
593
+ assert_eq!(fallback_application_pid(0, Some(0)), 0);
594
+ assert_eq!(fallback_application_pid(0, None), 0);
595
+ }
596
+
597
+ #[tokio::test]
598
+ #[ignore = "requires a live session AT-SPI bus"]
599
+ async fn connects_through_get_address_method() {
600
+ AtspiClient::connect()
601
+ .await
602
+ .expect("org.a11y.Bus.GetAddress should return a usable bus address");
603
+ }
604
+
605
+ #[test]
606
+ fn root_shape_has_cross_platform_aliases() {
607
+ let root = RootSnapshot {
608
+ accessible: AccessibleRef {
609
+ destination: ":1.2".into(),
610
+ path: "/window".into(),
611
+ },
612
+ pid: 42,
613
+ name: "Editor".into(),
614
+ app_name: "Text Editor".into(),
615
+ role: "frame".into(),
616
+ frame: Some(Rect {
617
+ x: 1,
618
+ y: 2,
619
+ width: 3,
620
+ height: 4,
621
+ }),
622
+ x11_window: Some(99),
623
+ is_focused: true,
624
+ is_minimized: false,
625
+ z_order: Some(0),
626
+ };
627
+ let value = root_json("@w1", &root, 0);
628
+ assert_eq!(value["rootRef"], "@w1");
629
+ assert_eq!(value["windowRef"], "@w1");
630
+ assert_eq!(value["windowId"], 99);
631
+ assert_eq!(value["metadata"]["backend"], "at-spi2");
632
+ assert_eq!(value["title"], "Editor");
633
+ assert_eq!(value["appName"], "Text Editor");
634
+ assert_eq!(value["processName"], "Text Editor");
635
+
636
+ let mut wayland_root = root;
637
+ wayland_root.x11_window = None;
638
+ assert!(root_json("@w2", &wayland_root, 0)["windowId"].is_null());
639
+ }
640
+ }