@zenalexa/unicli 0.217.0 → 0.217.3

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 (341) hide show
  1. package/AGENTS.md +7 -6
  2. package/README.md +59 -19
  3. package/README.zh-CN.md +36 -15
  4. package/crates/unicli-atspi/Cargo.toml +47 -0
  5. package/crates/unicli-atspi/README.md +6 -0
  6. package/crates/unicli-atspi/src/errors.rs +213 -0
  7. package/crates/unicli-atspi/src/input.rs +1004 -0
  8. package/crates/unicli-atspi/src/invoke.rs +1132 -0
  9. package/crates/unicli-atspi/src/main.rs +130 -0
  10. package/crates/unicli-atspi/src/refs.rs +24 -0
  11. package/crates/unicli-atspi/src/screenshot.rs +756 -0
  12. package/crates/unicli-atspi/src/tree.rs +2319 -0
  13. package/crates/unicli-shared/Cargo.toml +13 -0
  14. package/crates/unicli-shared/src/lib.rs +77 -0
  15. package/crates/unicli-uia/Cargo.toml +29 -0
  16. package/crates/unicli-uia/README.md +6 -0
  17. package/crates/unicli-uia/src/errors.rs +179 -0
  18. package/crates/unicli-uia/src/input.rs +790 -0
  19. package/crates/unicli-uia/src/invoke.rs +977 -0
  20. package/crates/unicli-uia/src/main.rs +130 -0
  21. package/crates/unicli-uia/src/refs.rs +24 -0
  22. package/crates/unicli-uia/src/screenshot.rs +685 -0
  23. package/crates/unicli-uia/src/tree.rs +2135 -0
  24. package/dist/adapters/macos/actions.d.ts +9 -0
  25. package/dist/adapters/macos/actions.d.ts.map +1 -0
  26. package/dist/adapters/macos/actions.js +55 -0
  27. package/dist/adapters/macos/actions.js.map +1 -0
  28. package/dist/browser/bridge.d.ts +2 -0
  29. package/dist/browser/bridge.d.ts.map +1 -1
  30. package/dist/browser/bridge.js +39 -0
  31. package/dist/browser/bridge.js.map +1 -1
  32. package/dist/browser/cdp-client.d.ts +2 -0
  33. package/dist/browser/cdp-client.d.ts.map +1 -1
  34. package/dist/browser/cdp-client.js +7 -0
  35. package/dist/browser/cdp-client.js.map +1 -1
  36. package/dist/browser/page.d.ts +2 -0
  37. package/dist/browser/page.d.ts.map +1 -1
  38. package/dist/browser/page.js +35 -0
  39. package/dist/browser/page.js.map +1 -1
  40. package/dist/cli.d.ts.map +1 -1
  41. package/dist/cli.js +13 -1
  42. package/dist/cli.js.map +1 -1
  43. package/dist/commands/approvals.d.ts +3 -0
  44. package/dist/commands/approvals.d.ts.map +1 -0
  45. package/dist/commands/approvals.js +123 -0
  46. package/dist/commands/approvals.js.map +1 -0
  47. package/dist/commands/browser-operator-runtime.d.ts.map +1 -1
  48. package/dist/commands/browser-operator-runtime.js +1 -0
  49. package/dist/commands/browser-operator-runtime.js.map +1 -1
  50. package/dist/commands/browser-operator.d.ts.map +1 -1
  51. package/dist/commands/browser-operator.js +84 -12
  52. package/dist/commands/browser-operator.js.map +1 -1
  53. package/dist/commands/compute.d.ts +3 -0
  54. package/dist/commands/compute.d.ts.map +1 -0
  55. package/dist/commands/compute.js +324 -0
  56. package/dist/commands/compute.js.map +1 -0
  57. package/dist/commands/dispatch.d.ts.map +1 -1
  58. package/dist/commands/dispatch.js +10 -4
  59. package/dist/commands/dispatch.js.map +1 -1
  60. package/dist/commands/doctor-compute.d.ts +38 -0
  61. package/dist/commands/doctor-compute.d.ts.map +1 -0
  62. package/dist/commands/doctor-compute.js +376 -0
  63. package/dist/commands/doctor-compute.js.map +1 -0
  64. package/dist/commands/lint.d.ts.map +1 -1
  65. package/dist/commands/lint.js +69 -1
  66. package/dist/commands/lint.js.map +1 -1
  67. package/dist/commands/mcp.d.ts.map +1 -1
  68. package/dist/commands/mcp.js +4 -0
  69. package/dist/commands/mcp.js.map +1 -1
  70. package/dist/commands/runs.d.ts +3 -0
  71. package/dist/commands/runs.d.ts.map +1 -0
  72. package/dist/commands/runs.js +367 -0
  73. package/dist/commands/runs.js.map +1 -0
  74. package/dist/core/envelope.d.ts +8 -0
  75. package/dist/core/envelope.d.ts.map +1 -1
  76. package/dist/core/envelope.js +1 -0
  77. package/dist/core/envelope.js.map +1 -1
  78. package/dist/discovery/aliases.d.ts.map +1 -1
  79. package/dist/discovery/aliases.js +15 -0
  80. package/dist/discovery/aliases.js.map +1 -1
  81. package/dist/discovery/loader.d.ts.map +1 -1
  82. package/dist/discovery/loader.js +9 -0
  83. package/dist/discovery/loader.js.map +1 -1
  84. package/dist/discovery/macos-dynamic.d.ts +58 -0
  85. package/dist/discovery/macos-dynamic.d.ts.map +1 -0
  86. package/dist/discovery/macos-dynamic.js +429 -0
  87. package/dist/discovery/macos-dynamic.js.map +1 -0
  88. package/dist/discovery/search.d.ts.map +1 -1
  89. package/dist/discovery/search.js +152 -3
  90. package/dist/discovery/search.js.map +1 -1
  91. package/dist/electron-apps.d.ts +1 -0
  92. package/dist/electron-apps.d.ts.map +1 -1
  93. package/dist/electron-apps.js +1 -0
  94. package/dist/electron-apps.js.map +1 -1
  95. package/dist/engine/approval-store.d.ts +43 -0
  96. package/dist/engine/approval-store.d.ts.map +1 -0
  97. package/dist/engine/approval-store.js +193 -0
  98. package/dist/engine/approval-store.js.map +1 -0
  99. package/dist/engine/browser/action-evidence.d.ts +2 -0
  100. package/dist/engine/browser/action-evidence.d.ts.map +1 -1
  101. package/dist/engine/browser/action-evidence.js +35 -1
  102. package/dist/engine/browser/action-evidence.js.map +1 -1
  103. package/dist/engine/browser/evidence.d.ts +22 -0
  104. package/dist/engine/browser/evidence.d.ts.map +1 -1
  105. package/dist/engine/browser/evidence.js +72 -0
  106. package/dist/engine/browser/evidence.js.map +1 -1
  107. package/dist/engine/browser/session-lease.d.ts +53 -0
  108. package/dist/engine/browser/session-lease.d.ts.map +1 -0
  109. package/dist/engine/browser/session-lease.js +100 -0
  110. package/dist/engine/browser/session-lease.js.map +1 -0
  111. package/dist/engine/browser/session-lock.d.ts +17 -0
  112. package/dist/engine/browser/session-lock.d.ts.map +1 -0
  113. package/dist/engine/browser/session-lock.js +114 -0
  114. package/dist/engine/browser/session-lock.js.map +1 -0
  115. package/dist/engine/browser/session-runtime.d.ts +10 -0
  116. package/dist/engine/browser/session-runtime.d.ts.map +1 -0
  117. package/dist/engine/browser/session-runtime.js +87 -0
  118. package/dist/engine/browser/session-runtime.js.map +1 -0
  119. package/dist/engine/capability-policy.d.ts +13 -2
  120. package/dist/engine/capability-policy.d.ts.map +1 -1
  121. package/dist/engine/capability-policy.js +113 -3
  122. package/dist/engine/capability-policy.js.map +1 -1
  123. package/dist/engine/executor.d.ts +8 -3
  124. package/dist/engine/executor.d.ts.map +1 -1
  125. package/dist/engine/executor.js +9 -2
  126. package/dist/engine/executor.js.map +1 -1
  127. package/dist/engine/kernel/execute.d.ts +1 -0
  128. package/dist/engine/kernel/execute.d.ts.map +1 -1
  129. package/dist/engine/kernel/execute.js +125 -3
  130. package/dist/engine/kernel/execute.js.map +1 -1
  131. package/dist/engine/kernel/types.d.ts +13 -0
  132. package/dist/engine/kernel/types.d.ts.map +1 -1
  133. package/dist/engine/operation-policy.d.ts +9 -1
  134. package/dist/engine/operation-policy.d.ts.map +1 -1
  135. package/dist/engine/operation-policy.js +6 -2
  136. package/dist/engine/operation-policy.js.map +1 -1
  137. package/dist/engine/permission-rules.d.ts +43 -0
  138. package/dist/engine/permission-rules.d.ts.map +1 -0
  139. package/dist/engine/permission-rules.js +401 -0
  140. package/dist/engine/permission-rules.js.map +1 -0
  141. package/dist/engine/permission-runtime.d.ts +11 -0
  142. package/dist/engine/permission-runtime.d.ts.map +1 -0
  143. package/dist/engine/permission-runtime.js +21 -0
  144. package/dist/engine/permission-runtime.js.map +1 -0
  145. package/dist/engine/repair/remedies.d.ts +4 -0
  146. package/dist/engine/repair/remedies.d.ts.map +1 -0
  147. package/dist/engine/repair/remedies.js +169 -0
  148. package/dist/engine/repair/remedies.js.map +1 -0
  149. package/dist/engine/runtime-resource-guard.d.ts +23 -0
  150. package/dist/engine/runtime-resource-guard.d.ts.map +1 -0
  151. package/dist/engine/runtime-resource-guard.js +85 -0
  152. package/dist/engine/runtime-resource-guard.js.map +1 -0
  153. package/dist/engine/session/args.d.ts +3 -0
  154. package/dist/engine/session/args.d.ts.map +1 -0
  155. package/dist/engine/session/args.js +17 -0
  156. package/dist/engine/session/args.js.map +1 -0
  157. package/dist/engine/session/compare.d.ts +92 -0
  158. package/dist/engine/session/compare.d.ts.map +1 -0
  159. package/dist/engine/session/compare.js +324 -0
  160. package/dist/engine/session/compare.js.map +1 -0
  161. package/dist/engine/session/environment.d.ts +4 -0
  162. package/dist/engine/session/environment.d.ts.map +1 -0
  163. package/dist/engine/session/environment.js +25 -0
  164. package/dist/engine/session/environment.js.map +1 -0
  165. package/dist/engine/session/events.d.ts +2 -0
  166. package/dist/engine/session/events.d.ts.map +1 -1
  167. package/dist/engine/session/events.js +12 -0
  168. package/dist/engine/session/events.js.map +1 -1
  169. package/dist/engine/session/query.d.ts +47 -0
  170. package/dist/engine/session/query.d.ts.map +1 -0
  171. package/dist/engine/session/query.js +299 -0
  172. package/dist/engine/session/query.js.map +1 -0
  173. package/dist/engine/session/replay.d.ts +35 -0
  174. package/dist/engine/session/replay.d.ts.map +1 -0
  175. package/dist/engine/session/replay.js +144 -0
  176. package/dist/engine/session/replay.js.map +1 -0
  177. package/dist/engine/session/run-loop.d.ts.map +1 -1
  178. package/dist/engine/session/run-loop.js +62 -23
  179. package/dist/engine/session/run-loop.js.map +1 -1
  180. package/dist/engine/session/store.d.ts +7 -0
  181. package/dist/engine/session/store.d.ts.map +1 -1
  182. package/dist/engine/session/store.js +131 -1
  183. package/dist/engine/session/store.js.map +1 -1
  184. package/dist/engine/session/types.d.ts +3 -1
  185. package/dist/engine/session/types.d.ts.map +1 -1
  186. package/dist/engine/steps/compute.d.ts +41 -0
  187. package/dist/engine/steps/compute.d.ts.map +1 -0
  188. package/dist/engine/steps/compute.js +55 -0
  189. package/dist/engine/steps/compute.js.map +1 -0
  190. package/dist/engine/steps/desktop-ax.d.ts +8 -0
  191. package/dist/engine/steps/desktop-ax.d.ts.map +1 -1
  192. package/dist/engine/steps/desktop-ax.js +16 -0
  193. package/dist/engine/steps/desktop-ax.js.map +1 -1
  194. package/dist/engine/steps/desktop-sidecar.d.ts +49 -0
  195. package/dist/engine/steps/desktop-sidecar.d.ts.map +1 -0
  196. package/dist/engine/steps/desktop-sidecar.js +50 -0
  197. package/dist/engine/steps/desktop-sidecar.js.map +1 -0
  198. package/dist/engine/steps/download.d.ts +1 -1
  199. package/dist/engine/steps/download.d.ts.map +1 -1
  200. package/dist/engine/steps/download.js +24 -2
  201. package/dist/engine/steps/download.js.map +1 -1
  202. package/dist/engine/steps/exec.d.ts +1 -1
  203. package/dist/engine/steps/exec.d.ts.map +1 -1
  204. package/dist/engine/steps/exec.js +23 -7
  205. package/dist/engine/steps/exec.js.map +1 -1
  206. package/dist/engine/steps/fetch-text.d.ts +1 -1
  207. package/dist/engine/steps/fetch-text.d.ts.map +1 -1
  208. package/dist/engine/steps/fetch-text.js +12 -4
  209. package/dist/engine/steps/fetch-text.js.map +1 -1
  210. package/dist/engine/steps/fetch.d.ts +2 -1
  211. package/dist/engine/steps/fetch.d.ts.map +1 -1
  212. package/dist/engine/steps/fetch.js +29 -6
  213. package/dist/engine/steps/fetch.js.map +1 -1
  214. package/dist/engine/steps/index.d.ts +2 -0
  215. package/dist/engine/steps/index.d.ts.map +1 -1
  216. package/dist/engine/steps/index.js +2 -0
  217. package/dist/engine/steps/index.js.map +1 -1
  218. package/dist/engine/steps/navigate.d.ts +1 -1
  219. package/dist/engine/steps/navigate.d.ts.map +1 -1
  220. package/dist/engine/steps/navigate.js +29 -2
  221. package/dist/engine/steps/navigate.js.map +1 -1
  222. package/dist/fast-path.d.ts.map +1 -1
  223. package/dist/fast-path.js +96 -12
  224. package/dist/fast-path.js.map +1 -1
  225. package/dist/manifest-compact.txt +2 -2
  226. package/dist/manifest-search.json +1 -1
  227. package/dist/manifest.json +1024 -1
  228. package/dist/mcp/handler.d.ts +2 -2
  229. package/dist/mcp/handler.d.ts.map +1 -1
  230. package/dist/mcp/handler.js +59 -5
  231. package/dist/mcp/handler.js.map +1 -1
  232. package/dist/mcp/profiles/computer-use.d.ts +4 -0
  233. package/dist/mcp/profiles/computer-use.d.ts.map +1 -0
  234. package/dist/mcp/profiles/computer-use.js +305 -0
  235. package/dist/mcp/profiles/computer-use.js.map +1 -0
  236. package/dist/mcp/server.d.ts.map +1 -1
  237. package/dist/mcp/server.js +30 -6
  238. package/dist/mcp/server.js.map +1 -1
  239. package/dist/mcp/tools.d.ts +9 -0
  240. package/dist/mcp/tools.d.ts.map +1 -1
  241. package/dist/mcp/tools.js +20 -0
  242. package/dist/mcp/tools.js.map +1 -1
  243. package/dist/output/envelope.d.ts +6 -0
  244. package/dist/output/envelope.d.ts.map +1 -1
  245. package/dist/output/envelope.js.map +1 -1
  246. package/dist/output/error-map.d.ts.map +1 -1
  247. package/dist/output/error-map.js +4 -0
  248. package/dist/output/error-map.js.map +1 -1
  249. package/dist/registry.d.ts +1 -0
  250. package/dist/registry.d.ts.map +1 -1
  251. package/dist/registry.js +5 -0
  252. package/dist/registry.js.map +1 -1
  253. package/dist/transport/adapters/cdp-browser.d.ts +38 -2
  254. package/dist/transport/adapters/cdp-browser.d.ts.map +1 -1
  255. package/dist/transport/adapters/cdp-browser.js +349 -22
  256. package/dist/transport/adapters/cdp-browser.js.map +1 -1
  257. package/dist/transport/adapters/desktop-atspi.d.ts +23 -17
  258. package/dist/transport/adapters/desktop-atspi.d.ts.map +1 -1
  259. package/dist/transport/adapters/desktop-atspi.js +143 -32
  260. package/dist/transport/adapters/desktop-atspi.js.map +1 -1
  261. package/dist/transport/adapters/desktop-ax-helpers.d.ts +24 -0
  262. package/dist/transport/adapters/desktop-ax-helpers.d.ts.map +1 -0
  263. package/dist/transport/adapters/desktop-ax-helpers.js +190 -0
  264. package/dist/transport/adapters/desktop-ax-helpers.js.map +1 -0
  265. package/dist/transport/adapters/desktop-ax-swift.d.ts +13 -0
  266. package/dist/transport/adapters/desktop-ax-swift.d.ts.map +1 -1
  267. package/dist/transport/adapters/desktop-ax-swift.js +176 -2
  268. package/dist/transport/adapters/desktop-ax-swift.js.map +1 -1
  269. package/dist/transport/adapters/desktop-ax.d.ts +11 -2
  270. package/dist/transport/adapters/desktop-ax.d.ts.map +1 -1
  271. package/dist/transport/adapters/desktop-ax.js +131 -16
  272. package/dist/transport/adapters/desktop-ax.js.map +1 -1
  273. package/dist/transport/adapters/desktop-sidecar-errors.d.ts +3 -0
  274. package/dist/transport/adapters/desktop-sidecar-errors.d.ts.map +1 -0
  275. package/dist/transport/adapters/desktop-sidecar-errors.js +34 -0
  276. package/dist/transport/adapters/desktop-sidecar-errors.js.map +1 -0
  277. package/dist/transport/adapters/desktop-sidecar-snapshot.d.ts +10 -0
  278. package/dist/transport/adapters/desktop-sidecar-snapshot.d.ts.map +1 -0
  279. package/dist/transport/adapters/desktop-sidecar-snapshot.js +89 -0
  280. package/dist/transport/adapters/desktop-sidecar-snapshot.js.map +1 -0
  281. package/dist/transport/adapters/desktop-uia.d.ts +23 -17
  282. package/dist/transport/adapters/desktop-uia.d.ts.map +1 -1
  283. package/dist/transport/adapters/desktop-uia.js +142 -32
  284. package/dist/transport/adapters/desktop-uia.js.map +1 -1
  285. package/dist/transport/adapters/subprocess.d.ts +7 -0
  286. package/dist/transport/adapters/subprocess.d.ts.map +1 -1
  287. package/dist/transport/adapters/subprocess.js +64 -0
  288. package/dist/transport/adapters/subprocess.js.map +1 -1
  289. package/dist/transport/bus.d.ts +2 -0
  290. package/dist/transport/bus.d.ts.map +1 -1
  291. package/dist/transport/bus.js +7 -11
  292. package/dist/transport/bus.js.map +1 -1
  293. package/dist/transport/capability.d.ts.map +1 -1
  294. package/dist/transport/capability.js +123 -98
  295. package/dist/transport/capability.js.map +1 -1
  296. package/dist/transport/cascade.d.ts +5 -0
  297. package/dist/transport/cascade.d.ts.map +1 -0
  298. package/dist/transport/cascade.js +550 -0
  299. package/dist/transport/cascade.js.map +1 -0
  300. package/dist/transport/cdp-session.d.ts +11 -0
  301. package/dist/transport/cdp-session.d.ts.map +1 -0
  302. package/dist/transport/cdp-session.js +52 -0
  303. package/dist/transport/cdp-session.js.map +1 -0
  304. package/dist/transport/refs.d.ts +51 -0
  305. package/dist/transport/refs.d.ts.map +1 -0
  306. package/dist/transport/refs.js +135 -0
  307. package/dist/transport/refs.js.map +1 -0
  308. package/dist/transport/sidecar-binary.d.ts +18 -0
  309. package/dist/transport/sidecar-binary.d.ts.map +1 -0
  310. package/dist/transport/sidecar-binary.js +55 -0
  311. package/dist/transport/sidecar-binary.js.map +1 -0
  312. package/dist/transport/sidecar.d.ts +35 -0
  313. package/dist/transport/sidecar.d.ts.map +1 -0
  314. package/dist/transport/sidecar.js +134 -0
  315. package/dist/transport/sidecar.js.map +1 -0
  316. package/dist/transport/snapshot-encoder.d.ts +34 -0
  317. package/dist/transport/snapshot-encoder.d.ts.map +1 -0
  318. package/dist/transport/snapshot-encoder.js +139 -0
  319. package/dist/transport/snapshot-encoder.js.map +1 -0
  320. package/dist/transport/types.d.ts +6 -1
  321. package/dist/transport/types.d.ts.map +1 -1
  322. package/dist/types.d.ts +2 -0
  323. package/dist/types.d.ts.map +1 -1
  324. package/dist/types.js.map +1 -1
  325. package/docs/mcp/clients/claude-code.md +29 -0
  326. package/docs/mcp/clients/claude-desktop.md +47 -0
  327. package/docs/mcp/clients/codex.md +29 -0
  328. package/docs/mcp/clients/cursor.md +38 -0
  329. package/docs/mcp/clients/gemini-cli.md +38 -0
  330. package/docs/operate/compute.md +172 -0
  331. package/docs/operate/electron.md +87 -0
  332. package/docs/operate/focus-behavior.md +40 -0
  333. package/docs/operate/troubleshooting.md +379 -0
  334. package/package.json +29 -4
  335. package/src/adapters/juejin/hot.test.ts +25 -0
  336. package/src/adapters/juejin/hot.yaml +52 -0
  337. package/src/adapters/juejin/search.test.ts +27 -0
  338. package/src/adapters/juejin/search.yaml +58 -0
  339. package/src/adapters/leetcode/discuss-search.test.ts +29 -0
  340. package/src/adapters/leetcode/discuss-search.yaml +56 -0
  341. package/src/adapters/macos/actions.ts +63 -0
@@ -0,0 +1,1132 @@
1
+ use std::process::Command;
2
+
3
+ use unicli_shared::SidecarRequest;
4
+
5
+ use crate::errors::{backend_unavailable, AtspiError, HandlerResult};
6
+ use crate::tree::{
7
+ enumerate_top_level_windows, resolve_descendant_element_ref, resolve_top_level_window_ref,
8
+ ElementBounds, ElementRecord, State, WindowRecord,
9
+ };
10
+
11
+ pub fn handle_invoke(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
12
+ let stable = read_stable_ref(&request.params, "atspi_invoke")?;
13
+ let windows = enumerate_top_level_windows()?;
14
+ if let Some((window, element, path)) = resolve_descendant_element_ref(&windows, &stable) {
15
+ if let Some(action) = try_native_invoke_descendant(window, &stable)? {
16
+ return Ok(invoke_response_for_native_descendant(
17
+ window, element, &stable, &path, action,
18
+ ));
19
+ }
20
+ let bounds = require_descendant_bounds(element, &stable)?;
21
+ focus_top_level_window(window)?;
22
+ let clicked = click_descendant(bounds)?;
23
+ return Ok(invoke_response_for_descendant(
24
+ window, element, &stable, &path, clicked,
25
+ ));
26
+ }
27
+ let stable = require_top_level_stable_ref(stable)?;
28
+ let window = resolve_top_level_window_ref(&windows, &stable)
29
+ .ok_or_else(|| AtspiError::no_element(stable.clone()))?;
30
+ focus_top_level_window(window)?;
31
+ Ok(invoke_response_for_window(window, &stable))
32
+ }
33
+
34
+ pub fn handle_set_value(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
35
+ let Some(stable) = read_optional_stable_ref(&request.params)? else {
36
+ return crate::input::handle_type_text(request);
37
+ };
38
+ let windows = enumerate_top_level_windows()?;
39
+ if let Some((window, element, path)) = resolve_descendant_element_ref(&windows, &stable) {
40
+ if let Some((via, value)) = try_native_set_value_descendant(window, &stable, request)? {
41
+ return Ok(set_value_response_for_native_descendant(
42
+ window, element, &stable, &path, via, value,
43
+ ));
44
+ }
45
+ let bounds = require_descendant_bounds(element, &stable)?;
46
+ focus_top_level_window(window)?;
47
+ let clicked = click_descendant(bounds)?;
48
+ let typed = crate::input::handle_type_text(request)?;
49
+ return Ok(set_value_response_for_descendant(
50
+ window,
51
+ element,
52
+ &stable,
53
+ &path,
54
+ "descendant_click_text_helper",
55
+ clicked,
56
+ typed,
57
+ ));
58
+ }
59
+ let stable = require_top_level_stable_ref(stable)?;
60
+ let window = resolve_top_level_window_ref(&windows, &stable)
61
+ .ok_or_else(|| AtspiError::no_element(stable.clone()))?;
62
+ focus_top_level_window(window)?;
63
+ let typed = crate::input::handle_type_text(request)?;
64
+ Ok(set_value_response_for_window(window, &stable, typed))
65
+ }
66
+
67
+ pub fn handle_focus(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
68
+ let stable = read_stable_ref(&request.params, "atspi_focus")?;
69
+ let windows = enumerate_top_level_windows()?;
70
+ if let Some((window, element, path)) = resolve_descendant_element_ref(&windows, &stable) {
71
+ if let Some(focus) = try_native_focus_descendant(window, &stable)? {
72
+ return Ok(focus_response_for_native_descendant(
73
+ window, element, &stable, &path, focus,
74
+ ));
75
+ }
76
+ let bounds = require_descendant_bounds(element, &stable)?;
77
+ focus_top_level_window(window)?;
78
+ let clicked = click_descendant(bounds)?;
79
+ return Ok(focus_response_for_descendant(
80
+ window, element, &stable, &path, clicked,
81
+ ));
82
+ }
83
+ let stable = require_top_level_stable_ref(stable)?;
84
+ let window = resolve_top_level_window_ref(&windows, &stable)
85
+ .ok_or_else(|| AtspiError::no_element(stable.clone()))?;
86
+ let focus = focus_top_level_window(window)?;
87
+ Ok(focus_response_for_window(window, &stable, focus))
88
+ }
89
+
90
+ pub fn handle_launch_app(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
91
+ if !cfg!(target_os = "linux") {
92
+ return Err(backend_unavailable());
93
+ }
94
+ let app = read_app_name(&request.params, "launch_app")?;
95
+ let args = read_args(&request.params);
96
+ let debug_port = read_debug_port(&request.params);
97
+ let plan = launch_plan_for_app(&app, &args, debug_port);
98
+ run_launch_plan(&plan)?;
99
+ Ok(serde_json::json!({
100
+ "launched": true,
101
+ "via": "gtk_launch",
102
+ "app": app,
103
+ }))
104
+ }
105
+
106
+ fn read_stable_ref(params: &serde_json::Value, action: &str) -> Result<String, AtspiError> {
107
+ params
108
+ .get("stable")
109
+ .or_else(|| params.get("ref"))
110
+ .and_then(serde_json::Value::as_str)
111
+ .filter(|value| value.starts_with("desktop-atspi:"))
112
+ .map(str::to_string)
113
+ .ok_or_else(|| {
114
+ AtspiError::invalid_input(format!(
115
+ "{action} requires a desktop-atspi stable top-level window ref"
116
+ ))
117
+ })
118
+ }
119
+
120
+ fn require_top_level_stable_ref(stable: String) -> Result<String, AtspiError> {
121
+ if stable
122
+ .split_once(':')
123
+ .and_then(|(_, tail)| tail.split_once(':'))
124
+ .map_or(false, |(_, path)| path.contains('/'))
125
+ {
126
+ return Err(AtspiError::not_invokable(stable));
127
+ }
128
+ Ok(stable)
129
+ }
130
+
131
+ fn read_app_name(params: &serde_json::Value, action: &str) -> Result<String, AtspiError> {
132
+ params
133
+ .get("app")
134
+ .or_else(|| params.get("name"))
135
+ .and_then(serde_json::Value::as_str)
136
+ .map(str::trim)
137
+ .filter(|value| !value.is_empty())
138
+ .map(str::to_string)
139
+ .ok_or_else(|| AtspiError::invalid_input(format!("{action} requires app or name")))
140
+ }
141
+
142
+ fn read_args(params: &serde_json::Value) -> Vec<String> {
143
+ params
144
+ .get("args")
145
+ .and_then(serde_json::Value::as_array)
146
+ .map(|args| {
147
+ args.iter()
148
+ .filter_map(serde_json::Value::as_str)
149
+ .map(str::to_string)
150
+ .collect()
151
+ })
152
+ .unwrap_or_default()
153
+ }
154
+
155
+ fn read_debug_port(params: &serde_json::Value) -> Option<u16> {
156
+ params
157
+ .get("debugPort")
158
+ .or_else(|| params.get("debug_port"))
159
+ .and_then(serde_json::Value::as_u64)
160
+ .and_then(|value| u16::try_from(value).ok())
161
+ }
162
+
163
+ fn read_optional_stable_ref(params: &serde_json::Value) -> Result<Option<String>, AtspiError> {
164
+ let Some(value) = params
165
+ .get("stable")
166
+ .or_else(|| params.get("ref"))
167
+ .and_then(serde_json::Value::as_str)
168
+ else {
169
+ return Ok(None);
170
+ };
171
+ if value.starts_with("desktop-atspi:") {
172
+ return Ok(Some(value.to_string()));
173
+ }
174
+ Err(AtspiError::invalid_input(
175
+ "atspi_set_value requires a desktop-atspi stable top-level window ref when ref is provided",
176
+ ))
177
+ }
178
+
179
+ #[derive(Debug, Clone, PartialEq, Eq)]
180
+ struct LaunchPlan {
181
+ program: &'static str,
182
+ args: Vec<String>,
183
+ }
184
+
185
+ fn launch_plan_for_app(app: &str, args: &[String], debug_port: Option<u16>) -> LaunchPlan {
186
+ LaunchPlan {
187
+ program: "gtk-launch",
188
+ args: std::iter::once(app.to_string())
189
+ .chain(args.iter().cloned())
190
+ .chain(debug_port.map(|port| format!("--remote-debugging-port={port}")))
191
+ .collect(),
192
+ }
193
+ }
194
+
195
+ fn run_launch_plan(plan: &LaunchPlan) -> Result<(), AtspiError> {
196
+ let status = Command::new(plan.program)
197
+ .args(&plan.args)
198
+ .status()
199
+ .map_err(|err| AtspiError::unavailable(format!("failed to run app launcher: {err}")))?;
200
+ if status.success() {
201
+ return Ok(());
202
+ }
203
+ Err(AtspiError::unavailable(format!(
204
+ "app launcher {} exited with status {status}",
205
+ plan.program
206
+ )))
207
+ }
208
+
209
+ fn focus_response_for_window(
210
+ window: &WindowRecord,
211
+ stable: &str,
212
+ focus: serde_json::Value,
213
+ ) -> serde_json::Value {
214
+ let via = focus
215
+ .get("via")
216
+ .and_then(serde_json::Value::as_str)
217
+ .unwrap_or("wmctrl_activate");
218
+ let mut response = serde_json::json!({
219
+ "focused": true,
220
+ "via": via,
221
+ "stable": stable,
222
+ "id": window.id,
223
+ "pid": window.pid,
224
+ "title": window.title,
225
+ });
226
+ if via != "wmctrl_activate" {
227
+ response["focus"] = focus;
228
+ }
229
+ response
230
+ }
231
+
232
+ fn invoke_response_for_window(window: &WindowRecord, stable: &str) -> serde_json::Value {
233
+ serde_json::json!({
234
+ "invoked": true,
235
+ "via": "top_level_window",
236
+ "stable": stable,
237
+ "id": window.id,
238
+ "pid": window.pid,
239
+ "title": window.title,
240
+ })
241
+ }
242
+
243
+ fn invoke_response_for_descendant(
244
+ window: &WindowRecord,
245
+ element: &ElementRecord,
246
+ stable: &str,
247
+ path: &str,
248
+ click: serde_json::Value,
249
+ ) -> serde_json::Value {
250
+ let mut target = descendant_target_node(element, path);
251
+ if let Some(bounds) = &element.bounds {
252
+ target["bounds"] = bounds_node(bounds);
253
+ }
254
+ serde_json::json!({
255
+ "invoked": true,
256
+ "via": "descendant_click_helper",
257
+ "stable": stable,
258
+ "id": window.id,
259
+ "pid": window.pid,
260
+ "title": window.title,
261
+ "target": target,
262
+ "click": click,
263
+ })
264
+ }
265
+
266
+ fn invoke_response_for_native_descendant(
267
+ window: &WindowRecord,
268
+ element: &ElementRecord,
269
+ stable: &str,
270
+ path: &str,
271
+ action: serde_json::Value,
272
+ ) -> serde_json::Value {
273
+ let mut target = descendant_target_node(element, path);
274
+ if let Some(bounds) = &element.bounds {
275
+ target["bounds"] = bounds_node(bounds);
276
+ }
277
+ serde_json::json!({
278
+ "invoked": true,
279
+ "via": "atspi_action_proxy",
280
+ "stable": stable,
281
+ "id": window.id,
282
+ "pid": window.pid,
283
+ "title": window.title,
284
+ "target": target,
285
+ "action": action,
286
+ })
287
+ }
288
+
289
+ fn set_value_response_for_window(
290
+ window: &WindowRecord,
291
+ stable: &str,
292
+ typed: serde_json::Value,
293
+ ) -> serde_json::Value {
294
+ serde_json::json!({
295
+ "set": true,
296
+ "via": "top_level_window_text_helper",
297
+ "stable": stable,
298
+ "id": window.id,
299
+ "pid": window.pid,
300
+ "title": window.title,
301
+ "typed": typed,
302
+ })
303
+ }
304
+
305
+ fn set_value_response_for_descendant(
306
+ window: &WindowRecord,
307
+ element: &ElementRecord,
308
+ stable: &str,
309
+ path: &str,
310
+ via: &str,
311
+ click: serde_json::Value,
312
+ typed: serde_json::Value,
313
+ ) -> serde_json::Value {
314
+ let mut target = descendant_target_node(element, path);
315
+ if let Some(bounds) = &element.bounds {
316
+ target["bounds"] = bounds_node(bounds);
317
+ }
318
+ serde_json::json!({
319
+ "set": true,
320
+ "via": via,
321
+ "stable": stable,
322
+ "id": window.id,
323
+ "pid": window.pid,
324
+ "title": window.title,
325
+ "target": target,
326
+ "click": click,
327
+ "typed": typed,
328
+ })
329
+ }
330
+
331
+ fn set_value_response_for_native_descendant(
332
+ window: &WindowRecord,
333
+ element: &ElementRecord,
334
+ stable: &str,
335
+ path: &str,
336
+ via: &str,
337
+ value: serde_json::Value,
338
+ ) -> serde_json::Value {
339
+ let mut target = descendant_target_node(element, path);
340
+ if let Some(bounds) = &element.bounds {
341
+ target["bounds"] = bounds_node(bounds);
342
+ }
343
+ serde_json::json!({
344
+ "set": true,
345
+ "via": via,
346
+ "stable": stable,
347
+ "id": window.id,
348
+ "pid": window.pid,
349
+ "title": window.title,
350
+ "target": target,
351
+ "value": value,
352
+ })
353
+ }
354
+
355
+ fn focus_response_for_descendant(
356
+ window: &WindowRecord,
357
+ element: &ElementRecord,
358
+ stable: &str,
359
+ path: &str,
360
+ click: serde_json::Value,
361
+ ) -> serde_json::Value {
362
+ let mut target = descendant_target_node(element, path);
363
+ if let Some(bounds) = &element.bounds {
364
+ target["bounds"] = bounds_node(bounds);
365
+ }
366
+ serde_json::json!({
367
+ "focused": true,
368
+ "via": "descendant_click_helper",
369
+ "stable": stable,
370
+ "id": window.id,
371
+ "pid": window.pid,
372
+ "title": window.title,
373
+ "target": target,
374
+ "click": click,
375
+ })
376
+ }
377
+
378
+ fn focus_response_for_native_descendant(
379
+ window: &WindowRecord,
380
+ element: &ElementRecord,
381
+ stable: &str,
382
+ path: &str,
383
+ focus: serde_json::Value,
384
+ ) -> serde_json::Value {
385
+ let mut target = descendant_target_node(element, path);
386
+ if let Some(bounds) = &element.bounds {
387
+ target["bounds"] = bounds_node(bounds);
388
+ }
389
+ serde_json::json!({
390
+ "focused": true,
391
+ "via": "atspi_component_proxy",
392
+ "stable": stable,
393
+ "id": window.id,
394
+ "pid": window.pid,
395
+ "title": window.title,
396
+ "target": target,
397
+ "focus": focus,
398
+ })
399
+ }
400
+
401
+ fn require_descendant_bounds<'a>(
402
+ element: &'a ElementRecord,
403
+ stable: &str,
404
+ ) -> Result<&'a ElementBounds, AtspiError> {
405
+ element
406
+ .bounds
407
+ .as_ref()
408
+ .ok_or_else(|| AtspiError::not_invokable(stable.to_string()))
409
+ }
410
+
411
+ fn click_descendant(bounds: &ElementBounds) -> HandlerResult {
412
+ crate::input::click_screen_point(
413
+ bounds.x + (bounds.width as i32 / 2),
414
+ bounds.y + (bounds.height as i32 / 2),
415
+ )
416
+ }
417
+
418
+ fn descendant_target_node(element: &ElementRecord, path: &str) -> serde_json::Value {
419
+ let mut target = serde_json::json!({
420
+ "role": element.role,
421
+ "name": element.name,
422
+ "path": path,
423
+ });
424
+ if let Some(value) = &element.value {
425
+ target["value"] = serde_json::json!(value);
426
+ }
427
+ target
428
+ }
429
+
430
+ fn bounds_node(bounds: &ElementBounds) -> serde_json::Value {
431
+ serde_json::json!({
432
+ "x": bounds.x,
433
+ "y": bounds.y,
434
+ "width": bounds.width,
435
+ "height": bounds.height,
436
+ })
437
+ }
438
+
439
+ #[cfg(any(target_os = "linux", test))]
440
+ #[derive(Debug, Clone, PartialEq)]
441
+ enum NativeSetValueRequest {
442
+ Numeric(f64),
443
+ EditableText(String),
444
+ }
445
+
446
+ #[cfg(any(target_os = "linux", test))]
447
+ fn native_set_value_request(params: &serde_json::Value) -> Option<NativeSetValueRequest> {
448
+ if let Some(value) = params.get("value") {
449
+ return numeric_param_value(value)
450
+ .map(NativeSetValueRequest::Numeric)
451
+ .or_else(|| {
452
+ value
453
+ .as_str()
454
+ .map(str::to_string)
455
+ .map(NativeSetValueRequest::EditableText)
456
+ });
457
+ }
458
+ let text = params.get("text")?;
459
+ numeric_param_value(text)
460
+ .map(NativeSetValueRequest::Numeric)
461
+ .or_else(|| {
462
+ text.as_str()
463
+ .map(str::to_string)
464
+ .map(NativeSetValueRequest::EditableText)
465
+ })
466
+ }
467
+
468
+ #[cfg(any(target_os = "linux", test))]
469
+ fn numeric_param_value(value: &serde_json::Value) -> Option<f64> {
470
+ value.as_f64().or_else(|| value.as_str()?.parse().ok())
471
+ }
472
+
473
+ #[cfg(any(target_os = "linux", test))]
474
+ fn native_set_value_via(request: &NativeSetValueRequest) -> &'static str {
475
+ match request {
476
+ NativeSetValueRequest::Numeric(_) => "atspi_value_proxy",
477
+ NativeSetValueRequest::EditableText(_) => "atspi_editable_text_proxy",
478
+ }
479
+ }
480
+
481
+ #[cfg(target_os = "linux")]
482
+ fn try_native_invoke_descendant(
483
+ window: &WindowRecord,
484
+ stable: &str,
485
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
486
+ let result = futures_lite::future::block_on(async {
487
+ let connection = atspi::AccessibilityConnection::new().await?;
488
+ let element = resolve_live_descendant_accessible(&connection, window, stable).await?;
489
+ let proxies = atspi::proxy::proxy_ext::ProxyExt::proxies(&element).await?;
490
+ let action = proxies.action().await?;
491
+ let invoked = action.do_action(0).await?;
492
+ Ok::<bool, atspi::AtspiError>(invoked)
493
+ });
494
+
495
+ match result {
496
+ Ok(true) => Ok(Some(serde_json::json!({
497
+ "action": true,
498
+ "index": 0,
499
+ }))),
500
+ Ok(false) | Err(_) => Ok(None),
501
+ }
502
+ }
503
+
504
+ #[cfg(not(target_os = "linux"))]
505
+ fn try_native_invoke_descendant(
506
+ _window: &WindowRecord,
507
+ _stable: &str,
508
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
509
+ Ok(None)
510
+ }
511
+
512
+ #[cfg(target_os = "linux")]
513
+ fn try_native_set_value_descendant(
514
+ window: &WindowRecord,
515
+ stable: &str,
516
+ request: &SidecarRequest,
517
+ ) -> Result<Option<(&'static str, serde_json::Value)>, AtspiError> {
518
+ let Some(value) = native_set_value_request(&request.params) else {
519
+ return Ok(None);
520
+ };
521
+ let result = futures_lite::future::block_on(async {
522
+ let connection = atspi::AccessibilityConnection::new().await?;
523
+ let element = resolve_live_descendant_accessible(&connection, window, stable).await?;
524
+ let proxies = atspi::proxy::proxy_ext::ProxyExt::proxies(&element).await?;
525
+ match &value {
526
+ NativeSetValueRequest::Numeric(value) => {
527
+ let value_proxy = proxies.value().await?;
528
+ value_proxy.set_current_value(*value).await?;
529
+ }
530
+ NativeSetValueRequest::EditableText(text) => {
531
+ let editable_text = proxies.editable_text().await?;
532
+ editable_text.set_text_contents(text).await?;
533
+ }
534
+ }
535
+ Ok::<NativeSetValueRequest, atspi::AtspiError>(value)
536
+ });
537
+
538
+ match result {
539
+ Ok(NativeSetValueRequest::Numeric(value)) => Ok(Some((
540
+ native_set_value_via(&NativeSetValueRequest::Numeric(value)),
541
+ serde_json::json!({
542
+ "set": true,
543
+ "value": value,
544
+ }),
545
+ ))),
546
+ Ok(NativeSetValueRequest::EditableText(text)) => Ok(Some((
547
+ native_set_value_via(&NativeSetValueRequest::EditableText(text.clone())),
548
+ serde_json::json!({
549
+ "set": true,
550
+ "text": text,
551
+ }),
552
+ ))),
553
+ Err(_) => Ok(None),
554
+ }
555
+ }
556
+
557
+ #[cfg(not(target_os = "linux"))]
558
+ fn try_native_set_value_descendant(
559
+ _window: &WindowRecord,
560
+ _stable: &str,
561
+ _request: &SidecarRequest,
562
+ ) -> Result<Option<(&'static str, serde_json::Value)>, AtspiError> {
563
+ Ok(None)
564
+ }
565
+
566
+ #[cfg(target_os = "linux")]
567
+ fn try_native_focus_descendant(
568
+ window: &WindowRecord,
569
+ stable: &str,
570
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
571
+ let result = futures_lite::future::block_on(async {
572
+ let connection = atspi::AccessibilityConnection::new().await?;
573
+ let element = resolve_live_descendant_accessible(&connection, window, stable).await?;
574
+ let proxies = atspi::proxy::proxy_ext::ProxyExt::proxies(&element).await?;
575
+ let component = proxies.component().await?;
576
+ let focused = component.grab_focus().await?;
577
+ Ok::<bool, atspi::AtspiError>(focused)
578
+ });
579
+
580
+ match result {
581
+ Ok(true) => Ok(Some(serde_json::json!({ "focused": true }))),
582
+ Ok(false) | Err(_) => Ok(None),
583
+ }
584
+ }
585
+
586
+ #[cfg(not(target_os = "linux"))]
587
+ fn try_native_focus_descendant(
588
+ _window: &WindowRecord,
589
+ _stable: &str,
590
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
591
+ Ok(None)
592
+ }
593
+
594
+ #[cfg(target_os = "linux")]
595
+ pub(crate) async fn resolve_live_descendant_accessible<'a>(
596
+ connection: &'a atspi::AccessibilityConnection,
597
+ window: &WindowRecord,
598
+ stable: &str,
599
+ ) -> Result<atspi::proxy::accessible::AccessibleProxy<'a>, atspi::AtspiError> {
600
+ use atspi::proxy::accessible::ObjectRefExt;
601
+ use std::collections::VecDeque;
602
+
603
+ let mut segments = descendant_segments(stable)
604
+ .ok_or_else(|| atspi::AtspiError::InterfaceNotAvailable("invalid stable ref"))?;
605
+ let _window_segment = segments
606
+ .first()
607
+ .ok_or_else(|| atspi::AtspiError::InterfaceNotAvailable("missing window segment"))?;
608
+ let descendant_segments = segments.split_off(1);
609
+ let root = connection.root_accessible_on_registry().await?;
610
+ let mut candidates: VecDeque<_> = root.get_children().await?.into();
611
+ let conn = connection.connection();
612
+
613
+ while let Some(candidate_ref) = candidates.pop_front() {
614
+ let candidate = candidate_ref.into_accessible_proxy(conn).await?;
615
+ if accessible_matches_window(&candidate, window).await {
616
+ return follow_descendant_segments(conn, candidate, descendant_segments).await;
617
+ }
618
+ for child_ref in candidate.get_children().await.unwrap_or_default() {
619
+ candidates.push_back(child_ref);
620
+ }
621
+ }
622
+
623
+ Err(atspi::AtspiError::InterfaceNotAvailable(
624
+ "window accessible",
625
+ ))
626
+ }
627
+
628
+ #[cfg(target_os = "linux")]
629
+ async fn accessible_matches_window(
630
+ accessible: &atspi::proxy::accessible::AccessibleProxy<'_>,
631
+ window: &WindowRecord,
632
+ ) -> bool {
633
+ let role = accessible
634
+ .get_role()
635
+ .await
636
+ .map(|role| normalize_atspi_role(role.name()))
637
+ .unwrap_or_default();
638
+ let name = accessible.name().await.unwrap_or_default();
639
+ matches!(role.as_str(), "frame" | "window" | "dialog")
640
+ && (name == window.title || (!name.is_empty() && window.title.contains(&name)))
641
+ }
642
+
643
+ #[cfg(target_os = "linux")]
644
+ async fn follow_descendant_segments<'a>(
645
+ conn: &'a zbus::Connection,
646
+ mut current: atspi::proxy::accessible::AccessibleProxy<'a>,
647
+ segments: Vec<(String, usize)>,
648
+ ) -> Result<atspi::proxy::accessible::AccessibleProxy<'a>, atspi::AtspiError> {
649
+ use atspi::proxy::accessible::ObjectRefExt;
650
+
651
+ for (role, target_index) in segments {
652
+ let mut role_index = 0usize;
653
+ let mut matched = None;
654
+ for child_ref in current.get_children().await? {
655
+ let child = child_ref.into_accessible_proxy(conn).await?;
656
+ let child_role = child
657
+ .get_role()
658
+ .await
659
+ .map(|role| normalize_atspi_role(role.name()))
660
+ .unwrap_or_default();
661
+ if child_role == role.as_str() {
662
+ if role_index == target_index {
663
+ matched = Some(child);
664
+ break;
665
+ }
666
+ role_index += 1;
667
+ }
668
+ }
669
+ current = matched.ok_or(atspi::AtspiError::InterfaceNotAvailable(
670
+ "descendant segment",
671
+ ))?;
672
+ }
673
+
674
+ Ok(current)
675
+ }
676
+
677
+ #[cfg(target_os = "linux")]
678
+ fn descendant_segments(stable: &str) -> Option<Vec<(String, usize)>> {
679
+ let (_, path) = stable.strip_prefix("desktop-atspi:")?.split_once(':')?;
680
+ Some(
681
+ path.split('/')
682
+ .filter_map(parse_indexed_path_segment)
683
+ .map(|(role, index)| (role.to_string(), index))
684
+ .collect(),
685
+ )
686
+ }
687
+
688
+ #[cfg(target_os = "linux")]
689
+ fn parse_indexed_path_segment(segment: &str) -> Option<(&str, usize)> {
690
+ let (role, raw_index) = segment.split_once('[')?;
691
+ let index = raw_index.strip_suffix(']')?.parse::<usize>().ok()?;
692
+ Some((role, index))
693
+ }
694
+
695
+ #[cfg(target_os = "linux")]
696
+ fn normalize_atspi_role(role: &str) -> String {
697
+ role.to_ascii_lowercase()
698
+ .chars()
699
+ .map(|character| {
700
+ if character.is_ascii_alphanumeric() {
701
+ character
702
+ } else {
703
+ '_'
704
+ }
705
+ })
706
+ .collect::<String>()
707
+ .split('_')
708
+ .filter(|part| !part.is_empty())
709
+ .collect::<Vec<_>>()
710
+ .join("_")
711
+ }
712
+
713
+ pub(crate) fn focus_top_level_window(window: &WindowRecord) -> HandlerResult {
714
+ if !cfg!(target_os = "linux") {
715
+ return Err(backend_unavailable());
716
+ }
717
+ if let Some(focus) = try_native_focus_top_level_window(window)? {
718
+ return Ok(focus);
719
+ }
720
+ let Some(plan) = activation_plan_for_window(window) else {
721
+ return Err(AtspiError::not_invokable(window_stable_hint(window)));
722
+ };
723
+ let status = Command::new(plan.program)
724
+ .args(&plan.args)
725
+ .status()
726
+ .map_err(|err| AtspiError::unavailable(format!("failed to run wmctrl: {err}")))?;
727
+ if status.success() {
728
+ return Ok(serde_json::json!({
729
+ "focused": true,
730
+ "via": "wmctrl_activate",
731
+ }));
732
+ }
733
+
734
+ Err(AtspiError::unavailable(format!(
735
+ "wmctrl -ia {} exited with status {status}",
736
+ window.id
737
+ )))
738
+ }
739
+
740
+ struct ActivationPlan {
741
+ program: &'static str,
742
+ args: Vec<String>,
743
+ }
744
+
745
+ fn activation_plan_for_window(window: &WindowRecord) -> Option<ActivationPlan> {
746
+ if is_synthetic_atspi_window(window) {
747
+ return None;
748
+ }
749
+ Some(ActivationPlan {
750
+ program: "wmctrl",
751
+ args: vec!["-ia".into(), window.id.clone()],
752
+ })
753
+ }
754
+
755
+ fn is_synthetic_atspi_window(window: &WindowRecord) -> bool {
756
+ window.id.starts_with("atspi-root-") || (window.desktop == "atspi" && window.host == "atspi")
757
+ }
758
+
759
+ fn window_stable_hint(window: &WindowRecord) -> String {
760
+ format!("desktop-atspi:pid-{}:Window[0]", window.pid)
761
+ }
762
+
763
+ #[cfg(target_os = "linux")]
764
+ fn try_native_focus_top_level_window(
765
+ window: &WindowRecord,
766
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
767
+ if !is_synthetic_atspi_window(window) {
768
+ return Ok(None);
769
+ }
770
+ let result = futures_lite::future::block_on(async {
771
+ let connection = atspi::AccessibilityConnection::new().await?;
772
+ let root = connection.root_accessible_on_registry().await?;
773
+ let conn = connection.connection();
774
+ for child_ref in root.get_children().await? {
775
+ let child =
776
+ atspi::proxy::accessible::ObjectRefExt::into_accessible_proxy(child_ref, conn)
777
+ .await?;
778
+ if accessible_matches_window(&child, window).await {
779
+ let proxies = atspi::proxy::proxy_ext::ProxyExt::proxies(&child).await?;
780
+ let component = proxies.component().await?;
781
+ let focused = component.grab_focus().await?;
782
+ return Ok::<bool, atspi::AtspiError>(focused);
783
+ }
784
+ }
785
+ Ok::<bool, atspi::AtspiError>(false)
786
+ });
787
+
788
+ match result {
789
+ Ok(true) => Ok(Some(serde_json::json!({
790
+ "focused": true,
791
+ "via": "atspi_component_proxy",
792
+ }))),
793
+ Ok(false) | Err(_) => Ok(None),
794
+ }
795
+ }
796
+
797
+ #[cfg(not(target_os = "linux"))]
798
+ fn try_native_focus_top_level_window(
799
+ _window: &WindowRecord,
800
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
801
+ Ok(None)
802
+ }
803
+
804
+ #[cfg(test)]
805
+ mod tests {
806
+ use super::*;
807
+ use crate::errors::IntoSidecarResponse;
808
+ use crate::tree::{ElementBounds, ElementRecord, WindowRecord};
809
+
810
+ #[test]
811
+ fn focus_response_includes_top_level_window_target_metadata() {
812
+ let response = focus_response_for_window(
813
+ &WindowRecord {
814
+ id: "0x03a00008".into(),
815
+ pid: 1234,
816
+ title: "Terminal Settings".into(),
817
+ desktop: "0".into(),
818
+ host: "host".into(),
819
+ bounds: None,
820
+ children: vec![],
821
+ },
822
+ "desktop-atspi:pid-1234:Window[1]",
823
+ serde_json::json!({
824
+ "focused": true,
825
+ "via": "wmctrl_activate",
826
+ }),
827
+ );
828
+
829
+ assert_eq!(
830
+ response,
831
+ serde_json::json!({
832
+ "focused": true,
833
+ "via": "wmctrl_activate",
834
+ "stable": "desktop-atspi:pid-1234:Window[1]",
835
+ "id": "0x03a00008",
836
+ "pid": 1234,
837
+ "title": "Terminal Settings",
838
+ }),
839
+ );
840
+ }
841
+
842
+ #[test]
843
+ fn focus_response_reports_native_top_level_focus_via() {
844
+ let response = focus_response_for_window(
845
+ &WindowRecord {
846
+ id: "atspi-root-0".into(),
847
+ pid: u32::MAX,
848
+ title: "Preferences".into(),
849
+ desktop: "atspi".into(),
850
+ host: "atspi".into(),
851
+ bounds: None,
852
+ children: vec![],
853
+ },
854
+ "desktop-atspi:pid-4294967295:Window[0]",
855
+ serde_json::json!({
856
+ "focused": true,
857
+ "via": "atspi_component_proxy",
858
+ }),
859
+ );
860
+
861
+ assert_eq!(response["via"], "atspi_component_proxy");
862
+ assert_eq!(response["focus"]["via"], "atspi_component_proxy");
863
+ }
864
+
865
+ #[test]
866
+ fn activation_plan_uses_wmctrl_window_id() {
867
+ let plan = activation_plan_for_window(&WindowRecord {
868
+ id: "0x03a00008".into(),
869
+ pid: 1234,
870
+ title: "Terminal Settings".into(),
871
+ desktop: "0".into(),
872
+ host: "host".into(),
873
+ bounds: None,
874
+ children: vec![],
875
+ })
876
+ .expect("wmctrl-backed window");
877
+
878
+ assert_eq!(plan.program, "wmctrl");
879
+ assert_eq!(plan.args, vec!["-ia", "0x03a00008"]);
880
+ }
881
+
882
+ #[test]
883
+ fn activation_plan_skips_synthetic_atspi_windows() {
884
+ let plan = activation_plan_for_window(&WindowRecord {
885
+ id: "atspi-root-0".into(),
886
+ pid: u32::MAX,
887
+ title: "Preferences".into(),
888
+ desktop: "atspi".into(),
889
+ host: "atspi".into(),
890
+ bounds: None,
891
+ children: vec![],
892
+ });
893
+
894
+ assert!(plan.is_none());
895
+ }
896
+
897
+ #[test]
898
+ fn launch_plan_uses_gtk_launch() {
899
+ let plan = launch_plan_for_app("org.gnome.Calculator", &["--safe-mode".into()], None);
900
+
901
+ assert_eq!(plan.program, "gtk-launch");
902
+ assert_eq!(plan.args, vec!["org.gnome.Calculator", "--safe-mode"]);
903
+ }
904
+
905
+ #[test]
906
+ fn launch_plan_appends_debug_port_argument() {
907
+ let plan = launch_plan_for_app("code.desktop", &[], Some(9230));
908
+
909
+ assert_eq!(plan.program, "gtk-launch");
910
+ assert_eq!(
911
+ plan.args,
912
+ vec!["code.desktop", "--remote-debugging-port=9230"],
913
+ );
914
+ }
915
+
916
+ #[test]
917
+ fn set_value_response_includes_target_window_metadata() {
918
+ let response = set_value_response_for_window(
919
+ &WindowRecord {
920
+ id: "0x03a00008".into(),
921
+ pid: 1234,
922
+ title: "Terminal Settings".into(),
923
+ desktop: "0".into(),
924
+ host: "host".into(),
925
+ bounds: None,
926
+ children: vec![],
927
+ },
928
+ "desktop-atspi:pid-1234:Window[1]",
929
+ serde_json::json!({
930
+ "typed": true,
931
+ "backend": "xdotool",
932
+ "chars": 5,
933
+ }),
934
+ );
935
+
936
+ assert_eq!(
937
+ response,
938
+ serde_json::json!({
939
+ "set": true,
940
+ "via": "top_level_window_text_helper",
941
+ "stable": "desktop-atspi:pid-1234:Window[1]",
942
+ "id": "0x03a00008",
943
+ "pid": 1234,
944
+ "title": "Terminal Settings",
945
+ "typed": {
946
+ "typed": true,
947
+ "backend": "xdotool",
948
+ "chars": 5,
949
+ },
950
+ }),
951
+ );
952
+ }
953
+
954
+ #[test]
955
+ fn invoke_response_includes_descendant_target_metadata() {
956
+ let response = invoke_response_for_descendant(
957
+ &WindowRecord {
958
+ id: "0x03a00008".into(),
959
+ pid: 1234,
960
+ title: "Calculator".into(),
961
+ desktop: "0".into(),
962
+ host: "host".into(),
963
+ bounds: None,
964
+ children: vec![],
965
+ },
966
+ &ElementRecord {
967
+ role: "push_button".into(),
968
+ name: "Seven".into(),
969
+ value: None,
970
+ bounds: Some(ElementBounds {
971
+ x: 20,
972
+ y: 30,
973
+ width: 40,
974
+ height: 50,
975
+ }),
976
+ states: vec!["enabled".into()],
977
+ children: vec![],
978
+ },
979
+ "desktop-atspi:pid-1234:Window[0]/push_button[1]",
980
+ "Window[0]/push_button[1]",
981
+ serde_json::json!({
982
+ "clicked": true,
983
+ "backend": "xdotool",
984
+ }),
985
+ );
986
+
987
+ assert_eq!(
988
+ response,
989
+ serde_json::json!({
990
+ "invoked": true,
991
+ "via": "descendant_click_helper",
992
+ "stable": "desktop-atspi:pid-1234:Window[0]/push_button[1]",
993
+ "id": "0x03a00008",
994
+ "pid": 1234,
995
+ "title": "Calculator",
996
+ "target": {
997
+ "role": "push_button",
998
+ "name": "Seven",
999
+ "path": "Window[0]/push_button[1]",
1000
+ "bounds": {
1001
+ "x": 20,
1002
+ "y": 30,
1003
+ "width": 40,
1004
+ "height": 50,
1005
+ },
1006
+ },
1007
+ "click": {
1008
+ "clicked": true,
1009
+ "backend": "xdotool",
1010
+ },
1011
+ }),
1012
+ );
1013
+ }
1014
+
1015
+ #[test]
1016
+ fn descendant_refs_are_rejected_when_no_action_bounds_exist() {
1017
+ let error = require_descendant_bounds(
1018
+ &ElementRecord {
1019
+ role: "push_button".into(),
1020
+ name: "Seven".into(),
1021
+ value: None,
1022
+ bounds: None,
1023
+ states: vec!["enabled".into()],
1024
+ children: vec![],
1025
+ },
1026
+ "desktop-atspi:pid-1234:Window[0]/push_button[1]",
1027
+ )
1028
+ .expect_err("descendant action needs target bounds");
1029
+
1030
+ let response = Err::<serde_json::Value, _>(error).into_response(1, "atspi_invoke".into());
1031
+ let error = response.error.expect("error envelope");
1032
+ assert_eq!(error.minimum_capability, "desktop-atspi.not_invokable");
1033
+ assert_eq!(
1034
+ error.r#ref.as_deref(),
1035
+ Some("desktop-atspi:pid-1234:Window[0]/push_button[1]"),
1036
+ );
1037
+ }
1038
+
1039
+ #[test]
1040
+ fn set_value_request_uses_editable_text_proxy_for_non_numeric_text() {
1041
+ let request = native_set_value_request(&serde_json::json!({
1042
+ "text": "Ada Lovelace",
1043
+ }))
1044
+ .expect("text request");
1045
+
1046
+ assert_eq!(
1047
+ request,
1048
+ NativeSetValueRequest::EditableText("Ada Lovelace".into()),
1049
+ );
1050
+ }
1051
+
1052
+ #[test]
1053
+ fn set_value_request_uses_value_proxy_for_numeric_value() {
1054
+ let request = native_set_value_request(&serde_json::json!({
1055
+ "value": 42.5,
1056
+ }))
1057
+ .expect("numeric request");
1058
+
1059
+ assert_eq!(request, NativeSetValueRequest::Numeric(42.5));
1060
+ }
1061
+
1062
+ #[test]
1063
+ fn set_value_request_reports_editable_text_proxy_via() {
1064
+ assert_eq!(
1065
+ native_set_value_via(&NativeSetValueRequest::EditableText("Ada".into())),
1066
+ "atspi_editable_text_proxy",
1067
+ );
1068
+ }
1069
+
1070
+ #[test]
1071
+ fn invoke_response_can_report_native_action_proxy() {
1072
+ let response = invoke_response_for_native_descendant(
1073
+ &WindowRecord {
1074
+ id: "0x03a00008".into(),
1075
+ pid: 1234,
1076
+ title: "Calculator".into(),
1077
+ desktop: "0".into(),
1078
+ host: "host".into(),
1079
+ bounds: None,
1080
+ children: vec![],
1081
+ },
1082
+ &ElementRecord {
1083
+ role: "push_button".into(),
1084
+ name: "Seven".into(),
1085
+ value: None,
1086
+ bounds: None,
1087
+ states: vec!["enabled".into()],
1088
+ children: vec![],
1089
+ },
1090
+ "desktop-atspi:pid-1234:Window[0]/push_button[1]",
1091
+ "Window[0]/push_button[1]",
1092
+ serde_json::json!({
1093
+ "action": true,
1094
+ "index": 0,
1095
+ }),
1096
+ );
1097
+
1098
+ assert_eq!(response["via"], "atspi_action_proxy");
1099
+ assert_eq!(response["action"]["index"], 0);
1100
+ }
1101
+
1102
+ #[test]
1103
+ fn focus_response_can_report_native_component_proxy() {
1104
+ let response = focus_response_for_native_descendant(
1105
+ &WindowRecord {
1106
+ id: "0x03a00008".into(),
1107
+ pid: 1234,
1108
+ title: "Calculator".into(),
1109
+ desktop: "0".into(),
1110
+ host: "host".into(),
1111
+ bounds: None,
1112
+ children: vec![],
1113
+ },
1114
+ &ElementRecord {
1115
+ role: "text".into(),
1116
+ name: "Display".into(),
1117
+ value: None,
1118
+ bounds: None,
1119
+ states: vec!["focusable".into()],
1120
+ children: vec![],
1121
+ },
1122
+ "desktop-atspi:pid-1234:Window[0]/text[0]",
1123
+ "Window[0]/text[0]",
1124
+ serde_json::json!({
1125
+ "focused": true,
1126
+ }),
1127
+ );
1128
+
1129
+ assert_eq!(response["via"], "atspi_component_proxy");
1130
+ assert_eq!(response["focus"]["focused"], true);
1131
+ }
1132
+ }