@zenalexa/unicli 0.216.3 → 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 (379) hide show
  1. package/AGENTS.md +7 -6
  2. package/README.md +67 -19
  3. package/README.zh-CN.md +44 -16
  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/_electron/desktop-shared.d.ts.map +1 -1
  25. package/dist/adapters/_electron/desktop-shared.js +13 -0
  26. package/dist/adapters/_electron/desktop-shared.js.map +1 -1
  27. package/dist/adapters/_electron/shared.d.ts +1 -0
  28. package/dist/adapters/_electron/shared.d.ts.map +1 -1
  29. package/dist/adapters/_electron/shared.js +49 -2
  30. package/dist/adapters/_electron/shared.js.map +1 -1
  31. package/dist/adapters/macos/actions.d.ts +9 -0
  32. package/dist/adapters/macos/actions.d.ts.map +1 -0
  33. package/dist/adapters/macos/actions.js +55 -0
  34. package/dist/adapters/macos/actions.js.map +1 -0
  35. package/dist/browser/bridge.d.ts +5 -1
  36. package/dist/browser/bridge.d.ts.map +1 -1
  37. package/dist/browser/bridge.js +86 -23
  38. package/dist/browser/bridge.js.map +1 -1
  39. package/dist/browser/cdp-client.d.ts +2 -0
  40. package/dist/browser/cdp-client.d.ts.map +1 -1
  41. package/dist/browser/cdp-client.js +7 -0
  42. package/dist/browser/cdp-client.js.map +1 -1
  43. package/dist/browser/page.d.ts +2 -0
  44. package/dist/browser/page.d.ts.map +1 -1
  45. package/dist/browser/page.js +35 -0
  46. package/dist/browser/page.js.map +1 -1
  47. package/dist/cli.d.ts.map +1 -1
  48. package/dist/cli.js +17 -2
  49. package/dist/cli.js.map +1 -1
  50. package/dist/commands/approvals.d.ts +3 -0
  51. package/dist/commands/approvals.d.ts.map +1 -0
  52. package/dist/commands/approvals.js +123 -0
  53. package/dist/commands/approvals.js.map +1 -0
  54. package/dist/commands/browser-operator-runtime.d.ts.map +1 -1
  55. package/dist/commands/browser-operator-runtime.js +5 -2
  56. package/dist/commands/browser-operator-runtime.js.map +1 -1
  57. package/dist/commands/browser-operator.d.ts.map +1 -1
  58. package/dist/commands/browser-operator.js +182 -38
  59. package/dist/commands/browser-operator.js.map +1 -1
  60. package/dist/commands/compute.d.ts +3 -0
  61. package/dist/commands/compute.d.ts.map +1 -0
  62. package/dist/commands/compute.js +324 -0
  63. package/dist/commands/compute.js.map +1 -0
  64. package/dist/commands/describe.d.ts.map +1 -1
  65. package/dist/commands/describe.js +20 -1
  66. package/dist/commands/describe.js.map +1 -1
  67. package/dist/commands/dispatch.d.ts +3 -0
  68. package/dist/commands/dispatch.d.ts.map +1 -1
  69. package/dist/commands/dispatch.js +76 -4
  70. package/dist/commands/dispatch.js.map +1 -1
  71. package/dist/commands/doctor-compute.d.ts +38 -0
  72. package/dist/commands/doctor-compute.d.ts.map +1 -0
  73. package/dist/commands/doctor-compute.js +376 -0
  74. package/dist/commands/doctor-compute.js.map +1 -0
  75. package/dist/commands/lint.d.ts.map +1 -1
  76. package/dist/commands/lint.js +69 -1
  77. package/dist/commands/lint.js.map +1 -1
  78. package/dist/commands/mcp.d.ts.map +1 -1
  79. package/dist/commands/mcp.js +4 -0
  80. package/dist/commands/mcp.js.map +1 -1
  81. package/dist/commands/runs.d.ts +3 -0
  82. package/dist/commands/runs.d.ts.map +1 -0
  83. package/dist/commands/runs.js +367 -0
  84. package/dist/commands/runs.js.map +1 -0
  85. package/dist/core/envelope.d.ts +8 -0
  86. package/dist/core/envelope.d.ts.map +1 -1
  87. package/dist/core/envelope.js +1 -0
  88. package/dist/core/envelope.js.map +1 -1
  89. package/dist/core/schema-v2.d.ts +2 -2
  90. package/dist/discovery/aliases.d.ts.map +1 -1
  91. package/dist/discovery/aliases.js +15 -0
  92. package/dist/discovery/aliases.js.map +1 -1
  93. package/dist/discovery/loader.d.ts.map +1 -1
  94. package/dist/discovery/loader.js +11 -0
  95. package/dist/discovery/loader.js.map +1 -1
  96. package/dist/discovery/macos-dynamic.d.ts +58 -0
  97. package/dist/discovery/macos-dynamic.d.ts.map +1 -0
  98. package/dist/discovery/macos-dynamic.js +429 -0
  99. package/dist/discovery/macos-dynamic.js.map +1 -0
  100. package/dist/discovery/search.d.ts.map +1 -1
  101. package/dist/discovery/search.js +152 -3
  102. package/dist/discovery/search.js.map +1 -1
  103. package/dist/electron-apps.d.ts +1 -0
  104. package/dist/electron-apps.d.ts.map +1 -1
  105. package/dist/electron-apps.js +1 -0
  106. package/dist/electron-apps.js.map +1 -1
  107. package/dist/engine/approval-store.d.ts +43 -0
  108. package/dist/engine/approval-store.d.ts.map +1 -0
  109. package/dist/engine/approval-store.js +193 -0
  110. package/dist/engine/approval-store.js.map +1 -0
  111. package/dist/engine/browser/action-evidence.d.ts +30 -0
  112. package/dist/engine/browser/action-evidence.d.ts.map +1 -0
  113. package/dist/engine/browser/action-evidence.js +354 -0
  114. package/dist/engine/browser/action-evidence.js.map +1 -0
  115. package/dist/engine/browser/evidence.d.ts +85 -0
  116. package/dist/engine/browser/evidence.d.ts.map +1 -0
  117. package/dist/engine/browser/evidence.js +373 -0
  118. package/dist/engine/browser/evidence.js.map +1 -0
  119. package/dist/engine/browser/session-lease.d.ts +53 -0
  120. package/dist/engine/browser/session-lease.d.ts.map +1 -0
  121. package/dist/engine/browser/session-lease.js +100 -0
  122. package/dist/engine/browser/session-lease.js.map +1 -0
  123. package/dist/engine/browser/session-lock.d.ts +17 -0
  124. package/dist/engine/browser/session-lock.d.ts.map +1 -0
  125. package/dist/engine/browser/session-lock.js +114 -0
  126. package/dist/engine/browser/session-lock.js.map +1 -0
  127. package/dist/engine/browser/session-runtime.d.ts +10 -0
  128. package/dist/engine/browser/session-runtime.d.ts.map +1 -0
  129. package/dist/engine/browser/session-runtime.js +87 -0
  130. package/dist/engine/browser/session-runtime.js.map +1 -0
  131. package/dist/engine/capability-policy.d.ts +50 -0
  132. package/dist/engine/capability-policy.d.ts.map +1 -0
  133. package/dist/engine/capability-policy.js +305 -0
  134. package/dist/engine/capability-policy.js.map +1 -0
  135. package/dist/engine/executor.d.ts +8 -3
  136. package/dist/engine/executor.d.ts.map +1 -1
  137. package/dist/engine/executor.js +9 -2
  138. package/dist/engine/executor.js.map +1 -1
  139. package/dist/engine/kernel/execute.d.ts +5 -1
  140. package/dist/engine/kernel/execute.d.ts.map +1 -1
  141. package/dist/engine/kernel/execute.js +215 -11
  142. package/dist/engine/kernel/execute.js.map +1 -1
  143. package/dist/engine/kernel/types.d.ts +15 -0
  144. package/dist/engine/kernel/types.d.ts.map +1 -1
  145. package/dist/engine/operation-policy.d.ts +60 -0
  146. package/dist/engine/operation-policy.d.ts.map +1 -0
  147. package/dist/engine/operation-policy.js +364 -0
  148. package/dist/engine/operation-policy.js.map +1 -0
  149. package/dist/engine/permission-rules.d.ts +43 -0
  150. package/dist/engine/permission-rules.d.ts.map +1 -0
  151. package/dist/engine/permission-rules.js +401 -0
  152. package/dist/engine/permission-rules.js.map +1 -0
  153. package/dist/engine/permission-runtime.d.ts +11 -0
  154. package/dist/engine/permission-runtime.d.ts.map +1 -0
  155. package/dist/engine/permission-runtime.js +21 -0
  156. package/dist/engine/permission-runtime.js.map +1 -0
  157. package/dist/engine/repair/remedies.d.ts +4 -0
  158. package/dist/engine/repair/remedies.d.ts.map +1 -0
  159. package/dist/engine/repair/remedies.js +169 -0
  160. package/dist/engine/repair/remedies.js.map +1 -0
  161. package/dist/engine/runtime-resource-guard.d.ts +23 -0
  162. package/dist/engine/runtime-resource-guard.d.ts.map +1 -0
  163. package/dist/engine/runtime-resource-guard.js +85 -0
  164. package/dist/engine/runtime-resource-guard.js.map +1 -0
  165. package/dist/engine/session/args.d.ts +3 -0
  166. package/dist/engine/session/args.d.ts.map +1 -0
  167. package/dist/engine/session/args.js +17 -0
  168. package/dist/engine/session/args.js.map +1 -0
  169. package/dist/engine/session/compare.d.ts +92 -0
  170. package/dist/engine/session/compare.d.ts.map +1 -0
  171. package/dist/engine/session/compare.js +324 -0
  172. package/dist/engine/session/compare.js.map +1 -0
  173. package/dist/engine/session/environment.d.ts +4 -0
  174. package/dist/engine/session/environment.d.ts.map +1 -0
  175. package/dist/engine/session/environment.js +25 -0
  176. package/dist/engine/session/environment.js.map +1 -0
  177. package/dist/engine/session/events.d.ts +24 -0
  178. package/dist/engine/session/events.d.ts.map +1 -0
  179. package/dist/engine/session/events.js +78 -0
  180. package/dist/engine/session/events.js.map +1 -0
  181. package/dist/engine/session/query.d.ts +47 -0
  182. package/dist/engine/session/query.d.ts.map +1 -0
  183. package/dist/engine/session/query.js +299 -0
  184. package/dist/engine/session/query.js.map +1 -0
  185. package/dist/engine/session/replay.d.ts +35 -0
  186. package/dist/engine/session/replay.d.ts.map +1 -0
  187. package/dist/engine/session/replay.js +144 -0
  188. package/dist/engine/session/replay.js.map +1 -0
  189. package/dist/engine/session/run-loop.d.ts +11 -0
  190. package/dist/engine/session/run-loop.d.ts.map +1 -0
  191. package/dist/engine/session/run-loop.js +212 -0
  192. package/dist/engine/session/run-loop.js.map +1 -0
  193. package/dist/engine/session/store.d.ts +26 -0
  194. package/dist/engine/session/store.d.ts.map +1 -0
  195. package/dist/engine/session/store.js +214 -0
  196. package/dist/engine/session/store.js.map +1 -0
  197. package/dist/engine/session/types.d.ts +39 -0
  198. package/dist/engine/session/types.d.ts.map +1 -0
  199. package/dist/engine/session/types.js +2 -0
  200. package/dist/engine/session/types.js.map +1 -0
  201. package/dist/engine/steps/compute.d.ts +41 -0
  202. package/dist/engine/steps/compute.d.ts.map +1 -0
  203. package/dist/engine/steps/compute.js +55 -0
  204. package/dist/engine/steps/compute.js.map +1 -0
  205. package/dist/engine/steps/desktop-ax.d.ts +8 -0
  206. package/dist/engine/steps/desktop-ax.d.ts.map +1 -1
  207. package/dist/engine/steps/desktop-ax.js +16 -0
  208. package/dist/engine/steps/desktop-ax.js.map +1 -1
  209. package/dist/engine/steps/desktop-sidecar.d.ts +49 -0
  210. package/dist/engine/steps/desktop-sidecar.d.ts.map +1 -0
  211. package/dist/engine/steps/desktop-sidecar.js +50 -0
  212. package/dist/engine/steps/desktop-sidecar.js.map +1 -0
  213. package/dist/engine/steps/download.d.ts +1 -1
  214. package/dist/engine/steps/download.d.ts.map +1 -1
  215. package/dist/engine/steps/download.js +24 -2
  216. package/dist/engine/steps/download.js.map +1 -1
  217. package/dist/engine/steps/exec.d.ts +1 -1
  218. package/dist/engine/steps/exec.d.ts.map +1 -1
  219. package/dist/engine/steps/exec.js +23 -7
  220. package/dist/engine/steps/exec.js.map +1 -1
  221. package/dist/engine/steps/fetch-text.d.ts +2 -2
  222. package/dist/engine/steps/fetch-text.d.ts.map +1 -1
  223. package/dist/engine/steps/fetch-text.js +61 -19
  224. package/dist/engine/steps/fetch-text.js.map +1 -1
  225. package/dist/engine/steps/fetch.d.ts +3 -1
  226. package/dist/engine/steps/fetch.d.ts.map +1 -1
  227. package/dist/engine/steps/fetch.js +36 -7
  228. package/dist/engine/steps/fetch.js.map +1 -1
  229. package/dist/engine/steps/index.d.ts +2 -0
  230. package/dist/engine/steps/index.d.ts.map +1 -1
  231. package/dist/engine/steps/index.js +2 -0
  232. package/dist/engine/steps/index.js.map +1 -1
  233. package/dist/engine/steps/navigate.d.ts +1 -1
  234. package/dist/engine/steps/navigate.d.ts.map +1 -1
  235. package/dist/engine/steps/navigate.js +29 -2
  236. package/dist/engine/steps/navigate.js.map +1 -1
  237. package/dist/engine/steps/parse-rss.d.ts.map +1 -1
  238. package/dist/engine/steps/parse-rss.js +9 -4
  239. package/dist/engine/steps/parse-rss.js.map +1 -1
  240. package/dist/engine/template.d.ts.map +1 -1
  241. package/dist/engine/template.js +2 -1
  242. package/dist/engine/template.js.map +1 -1
  243. package/dist/engine/text-normalize.d.ts +6 -0
  244. package/dist/engine/text-normalize.d.ts.map +1 -0
  245. package/dist/engine/text-normalize.js +63 -0
  246. package/dist/engine/text-normalize.js.map +1 -0
  247. package/dist/fast-path.d.ts.map +1 -1
  248. package/dist/fast-path.js +291 -8
  249. package/dist/fast-path.js.map +1 -1
  250. package/dist/main.d.ts +1 -1
  251. package/dist/main.js +1 -1
  252. package/dist/manifest-compact.txt +2 -2
  253. package/dist/manifest-search.json +1 -1
  254. package/dist/manifest.json +4313 -533
  255. package/dist/mcp/dispatch.d.ts +3 -3
  256. package/dist/mcp/dispatch.d.ts.map +1 -1
  257. package/dist/mcp/dispatch.js +6 -5
  258. package/dist/mcp/dispatch.js.map +1 -1
  259. package/dist/mcp/handler.d.ts +2 -2
  260. package/dist/mcp/handler.d.ts.map +1 -1
  261. package/dist/mcp/handler.js +59 -5
  262. package/dist/mcp/handler.js.map +1 -1
  263. package/dist/mcp/profiles/computer-use.d.ts +4 -0
  264. package/dist/mcp/profiles/computer-use.d.ts.map +1 -0
  265. package/dist/mcp/profiles/computer-use.js +305 -0
  266. package/dist/mcp/profiles/computer-use.js.map +1 -0
  267. package/dist/mcp/server.d.ts.map +1 -1
  268. package/dist/mcp/server.js +30 -6
  269. package/dist/mcp/server.js.map +1 -1
  270. package/dist/mcp/tools.d.ts +9 -0
  271. package/dist/mcp/tools.d.ts.map +1 -1
  272. package/dist/mcp/tools.js +20 -0
  273. package/dist/mcp/tools.js.map +1 -1
  274. package/dist/output/envelope.d.ts +6 -0
  275. package/dist/output/envelope.d.ts.map +1 -1
  276. package/dist/output/envelope.js.map +1 -1
  277. package/dist/output/error-map.d.ts.map +1 -1
  278. package/dist/output/error-map.js +25 -0
  279. package/dist/output/error-map.js.map +1 -1
  280. package/dist/protocol/acp-helpers.d.ts +2 -2
  281. package/dist/protocol/acp-helpers.d.ts.map +1 -1
  282. package/dist/protocol/acp-helpers.js +5 -4
  283. package/dist/protocol/acp-helpers.js.map +1 -1
  284. package/dist/registry.d.ts +4 -1
  285. package/dist/registry.d.ts.map +1 -1
  286. package/dist/registry.js +7 -0
  287. package/dist/registry.js.map +1 -1
  288. package/dist/transport/adapters/cdp-browser.d.ts +38 -2
  289. package/dist/transport/adapters/cdp-browser.d.ts.map +1 -1
  290. package/dist/transport/adapters/cdp-browser.js +349 -22
  291. package/dist/transport/adapters/cdp-browser.js.map +1 -1
  292. package/dist/transport/adapters/desktop-atspi.d.ts +23 -17
  293. package/dist/transport/adapters/desktop-atspi.d.ts.map +1 -1
  294. package/dist/transport/adapters/desktop-atspi.js +143 -32
  295. package/dist/transport/adapters/desktop-atspi.js.map +1 -1
  296. package/dist/transport/adapters/desktop-ax-helpers.d.ts +24 -0
  297. package/dist/transport/adapters/desktop-ax-helpers.d.ts.map +1 -0
  298. package/dist/transport/adapters/desktop-ax-helpers.js +190 -0
  299. package/dist/transport/adapters/desktop-ax-helpers.js.map +1 -0
  300. package/dist/transport/adapters/desktop-ax-swift.d.ts +13 -0
  301. package/dist/transport/adapters/desktop-ax-swift.d.ts.map +1 -1
  302. package/dist/transport/adapters/desktop-ax-swift.js +176 -2
  303. package/dist/transport/adapters/desktop-ax-swift.js.map +1 -1
  304. package/dist/transport/adapters/desktop-ax.d.ts +11 -2
  305. package/dist/transport/adapters/desktop-ax.d.ts.map +1 -1
  306. package/dist/transport/adapters/desktop-ax.js +131 -16
  307. package/dist/transport/adapters/desktop-ax.js.map +1 -1
  308. package/dist/transport/adapters/desktop-sidecar-errors.d.ts +3 -0
  309. package/dist/transport/adapters/desktop-sidecar-errors.d.ts.map +1 -0
  310. package/dist/transport/adapters/desktop-sidecar-errors.js +34 -0
  311. package/dist/transport/adapters/desktop-sidecar-errors.js.map +1 -0
  312. package/dist/transport/adapters/desktop-sidecar-snapshot.d.ts +10 -0
  313. package/dist/transport/adapters/desktop-sidecar-snapshot.d.ts.map +1 -0
  314. package/dist/transport/adapters/desktop-sidecar-snapshot.js +89 -0
  315. package/dist/transport/adapters/desktop-sidecar-snapshot.js.map +1 -0
  316. package/dist/transport/adapters/desktop-uia.d.ts +23 -17
  317. package/dist/transport/adapters/desktop-uia.d.ts.map +1 -1
  318. package/dist/transport/adapters/desktop-uia.js +142 -32
  319. package/dist/transport/adapters/desktop-uia.js.map +1 -1
  320. package/dist/transport/adapters/subprocess.d.ts +7 -0
  321. package/dist/transport/adapters/subprocess.d.ts.map +1 -1
  322. package/dist/transport/adapters/subprocess.js +64 -0
  323. package/dist/transport/adapters/subprocess.js.map +1 -1
  324. package/dist/transport/bus.d.ts +2 -0
  325. package/dist/transport/bus.d.ts.map +1 -1
  326. package/dist/transport/bus.js +7 -11
  327. package/dist/transport/bus.js.map +1 -1
  328. package/dist/transport/capability.d.ts.map +1 -1
  329. package/dist/transport/capability.js +123 -98
  330. package/dist/transport/capability.js.map +1 -1
  331. package/dist/transport/cascade.d.ts +5 -0
  332. package/dist/transport/cascade.d.ts.map +1 -0
  333. package/dist/transport/cascade.js +550 -0
  334. package/dist/transport/cascade.js.map +1 -0
  335. package/dist/transport/cdp-session.d.ts +11 -0
  336. package/dist/transport/cdp-session.d.ts.map +1 -0
  337. package/dist/transport/cdp-session.js +52 -0
  338. package/dist/transport/cdp-session.js.map +1 -0
  339. package/dist/transport/refs.d.ts +51 -0
  340. package/dist/transport/refs.d.ts.map +1 -0
  341. package/dist/transport/refs.js +135 -0
  342. package/dist/transport/refs.js.map +1 -0
  343. package/dist/transport/sidecar-binary.d.ts +18 -0
  344. package/dist/transport/sidecar-binary.d.ts.map +1 -0
  345. package/dist/transport/sidecar-binary.js +55 -0
  346. package/dist/transport/sidecar-binary.js.map +1 -0
  347. package/dist/transport/sidecar.d.ts +35 -0
  348. package/dist/transport/sidecar.d.ts.map +1 -0
  349. package/dist/transport/sidecar.js +134 -0
  350. package/dist/transport/sidecar.js.map +1 -0
  351. package/dist/transport/snapshot-encoder.d.ts +34 -0
  352. package/dist/transport/snapshot-encoder.d.ts.map +1 -0
  353. package/dist/transport/snapshot-encoder.js +139 -0
  354. package/dist/transport/snapshot-encoder.js.map +1 -0
  355. package/dist/transport/types.d.ts +6 -1
  356. package/dist/transport/types.d.ts.map +1 -1
  357. package/dist/types.d.ts +11 -1
  358. package/dist/types.d.ts.map +1 -1
  359. package/dist/types.js.map +1 -1
  360. package/docs/mcp/clients/claude-code.md +29 -0
  361. package/docs/mcp/clients/claude-desktop.md +47 -0
  362. package/docs/mcp/clients/codex.md +29 -0
  363. package/docs/mcp/clients/cursor.md +38 -0
  364. package/docs/mcp/clients/gemini-cli.md +38 -0
  365. package/docs/operate/compute.md +172 -0
  366. package/docs/operate/electron.md +87 -0
  367. package/docs/operate/focus-behavior.md +40 -0
  368. package/docs/operate/troubleshooting.md +379 -0
  369. package/package.json +44 -19
  370. package/src/adapters/36kr/news.yaml +4 -1
  371. package/src/adapters/_electron/desktop-shared.ts +14 -0
  372. package/src/adapters/_electron/shared.ts +54 -2
  373. package/src/adapters/juejin/hot.test.ts +25 -0
  374. package/src/adapters/juejin/hot.yaml +52 -0
  375. package/src/adapters/juejin/search.test.ts +27 -0
  376. package/src/adapters/juejin/search.yaml +58 -0
  377. package/src/adapters/leetcode/discuss-search.test.ts +29 -0
  378. package/src/adapters/leetcode/discuss-search.yaml +56 -0
  379. package/src/adapters/macos/actions.ts +63 -0
@@ -0,0 +1,2319 @@
1
+ use std::collections::BTreeMap;
2
+ use std::env;
3
+ use std::fs;
4
+ #[cfg(target_os = "linux")]
5
+ use std::future::Future;
6
+ use std::path::PathBuf;
7
+ #[cfg(target_os = "linux")]
8
+ use std::pin::Pin;
9
+ use std::process::Command;
10
+ use std::thread::sleep;
11
+ use std::time::{Duration, Instant};
12
+
13
+ use serde_json::Value;
14
+ use unicli_shared::SidecarRequest;
15
+
16
+ use crate::errors::{backend_unavailable, AtspiError, HandlerResult};
17
+ use crate::refs::RefTable;
18
+
19
+ #[derive(Default)]
20
+ pub struct State {
21
+ refs: RefTable,
22
+ }
23
+
24
+ impl State {
25
+ pub fn new() -> Self {
26
+ Self::default()
27
+ }
28
+
29
+ pub fn refs_mut(&mut self) -> &mut RefTable {
30
+ &mut self.refs
31
+ }
32
+ }
33
+
34
+ pub fn handle_apps(_state: &mut State, _request: &SidecarRequest) -> HandlerResult {
35
+ if !cfg!(target_os = "linux") {
36
+ return Err(backend_unavailable());
37
+ }
38
+ let windows = enumerate_top_level_windows()?;
39
+ Ok(apps_response_from_windows(&windows))
40
+ }
41
+
42
+ pub fn handle_windows(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
43
+ if !cfg!(target_os = "linux") {
44
+ return Err(backend_unavailable());
45
+ }
46
+ let windows = enumerate_top_level_windows()?;
47
+ Ok(windows_response_from_windows(&windows, &request.params))
48
+ }
49
+
50
+ pub fn handle_snapshot(state: &mut State, request: &SidecarRequest) -> HandlerResult {
51
+ state.refs_mut().clear();
52
+ if !cfg!(target_os = "linux") {
53
+ return Err(backend_unavailable());
54
+ }
55
+ let windows = enumerate_top_level_windows()?;
56
+ Ok(snapshot_response_from_windows(&windows, &request.params))
57
+ }
58
+
59
+ pub fn handle_find(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
60
+ if !cfg!(target_os = "linux") {
61
+ return Err(backend_unavailable());
62
+ }
63
+ let windows = enumerate_top_level_windows()?;
64
+ find_response_from_windows(&windows, &request.params)
65
+ }
66
+
67
+ pub fn handle_wait(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
68
+ if !cfg!(target_os = "linux") {
69
+ return Err(backend_unavailable());
70
+ }
71
+
72
+ let timeout = read_timeout(&request.params);
73
+ let poll_interval = read_poll_interval(&request.params);
74
+ let started = Instant::now();
75
+
76
+ loop {
77
+ let windows = enumerate_top_level_windows()?;
78
+ if let Ok(response) = wait_response_from_windows(&windows, &request.params) {
79
+ return Ok(response);
80
+ }
81
+
82
+ if started.elapsed() >= timeout {
83
+ return Err(AtspiError::no_element("top-level window wait"));
84
+ }
85
+
86
+ sleep(poll_interval.min(timeout.saturating_sub(started.elapsed())));
87
+ }
88
+ }
89
+
90
+ pub fn handle_observe(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
91
+ if !cfg!(target_os = "linux") {
92
+ return Err(backend_unavailable());
93
+ }
94
+ let windows = enumerate_top_level_windows()?;
95
+ Ok(observe_response_from_windows(&windows, &request.params))
96
+ }
97
+
98
+ pub fn handle_assert(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
99
+ if !cfg!(target_os = "linux") {
100
+ return Err(backend_unavailable());
101
+ }
102
+ let windows = enumerate_top_level_windows()?;
103
+ assert_response_from_windows(&windows, &request.params)
104
+ }
105
+
106
+ #[derive(Debug, Clone, PartialEq, Eq)]
107
+ pub(crate) struct WindowRecord {
108
+ pub(crate) id: String,
109
+ pub(crate) pid: u32,
110
+ pub(crate) title: String,
111
+ pub(crate) desktop: String,
112
+ pub(crate) host: String,
113
+ pub(crate) bounds: Option<WindowBounds>,
114
+ pub(crate) children: Vec<ElementRecord>,
115
+ }
116
+
117
+ #[derive(Debug, Clone, PartialEq, Eq)]
118
+ pub(crate) struct WindowBounds {
119
+ pub(crate) x: i32,
120
+ pub(crate) y: i32,
121
+ pub(crate) width: u32,
122
+ pub(crate) height: u32,
123
+ }
124
+
125
+ #[derive(Debug, Clone, PartialEq, Eq)]
126
+ pub(crate) struct ElementRecord {
127
+ pub(crate) role: String,
128
+ pub(crate) name: String,
129
+ pub(crate) value: Option<String>,
130
+ pub(crate) bounds: Option<ElementBounds>,
131
+ pub(crate) states: Vec<String>,
132
+ pub(crate) children: Vec<ElementRecord>,
133
+ }
134
+
135
+ #[derive(Debug, Clone, PartialEq, Eq)]
136
+ pub(crate) struct ElementBounds {
137
+ pub(crate) x: i32,
138
+ pub(crate) y: i32,
139
+ pub(crate) width: u32,
140
+ pub(crate) height: u32,
141
+ }
142
+
143
+ #[cfg(any(target_os = "linux", test))]
144
+ #[derive(Debug, Clone, PartialEq, Eq)]
145
+ struct LiveAtspiNode {
146
+ role: String,
147
+ name: String,
148
+ value: Option<String>,
149
+ bounds: Option<ElementBounds>,
150
+ states: Vec<String>,
151
+ children: Vec<LiveAtspiNode>,
152
+ }
153
+
154
+ pub(crate) fn enumerate_top_level_windows() -> Result<Vec<WindowRecord>, AtspiError> {
155
+ if !command_exists("wmctrl") {
156
+ return enumerate_windows_from_atspi_only().ok_or_else(|| {
157
+ AtspiError::unavailable("wmctrl is required for AT-SPI top-level app/window inventory")
158
+ });
159
+ }
160
+ let output = Command::new("wmctrl")
161
+ .args(["-lG", "-p"])
162
+ .output()
163
+ .map_err(|err| AtspiError::unavailable(format!("failed to run wmctrl -lG -p: {err}")))?;
164
+ if !output.status.success() {
165
+ return Err(AtspiError::unavailable(format!(
166
+ "wmctrl -lG -p exited with status {}",
167
+ output.status
168
+ )));
169
+ }
170
+ let stdout = String::from_utf8_lossy(&output.stdout);
171
+ let mut windows = parse_wmctrl_windows(&stdout);
172
+ if windows.is_empty() {
173
+ if let Some(atspi_windows) = enumerate_windows_from_atspi_only() {
174
+ return Ok(atspi_windows);
175
+ }
176
+ }
177
+ if !windows.is_empty() {
178
+ populate_live_atspi_descendants_best_effort(&mut windows);
179
+ }
180
+ Ok(windows)
181
+ }
182
+
183
+ fn parse_wmctrl_windows(output: &str) -> Vec<WindowRecord> {
184
+ output
185
+ .lines()
186
+ .filter_map(parse_wmctrl_window_line)
187
+ .collect()
188
+ }
189
+
190
+ fn parse_wmctrl_window_line(line: &str) -> Option<WindowRecord> {
191
+ let mut fields = line.split_whitespace();
192
+ let id = fields.next()?.to_string();
193
+ let desktop = fields.next()?.to_string();
194
+ let pid = fields.next()?.parse::<u32>().ok()?;
195
+ let next = fields.next()?;
196
+ let (bounds, host) = if let Ok(x) = next.parse::<i32>() {
197
+ let y = fields.next()?.parse::<i32>().ok()?;
198
+ let width = fields.next()?.parse::<u32>().ok()?;
199
+ let height = fields.next()?.parse::<u32>().ok()?;
200
+ let host = fields.next()?.to_string();
201
+ (
202
+ Some(WindowBounds {
203
+ x,
204
+ y,
205
+ width,
206
+ height,
207
+ }),
208
+ host,
209
+ )
210
+ } else {
211
+ (None, next.to_string())
212
+ };
213
+ let title = fields.collect::<Vec<_>>().join(" ");
214
+ if title.is_empty() {
215
+ return None;
216
+ }
217
+ Some(WindowRecord {
218
+ id,
219
+ pid,
220
+ title,
221
+ desktop,
222
+ host,
223
+ bounds,
224
+ children: Vec::new(),
225
+ })
226
+ }
227
+
228
+ #[cfg(target_os = "linux")]
229
+ fn enumerate_windows_from_atspi_only() -> Option<Vec<WindowRecord>> {
230
+ match futures_lite::future::block_on(collect_all_live_atspi_window_roots()) {
231
+ Ok(roots) => {
232
+ let windows = window_records_from_live_roots(roots);
233
+ (!windows.is_empty()).then_some(windows)
234
+ }
235
+ Err(err) => {
236
+ tracing::debug!(?err, "skipping AT-SPI-only top-level inventory");
237
+ None
238
+ }
239
+ }
240
+ }
241
+
242
+ #[cfg(not(target_os = "linux"))]
243
+ fn enumerate_windows_from_atspi_only() -> Option<Vec<WindowRecord>> {
244
+ None
245
+ }
246
+
247
+ #[cfg(any(target_os = "linux", test))]
248
+ fn window_records_from_live_roots(roots: Vec<LiveAtspiNode>) -> Vec<WindowRecord> {
249
+ roots
250
+ .into_iter()
251
+ .enumerate()
252
+ .filter_map(|(index, root)| {
253
+ if !is_live_window_role(&root.role) || root.name.is_empty() {
254
+ return None;
255
+ }
256
+ Some(WindowRecord {
257
+ id: format!("atspi-root-{index}"),
258
+ pid: u32::MAX.saturating_sub(index as u32),
259
+ title: root.name,
260
+ desktop: "atspi".into(),
261
+ host: "atspi".into(),
262
+ bounds: root.bounds.map(|bounds| WindowBounds {
263
+ x: bounds.x,
264
+ y: bounds.y,
265
+ width: bounds.width,
266
+ height: bounds.height,
267
+ }),
268
+ children: root
269
+ .children
270
+ .iter()
271
+ .map(element_record_from_live_node)
272
+ .collect(),
273
+ })
274
+ })
275
+ .collect()
276
+ }
277
+
278
+ #[cfg(any(target_os = "linux", test))]
279
+ fn is_live_window_role(role: &str) -> bool {
280
+ matches!(
281
+ normalize_atspi_label(role).as_str(),
282
+ "frame" | "window" | "dialog"
283
+ )
284
+ }
285
+
286
+ #[cfg(any(target_os = "linux", test))]
287
+ fn populate_window_descendants_from_live_roots(
288
+ windows: &mut [WindowRecord],
289
+ roots: Vec<LiveAtspiNode>,
290
+ ) {
291
+ for window in windows {
292
+ let Some(root) = roots
293
+ .iter()
294
+ .find(|candidate| live_node_matches_window(candidate, window))
295
+ else {
296
+ continue;
297
+ };
298
+ if window.bounds.is_none() {
299
+ window.bounds = root.bounds.as_ref().map(|bounds| WindowBounds {
300
+ x: bounds.x,
301
+ y: bounds.y,
302
+ width: bounds.width,
303
+ height: bounds.height,
304
+ });
305
+ }
306
+ window.children = root
307
+ .children
308
+ .iter()
309
+ .map(element_record_from_live_node)
310
+ .collect();
311
+ }
312
+ }
313
+
314
+ #[cfg(any(target_os = "linux", test))]
315
+ fn live_node_matches_window(node: &LiveAtspiNode, window: &WindowRecord) -> bool {
316
+ is_live_window_role(&node.role)
317
+ && (node.name == window.title
318
+ || (!node.name.is_empty() && window.title.contains(&node.name))
319
+ || (!window.title.is_empty() && node.name.contains(&window.title)))
320
+ }
321
+
322
+ #[cfg(any(target_os = "linux", test))]
323
+ fn element_record_from_live_node(node: &LiveAtspiNode) -> ElementRecord {
324
+ ElementRecord {
325
+ role: normalize_atspi_label(&node.role),
326
+ name: node.name.clone(),
327
+ value: node.value.clone(),
328
+ bounds: node.bounds.clone(),
329
+ states: node
330
+ .states
331
+ .iter()
332
+ .map(|state| normalize_atspi_label(state))
333
+ .filter(|state| !state.is_empty())
334
+ .collect(),
335
+ children: node
336
+ .children
337
+ .iter()
338
+ .map(element_record_from_live_node)
339
+ .collect(),
340
+ }
341
+ }
342
+
343
+ #[cfg(any(target_os = "linux", test))]
344
+ fn normalize_atspi_label(value: &str) -> String {
345
+ let mut normalized = String::new();
346
+ let mut previous_was_separator = false;
347
+ let mut previous_was_lower_or_digit = false;
348
+
349
+ for character in value.chars() {
350
+ if character.is_ascii_uppercase() {
351
+ if previous_was_lower_or_digit && !previous_was_separator {
352
+ normalized.push('_');
353
+ }
354
+ normalized.push(character.to_ascii_lowercase());
355
+ previous_was_separator = false;
356
+ previous_was_lower_or_digit = true;
357
+ } else if character.is_ascii_alphanumeric() {
358
+ normalized.push(character.to_ascii_lowercase());
359
+ previous_was_separator = false;
360
+ previous_was_lower_or_digit =
361
+ character.is_ascii_lowercase() || character.is_ascii_digit();
362
+ } else if !normalized.is_empty() && !previous_was_separator {
363
+ normalized.push('_');
364
+ previous_was_separator = true;
365
+ previous_was_lower_or_digit = false;
366
+ }
367
+ }
368
+
369
+ normalized.trim_matches('_').to_string()
370
+ }
371
+
372
+ #[cfg(target_os = "linux")]
373
+ fn populate_live_atspi_descendants_best_effort(windows: &mut [WindowRecord]) {
374
+ match futures_lite::future::block_on(collect_live_atspi_window_roots(windows)) {
375
+ Ok(roots) => populate_window_descendants_from_live_roots(windows, roots),
376
+ Err(err) => tracing::debug!(?err, "skipping live AT-SPI descendant population"),
377
+ }
378
+ }
379
+
380
+ #[cfg(not(target_os = "linux"))]
381
+ fn populate_live_atspi_descendants_best_effort(_windows: &mut [WindowRecord]) {}
382
+
383
+ #[cfg(target_os = "linux")]
384
+ async fn collect_live_atspi_window_roots(
385
+ windows: &[WindowRecord],
386
+ ) -> Result<Vec<LiveAtspiNode>, atspi::AtspiError> {
387
+ use atspi::proxy::accessible::ObjectRefExt;
388
+ use std::collections::VecDeque;
389
+
390
+ const MAX_SEARCH_NODES: usize = 4_000;
391
+ const MAX_SEARCH_DEPTH: usize = 8;
392
+ const MAX_CHILD_DEPTH: usize = 8;
393
+ const MAX_CHILD_NODES: usize = 2_000;
394
+
395
+ let connection = atspi::AccessibilityConnection::new().await?;
396
+ let root = connection.root_accessible_on_registry().await?;
397
+ let conn = connection.connection();
398
+ let mut queue: VecDeque<_> = root
399
+ .get_children()
400
+ .await?
401
+ .into_iter()
402
+ .map(|object| (object, 0usize))
403
+ .collect();
404
+ let mut visited = 0usize;
405
+ let mut roots = Vec::new();
406
+
407
+ while let Some((object_ref, depth)) = queue.pop_front() {
408
+ if visited >= MAX_SEARCH_NODES || roots.len() >= windows.len() {
409
+ break;
410
+ }
411
+ visited += 1;
412
+
413
+ let accessible = match object_ref.into_accessible_proxy(conn).await {
414
+ Ok(accessible) => accessible,
415
+ Err(_) => continue,
416
+ };
417
+ let role = live_accessible_role(&accessible).await;
418
+ let name = accessible.name().await.unwrap_or_default();
419
+
420
+ if live_accessible_matches_window(&role, &name, windows) {
421
+ let mut budget = MAX_CHILD_NODES;
422
+ roots.push(
423
+ live_node_from_accessible(
424
+ conn,
425
+ accessible,
426
+ role,
427
+ name,
428
+ MAX_CHILD_DEPTH,
429
+ &mut budget,
430
+ )
431
+ .await?,
432
+ );
433
+ continue;
434
+ }
435
+
436
+ if depth >= MAX_SEARCH_DEPTH {
437
+ continue;
438
+ }
439
+ for child_ref in accessible.get_children().await.unwrap_or_default() {
440
+ queue.push_back((child_ref, depth + 1));
441
+ }
442
+ }
443
+
444
+ Ok(roots)
445
+ }
446
+
447
+ #[cfg(target_os = "linux")]
448
+ async fn collect_all_live_atspi_window_roots() -> Result<Vec<LiveAtspiNode>, atspi::AtspiError> {
449
+ use atspi::proxy::accessible::ObjectRefExt;
450
+ use std::collections::VecDeque;
451
+
452
+ const MAX_SEARCH_NODES: usize = 4_000;
453
+ const MAX_SEARCH_DEPTH: usize = 8;
454
+ const MAX_CHILD_DEPTH: usize = 8;
455
+ const MAX_CHILD_NODES: usize = 2_000;
456
+
457
+ let connection = atspi::AccessibilityConnection::new().await?;
458
+ let root = connection.root_accessible_on_registry().await?;
459
+ let conn = connection.connection();
460
+ let mut queue: VecDeque<_> = root
461
+ .get_children()
462
+ .await?
463
+ .into_iter()
464
+ .map(|object| (object, 0usize))
465
+ .collect();
466
+ let mut visited = 0usize;
467
+ let mut roots = Vec::new();
468
+
469
+ while let Some((object_ref, depth)) = queue.pop_front() {
470
+ if visited >= MAX_SEARCH_NODES {
471
+ break;
472
+ }
473
+ visited += 1;
474
+
475
+ let accessible = match object_ref.into_accessible_proxy(conn).await {
476
+ Ok(accessible) => accessible,
477
+ Err(_) => continue,
478
+ };
479
+ let role = live_accessible_role(&accessible).await;
480
+ let name = accessible.name().await.unwrap_or_default();
481
+
482
+ if is_live_window_role(&role) {
483
+ let mut budget = MAX_CHILD_NODES;
484
+ roots.push(
485
+ live_node_from_accessible(
486
+ conn,
487
+ accessible,
488
+ role,
489
+ name,
490
+ MAX_CHILD_DEPTH,
491
+ &mut budget,
492
+ )
493
+ .await?,
494
+ );
495
+ continue;
496
+ }
497
+
498
+ if depth >= MAX_SEARCH_DEPTH {
499
+ continue;
500
+ }
501
+ for child_ref in accessible.get_children().await.unwrap_or_default() {
502
+ queue.push_back((child_ref, depth + 1));
503
+ }
504
+ }
505
+
506
+ Ok(roots)
507
+ }
508
+
509
+ #[cfg(target_os = "linux")]
510
+ async fn live_accessible_role(
511
+ accessible: &atspi::proxy::accessible::AccessibleProxy<'_>,
512
+ ) -> String {
513
+ match accessible.get_role().await {
514
+ Ok(role) => role.name().to_string(),
515
+ Err(_) => accessible.get_role_name().await.unwrap_or_default(),
516
+ }
517
+ }
518
+
519
+ #[cfg(target_os = "linux")]
520
+ fn live_accessible_matches_window(role: &str, name: &str, windows: &[WindowRecord]) -> bool {
521
+ let node = LiveAtspiNode {
522
+ role: role.to_string(),
523
+ name: name.to_string(),
524
+ value: None,
525
+ bounds: None,
526
+ states: Vec::new(),
527
+ children: Vec::new(),
528
+ };
529
+ windows
530
+ .iter()
531
+ .any(|window| live_node_matches_window(&node, window))
532
+ }
533
+
534
+ #[cfg(target_os = "linux")]
535
+ fn live_node_from_accessible<'a>(
536
+ conn: &'a zbus::Connection,
537
+ accessible: atspi::proxy::accessible::AccessibleProxy<'a>,
538
+ role: String,
539
+ name: String,
540
+ depth_remaining: usize,
541
+ budget: &'a mut usize,
542
+ ) -> Pin<Box<dyn Future<Output = Result<LiveAtspiNode, atspi::AtspiError>> + Send + 'a>> {
543
+ use atspi::proxy::accessible::ObjectRefExt;
544
+ use atspi::proxy::proxy_ext::ProxyExt;
545
+
546
+ Box::pin(async move {
547
+ if *budget == 0 {
548
+ return Ok(LiveAtspiNode {
549
+ role,
550
+ name,
551
+ value: None,
552
+ bounds: None,
553
+ states: Vec::new(),
554
+ children: Vec::new(),
555
+ });
556
+ }
557
+ *budget -= 1;
558
+
559
+ let proxies = accessible.proxies().await.ok();
560
+ let bounds = if let Some(proxies) = &proxies {
561
+ match proxies.component().await {
562
+ Ok(component) => component
563
+ .get_extents(atspi::CoordType::Screen)
564
+ .await
565
+ .ok()
566
+ .and_then(|(x, y, width, height)| element_bounds_from_i32(x, y, width, height)),
567
+ Err(_) => None,
568
+ }
569
+ } else {
570
+ None
571
+ };
572
+ let value = live_accessible_value(&proxies).await;
573
+ let states = accessible
574
+ .get_state()
575
+ .await
576
+ .map(|states| {
577
+ states
578
+ .into_iter()
579
+ .map(|state| format!("{state:?}"))
580
+ .collect::<Vec<_>>()
581
+ })
582
+ .unwrap_or_default();
583
+ let mut children = Vec::new();
584
+
585
+ if depth_remaining > 0 && *budget > 0 {
586
+ for child_ref in accessible.get_children().await.unwrap_or_default() {
587
+ if *budget == 0 {
588
+ break;
589
+ }
590
+ let child = match child_ref.into_accessible_proxy(conn).await {
591
+ Ok(child) => child,
592
+ Err(_) => continue,
593
+ };
594
+ let child_role = live_accessible_role(&child).await;
595
+ let child_name = child.name().await.unwrap_or_default();
596
+ children.push(
597
+ live_node_from_accessible(
598
+ conn,
599
+ child,
600
+ child_role,
601
+ child_name,
602
+ depth_remaining - 1,
603
+ budget,
604
+ )
605
+ .await?,
606
+ );
607
+ }
608
+ }
609
+
610
+ Ok(LiveAtspiNode {
611
+ role,
612
+ name,
613
+ value,
614
+ bounds,
615
+ states,
616
+ children,
617
+ })
618
+ })
619
+ }
620
+
621
+ #[cfg(target_os = "linux")]
622
+ async fn live_accessible_value(
623
+ proxies: &Option<atspi::proxy::proxy_ext::Proxies<'_>>,
624
+ ) -> Option<String> {
625
+ if let Some(proxies) = proxies {
626
+ if let Ok(text) = proxies.text().await {
627
+ if let Ok(count) = text.character_count().await {
628
+ if count > 0 {
629
+ if let Ok(value) = text.get_text(0, count.min(4096)).await {
630
+ if !value.is_empty() {
631
+ return Some(value);
632
+ }
633
+ }
634
+ }
635
+ }
636
+ }
637
+ if let Ok(value) = proxies.value().await {
638
+ if let Ok(text) = value.text().await {
639
+ if !text.is_empty() {
640
+ return Some(text);
641
+ }
642
+ }
643
+ if let Ok(current) = value.current_value().await {
644
+ return Some(current.to_string());
645
+ }
646
+ }
647
+ }
648
+ None
649
+ }
650
+
651
+ #[cfg(target_os = "linux")]
652
+ fn element_bounds_from_i32(x: i32, y: i32, width: i32, height: i32) -> Option<ElementBounds> {
653
+ Some(ElementBounds {
654
+ x,
655
+ y,
656
+ width: width.try_into().ok()?,
657
+ height: height.try_into().ok()?,
658
+ })
659
+ }
660
+
661
+ fn apps_response_from_windows(windows: &[WindowRecord]) -> Value {
662
+ let mut by_pid: BTreeMap<u32, (&str, usize)> = BTreeMap::new();
663
+ for window in windows {
664
+ by_pid
665
+ .entry(window.pid)
666
+ .and_modify(|(_, count)| *count += 1)
667
+ .or_insert((window.title.as_str(), 1));
668
+ }
669
+
670
+ let mut apps: Vec<Value> = by_pid
671
+ .into_iter()
672
+ .map(|(pid, (name, window_count))| {
673
+ serde_json::json!({
674
+ "name": name,
675
+ "pid": pid,
676
+ "windowCount": window_count,
677
+ })
678
+ })
679
+ .collect();
680
+ apps.sort_by(|left, right| {
681
+ let left_name = left
682
+ .get("name")
683
+ .and_then(Value::as_str)
684
+ .unwrap_or_default()
685
+ .to_ascii_lowercase();
686
+ let right_name = right
687
+ .get("name")
688
+ .and_then(Value::as_str)
689
+ .unwrap_or_default()
690
+ .to_ascii_lowercase();
691
+ left_name.cmp(&right_name)
692
+ });
693
+
694
+ serde_json::json!({
695
+ "mode": "apps",
696
+ "count": apps.len(),
697
+ "apps": apps,
698
+ })
699
+ }
700
+
701
+ fn windows_response_from_windows(windows: &[WindowRecord], params: &Value) -> Value {
702
+ let windows: Vec<Value> = windows
703
+ .iter()
704
+ .filter(|window| window_matches_params(window, params))
705
+ .map(|window| {
706
+ serde_json::json!({
707
+ "id": window.id,
708
+ "name": window.title,
709
+ "title": window.title,
710
+ "pid": window.pid,
711
+ "desktop": window.desktop,
712
+ "host": window.host,
713
+ "visible": true,
714
+ })
715
+ })
716
+ .collect();
717
+
718
+ serde_json::json!({
719
+ "mode": "windows",
720
+ "count": windows.len(),
721
+ "windows": windows,
722
+ })
723
+ }
724
+
725
+ fn snapshot_response_from_windows(windows: &[WindowRecord], params: &Value) -> Value {
726
+ let children: Vec<Value> = windows
727
+ .iter()
728
+ .filter(|window| window_matches_params(window, params))
729
+ .map(|window| {
730
+ let index = pid_local_window_index(windows, window);
731
+ window_node(window, index, true)
732
+ })
733
+ .collect();
734
+
735
+ serde_json::json!({
736
+ "role": "Desktop",
737
+ "name": "Linux Desktop",
738
+ "path": "Desktop[0]",
739
+ "scope": "desktop",
740
+ "children": children,
741
+ })
742
+ }
743
+
744
+ fn find_response_from_windows(windows: &[WindowRecord], params: &Value) -> HandlerResult {
745
+ let mut matches = Vec::new();
746
+ for window in windows
747
+ .iter()
748
+ .filter(|window| window_matches_params(window, params))
749
+ {
750
+ let index = pid_local_window_index(windows, window);
751
+ if window_node_matches_find_params(window, params) {
752
+ matches.push(window_node(window, index, true));
753
+ }
754
+ collect_descendant_matches(&mut matches, window, index, params);
755
+ }
756
+
757
+ if params.get("first").and_then(Value::as_bool) == Some(true) {
758
+ return matches
759
+ .into_iter()
760
+ .next()
761
+ .ok_or_else(|| AtspiError::no_element("top-level window query"));
762
+ }
763
+
764
+ Ok(serde_json::json!(matches))
765
+ }
766
+
767
+ fn wait_response_from_windows(windows: &[WindowRecord], params: &Value) -> HandlerResult {
768
+ let (via, node) = first_matching_node(windows, params)
769
+ .ok_or_else(|| AtspiError::no_element("top-level window query"))?;
770
+
771
+ Ok(serde_json::json!({
772
+ "matched": true,
773
+ "via": via,
774
+ "node": node,
775
+ }))
776
+ }
777
+
778
+ fn observe_response_from_windows(windows: &[WindowRecord], params: &Value) -> Value {
779
+ let goal = params
780
+ .get("goal")
781
+ .and_then(Value::as_str)
782
+ .unwrap_or_default();
783
+ let top_k = read_top_k(params);
784
+ let mut candidates: Vec<Value> = Vec::new();
785
+ for window in windows
786
+ .iter()
787
+ .filter(|window| window_matches_params(window, params))
788
+ {
789
+ if let Some((confidence, reason)) = score_window_for_goal(window, goal) {
790
+ let index = pid_local_window_index(windows, window);
791
+ let stable = window_stable(window, index);
792
+ candidates.push(serde_json::json!({
793
+ "action": "click",
794
+ "ref": stable,
795
+ "stable": stable,
796
+ "role": "Window",
797
+ "name": window.title,
798
+ "confidence": confidence,
799
+ "reason": reason,
800
+ }));
801
+ }
802
+
803
+ let index = pid_local_window_index(windows, window);
804
+ collect_descendant_observe_candidates(&mut candidates, window, index, goal);
805
+ }
806
+
807
+ candidates.sort_by(|left, right| {
808
+ let left_confidence = left
809
+ .get("confidence")
810
+ .and_then(Value::as_f64)
811
+ .unwrap_or_default();
812
+ let right_confidence = right
813
+ .get("confidence")
814
+ .and_then(Value::as_f64)
815
+ .unwrap_or_default();
816
+ right_confidence
817
+ .total_cmp(&left_confidence)
818
+ .then_with(|| candidate_name(left).cmp(&candidate_name(right)))
819
+ });
820
+ candidates.truncate(top_k);
821
+
822
+ serde_json::json!({
823
+ "goal": goal,
824
+ "count": candidates.len(),
825
+ "candidates": candidates,
826
+ })
827
+ }
828
+
829
+ fn assert_response_from_windows(windows: &[WindowRecord], params: &Value) -> HandlerResult {
830
+ if let Some(stable) = stable_param(params) {
831
+ if !stable.starts_with("desktop-atspi:") {
832
+ return Err(AtspiError::invalid_input(
833
+ "atspi_assert requires a desktop-atspi stable ref when ref is provided",
834
+ ));
835
+ }
836
+ let (via, node) = assert_target_ref_node(windows, stable, params)
837
+ .ok_or_else(|| AtspiError::no_element(stable.to_string()))?;
838
+
839
+ return Ok(serde_json::json!({
840
+ "asserted": true,
841
+ "via": via,
842
+ "checks": assertion_checks(params),
843
+ "node": node,
844
+ }));
845
+ }
846
+
847
+ let (via, node) = first_assertion_node(windows, params)
848
+ .ok_or_else(|| AtspiError::no_element("top-level window assertion"))?;
849
+
850
+ Ok(serde_json::json!({
851
+ "asserted": true,
852
+ "via": via,
853
+ "checks": assertion_checks(params),
854
+ "node": node,
855
+ }))
856
+ }
857
+
858
+ fn stable_param(params: &Value) -> Option<&str> {
859
+ params
860
+ .get("stable")
861
+ .or_else(|| params.get("ref"))
862
+ .and_then(Value::as_str)
863
+ }
864
+
865
+ pub(crate) fn resolve_top_level_window_ref<'a>(
866
+ windows: &'a [WindowRecord],
867
+ stable: &str,
868
+ ) -> Option<&'a WindowRecord> {
869
+ let (scope, path) = stable.strip_prefix("desktop-atspi:")?.split_once(':')?;
870
+ let pid = scope.strip_prefix("pid-")?.parse::<u32>().ok()?;
871
+ let index = path
872
+ .strip_prefix("Window[")?
873
+ .strip_suffix(']')?
874
+ .parse::<usize>()
875
+ .ok()?;
876
+
877
+ windows.iter().filter(|window| window.pid == pid).nth(index)
878
+ }
879
+
880
+ fn assert_target_ref_node(
881
+ windows: &[WindowRecord],
882
+ stable: &str,
883
+ params: &Value,
884
+ ) -> Option<(&'static str, Value)> {
885
+ if let Some(window) = resolve_top_level_window_ref(windows, stable) {
886
+ if window_satisfies_assertion(window, params) {
887
+ let index = pid_local_window_index(windows, window);
888
+ return Some((
889
+ "top_level_window_inventory",
890
+ window_node(window, index, true),
891
+ ));
892
+ }
893
+ return None;
894
+ }
895
+
896
+ let (window, element, path) = resolve_descendant_element_ref(windows, stable)?;
897
+ if element_matches_find_params(element, params) && element_state_filter_matches(element, params)
898
+ {
899
+ let scope = format!("pid-{}", window.pid);
900
+ return Some((
901
+ "native_descendant_tree",
902
+ element_node(
903
+ element,
904
+ &scope,
905
+ &window.title,
906
+ window.pid,
907
+ &path,
908
+ true,
909
+ false,
910
+ ),
911
+ ));
912
+ }
913
+ None
914
+ }
915
+
916
+ pub(crate) fn resolve_descendant_element_ref<'a>(
917
+ windows: &'a [WindowRecord],
918
+ stable: &str,
919
+ ) -> Option<(&'a WindowRecord, &'a ElementRecord, String)> {
920
+ let (scope, path) = stable.strip_prefix("desktop-atspi:")?.split_once(':')?;
921
+ let pid = scope.strip_prefix("pid-")?.parse::<u32>().ok()?;
922
+ let mut segments = path.split('/');
923
+ let (window_role, window_index) = parse_indexed_path_segment(segments.next()?)?;
924
+ if window_role != "Window" {
925
+ return None;
926
+ }
927
+ let window = windows
928
+ .iter()
929
+ .filter(|window| window.pid == pid)
930
+ .nth(window_index)?;
931
+ let mut resolved_path = format!("Window[{window_index}]");
932
+ let mut children = window.children.as_slice();
933
+ let mut current = None;
934
+
935
+ for segment in segments {
936
+ let (role, index) = parse_indexed_path_segment(segment)?;
937
+ let element = children
938
+ .iter()
939
+ .filter(|element| element.role == role)
940
+ .nth(index)?;
941
+ resolved_path.push('/');
942
+ resolved_path.push_str(segment);
943
+ children = element.children.as_slice();
944
+ current = Some(element);
945
+ }
946
+
947
+ current.map(|element| (window, element, resolved_path))
948
+ }
949
+
950
+ fn parse_indexed_path_segment(segment: &str) -> Option<(&str, usize)> {
951
+ let (role, raw_index) = segment.split_once('[')?;
952
+ let index = raw_index.strip_suffix(']')?.parse::<usize>().ok()?;
953
+ Some((role, index))
954
+ }
955
+
956
+ fn pid_local_window_index(windows: &[WindowRecord], target: &WindowRecord) -> usize {
957
+ windows
958
+ .iter()
959
+ .filter(|window| window.pid == target.pid)
960
+ .position(|window| window.id == target.id)
961
+ .unwrap_or(0)
962
+ }
963
+
964
+ fn window_node(window: &WindowRecord, index: usize, include_stable: bool) -> Value {
965
+ let path = format!("Window[{index}]");
966
+ let scope = format!("pid-{}", window.pid);
967
+ let mut node = serde_json::json!({
968
+ "role": "Window",
969
+ "name": window.title,
970
+ "path": path,
971
+ "scope": scope,
972
+ "app": window.title,
973
+ "pid": window.pid,
974
+ "states": ["visible"],
975
+ "metadata": {
976
+ "id": window.id,
977
+ "desktop": window.desktop,
978
+ "host": window.host,
979
+ },
980
+ });
981
+
982
+ if include_stable {
983
+ node["stable"] = serde_json::json!(window_stable(window, index));
984
+ }
985
+ if let Some(bounds) = &window.bounds {
986
+ node["bounds"] = serde_json::json!({
987
+ "x": bounds.x,
988
+ "y": bounds.y,
989
+ "width": bounds.width,
990
+ "height": bounds.height,
991
+ });
992
+ }
993
+ if !window.children.is_empty() {
994
+ node["children"] = serde_json::json!(element_nodes(
995
+ &window.children,
996
+ &scope,
997
+ &window.title,
998
+ window.pid,
999
+ &path,
1000
+ include_stable,
1001
+ ));
1002
+ }
1003
+
1004
+ node
1005
+ }
1006
+
1007
+ fn window_stable(window: &WindowRecord, index: usize) -> String {
1008
+ format!("desktop-atspi:pid-{}:Window[{index}]", window.pid)
1009
+ }
1010
+
1011
+ fn window_matches_params(window: &WindowRecord, params: &Value) -> bool {
1012
+ let pid_filter = params
1013
+ .get("pid")
1014
+ .and_then(Value::as_u64)
1015
+ .map(|pid| pid as u32);
1016
+ let app_filter = params
1017
+ .get("app")
1018
+ .and_then(Value::as_str)
1019
+ .map(|app| app.to_ascii_lowercase());
1020
+
1021
+ pid_filter.map_or(true, |pid| window.pid == pid)
1022
+ && app_filter
1023
+ .as_ref()
1024
+ .map_or(true, |app| window.title.to_ascii_lowercase().contains(app))
1025
+ }
1026
+
1027
+ fn window_matches_find_params(window: &WindowRecord, params: &Value) -> bool {
1028
+ window_matches_params(window, params) && window_node_matches_find_params(window, params)
1029
+ }
1030
+
1031
+ fn window_node_matches_find_params(window: &WindowRecord, params: &Value) -> bool {
1032
+ let role_filter = params
1033
+ .get("role")
1034
+ .and_then(Value::as_str)
1035
+ .map(|role| role.to_ascii_lowercase());
1036
+ let name_filter = params
1037
+ .get("name")
1038
+ .or_else(|| params.get("title"))
1039
+ .and_then(Value::as_str)
1040
+ .map(|name| name.to_ascii_lowercase());
1041
+
1042
+ role_filter
1043
+ .as_ref()
1044
+ .map_or(true, |role| role == "window" || role == "desktop-window")
1045
+ && name_filter.as_ref().map_or(true, |name| {
1046
+ window.title.to_ascii_lowercase().contains(name)
1047
+ })
1048
+ && text_matches(&window.title, None, params)
1049
+ }
1050
+
1051
+ fn window_satisfies_assertion(window: &WindowRecord, params: &Value) -> bool {
1052
+ window_matches_find_params(window, params)
1053
+ && text_filter_matches(window, params)
1054
+ && window_state_filter_matches(params)
1055
+ }
1056
+
1057
+ fn text_filter_matches(window: &WindowRecord, params: &Value) -> bool {
1058
+ text_matches(&window.title, None, params)
1059
+ }
1060
+
1061
+ fn window_state_filter_matches(params: &Value) -> bool {
1062
+ params
1063
+ .get("state")
1064
+ .and_then(Value::as_str)
1065
+ .map(|state| {
1066
+ matches!(
1067
+ state.to_ascii_lowercase().as_str(),
1068
+ "visible" | "appear" | "enabled"
1069
+ )
1070
+ })
1071
+ .unwrap_or(true)
1072
+ }
1073
+
1074
+ fn assertion_checks(params: &Value) -> Value {
1075
+ let mut checks = serde_json::Map::new();
1076
+ if let Some(text) = params.get("text").and_then(Value::as_str) {
1077
+ checks.insert("text".into(), serde_json::json!(text));
1078
+ }
1079
+ if let Some(state) = params.get("state").and_then(Value::as_str) {
1080
+ checks.insert("state".into(), serde_json::json!(state));
1081
+ }
1082
+ Value::Object(checks)
1083
+ }
1084
+
1085
+ fn read_timeout(params: &Value) -> Duration {
1086
+ Duration::from_millis(
1087
+ read_u64_param(params, &["timeoutMs", "timeout_ms", "timeout"], 10_000).clamp(1, 60_000),
1088
+ )
1089
+ }
1090
+
1091
+ fn read_poll_interval(params: &Value) -> Duration {
1092
+ Duration::from_millis(
1093
+ read_u64_param(
1094
+ params,
1095
+ &["pollMs", "poll_ms", "intervalMs", "interval_ms"],
1096
+ 100,
1097
+ )
1098
+ .clamp(10, 1_000),
1099
+ )
1100
+ }
1101
+
1102
+ fn read_top_k(params: &Value) -> usize {
1103
+ read_u64_param(params, &["topK", "top_k", "limit"], 5)
1104
+ .clamp(1, 50)
1105
+ .try_into()
1106
+ .unwrap_or(5)
1107
+ }
1108
+
1109
+ fn read_u64_param(params: &Value, names: &[&str], default: u64) -> u64 {
1110
+ names
1111
+ .iter()
1112
+ .find_map(|name| params.get(*name))
1113
+ .and_then(|value| {
1114
+ value
1115
+ .as_u64()
1116
+ .or_else(|| value.as_str().and_then(|text| text.parse::<u64>().ok()))
1117
+ })
1118
+ .unwrap_or(default)
1119
+ }
1120
+
1121
+ fn score_window_for_goal(window: &WindowRecord, goal: &str) -> Option<(f64, &'static str)> {
1122
+ score_label_for_goal(&window.title, goal, "title", Some("Window"))
1123
+ }
1124
+
1125
+ fn score_label_for_goal(
1126
+ label: &str,
1127
+ goal: &str,
1128
+ label_kind: &'static str,
1129
+ fallback_role: Option<&str>,
1130
+ ) -> Option<(f64, &'static str)> {
1131
+ let goal_tokens = tokenize(goal);
1132
+ if goal_tokens.is_empty() {
1133
+ return None;
1134
+ }
1135
+
1136
+ let normalized_goal = goal_tokens.join(" ");
1137
+ if label.to_ascii_lowercase().trim() == normalized_goal {
1138
+ return Some((
1139
+ 0.95,
1140
+ if label_kind == "name" {
1141
+ "exact name match"
1142
+ } else {
1143
+ "exact title match"
1144
+ },
1145
+ ));
1146
+ }
1147
+
1148
+ let label_tokens = tokenize(label);
1149
+ let matched = goal_tokens
1150
+ .iter()
1151
+ .filter(|query| token_matches_any(query, &label_tokens))
1152
+ .count();
1153
+ if matched == goal_tokens.len() {
1154
+ return Some((
1155
+ 0.85,
1156
+ if label_kind == "name" {
1157
+ "all goal tokens in name"
1158
+ } else {
1159
+ "all goal tokens in title"
1160
+ },
1161
+ ));
1162
+ }
1163
+ if matched > 0 {
1164
+ let confidence = 0.4 + (matched as f64 / goal_tokens.len() as f64) * 0.4;
1165
+ return Some((
1166
+ round_confidence(confidence),
1167
+ if label_kind == "name" {
1168
+ "some goal tokens in name"
1169
+ } else {
1170
+ "some goal tokens in title"
1171
+ },
1172
+ ));
1173
+ }
1174
+ if let Some(role) = fallback_role {
1175
+ if goal_tokens
1176
+ .iter()
1177
+ .any(|token| token == &role.to_ascii_lowercase())
1178
+ {
1179
+ return Some((0.1, "role match"));
1180
+ }
1181
+ }
1182
+
1183
+ None
1184
+ }
1185
+
1186
+ fn token_matches_any(query: &str, tokens: &[String]) -> bool {
1187
+ tokens.iter().any(|label| {
1188
+ if query.len() < 3 {
1189
+ label == query
1190
+ } else {
1191
+ label == query || label.contains(query) || (label.len() >= 3 && query.contains(label))
1192
+ }
1193
+ })
1194
+ }
1195
+
1196
+ fn tokenize(text: &str) -> Vec<String> {
1197
+ text.to_ascii_lowercase()
1198
+ .chars()
1199
+ .map(|character| {
1200
+ if character.is_alphanumeric() {
1201
+ character
1202
+ } else {
1203
+ ' '
1204
+ }
1205
+ })
1206
+ .collect::<String>()
1207
+ .split_whitespace()
1208
+ .map(str::to_string)
1209
+ .collect()
1210
+ }
1211
+
1212
+ fn round_confidence(confidence: f64) -> f64 {
1213
+ (confidence * 1000.0).round() / 1000.0
1214
+ }
1215
+
1216
+ fn candidate_name(candidate: &Value) -> String {
1217
+ candidate
1218
+ .get("name")
1219
+ .and_then(Value::as_str)
1220
+ .unwrap_or_default()
1221
+ .to_ascii_lowercase()
1222
+ }
1223
+
1224
+ fn element_nodes(
1225
+ elements: &[ElementRecord],
1226
+ scope: &str,
1227
+ app: &str,
1228
+ pid: u32,
1229
+ parent_path: &str,
1230
+ include_stable: bool,
1231
+ ) -> Vec<Value> {
1232
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
1233
+ elements
1234
+ .iter()
1235
+ .map(|element| {
1236
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
1237
+ let path = format!("{parent_path}/{}[{index}]", element.role);
1238
+ *index += 1;
1239
+ element_node(element, scope, app, pid, &path, include_stable, true)
1240
+ })
1241
+ .collect()
1242
+ }
1243
+
1244
+ fn element_node(
1245
+ element: &ElementRecord,
1246
+ scope: &str,
1247
+ app: &str,
1248
+ pid: u32,
1249
+ path: &str,
1250
+ include_stable: bool,
1251
+ include_children: bool,
1252
+ ) -> Value {
1253
+ let mut node = serde_json::json!({
1254
+ "role": element.role,
1255
+ "name": element.name,
1256
+ "path": path,
1257
+ "scope": scope,
1258
+ "app": app,
1259
+ "pid": pid,
1260
+ "states": element.states,
1261
+ });
1262
+
1263
+ if let Some(value) = &element.value {
1264
+ node["value"] = serde_json::json!(value);
1265
+ }
1266
+ if let Some(bounds) = &element.bounds {
1267
+ node["bounds"] = serde_json::json!({
1268
+ "x": bounds.x,
1269
+ "y": bounds.y,
1270
+ "width": bounds.width,
1271
+ "height": bounds.height,
1272
+ });
1273
+ }
1274
+ if include_stable {
1275
+ node["stable"] = serde_json::json!(format!("desktop-atspi:{scope}:{path}"));
1276
+ }
1277
+ if include_children && !element.children.is_empty() {
1278
+ node["children"] = serde_json::json!(element_nodes(
1279
+ &element.children,
1280
+ scope,
1281
+ app,
1282
+ pid,
1283
+ path,
1284
+ include_stable,
1285
+ ));
1286
+ }
1287
+
1288
+ node
1289
+ }
1290
+
1291
+ fn collect_descendant_matches(
1292
+ matches: &mut Vec<Value>,
1293
+ window: &WindowRecord,
1294
+ window_index: usize,
1295
+ params: &Value,
1296
+ ) {
1297
+ let scope = format!("pid-{}", window.pid);
1298
+ let path = format!("Window[{window_index}]");
1299
+ collect_element_matches(
1300
+ matches,
1301
+ &window.children,
1302
+ &scope,
1303
+ &window.title,
1304
+ window.pid,
1305
+ &path,
1306
+ params,
1307
+ );
1308
+ }
1309
+
1310
+ fn collect_descendant_observe_candidates(
1311
+ candidates: &mut Vec<Value>,
1312
+ window: &WindowRecord,
1313
+ window_index: usize,
1314
+ goal: &str,
1315
+ ) {
1316
+ let scope = format!("pid-{}", window.pid);
1317
+ let path = format!("Window[{window_index}]");
1318
+ collect_element_observe_candidates(candidates, &window.children, &scope, &path, goal);
1319
+ }
1320
+
1321
+ fn collect_element_observe_candidates(
1322
+ candidates: &mut Vec<Value>,
1323
+ elements: &[ElementRecord],
1324
+ scope: &str,
1325
+ parent_path: &str,
1326
+ goal: &str,
1327
+ ) {
1328
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
1329
+ for element in elements {
1330
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
1331
+ let path = format!("{parent_path}/{}[{index}]", element.role);
1332
+ *index += 1;
1333
+ if let Some((confidence, reason)) =
1334
+ score_label_for_goal(&element.name, goal, "name", Some(&element.role))
1335
+ {
1336
+ let stable = format!("desktop-atspi:{scope}:{path}");
1337
+ let mut candidate = serde_json::json!({
1338
+ "action": action_for_element(element),
1339
+ "ref": stable,
1340
+ "stable": stable,
1341
+ "role": element.role,
1342
+ "name": element.name,
1343
+ "states": element.states,
1344
+ "confidence": confidence,
1345
+ "reason": reason,
1346
+ });
1347
+ if let Some(value) = &element.value {
1348
+ candidate["value"] = serde_json::json!(value);
1349
+ }
1350
+ if let Some(bounds) = &element.bounds {
1351
+ candidate["bounds"] = serde_json::json!({
1352
+ "x": bounds.x,
1353
+ "y": bounds.y,
1354
+ "width": bounds.width,
1355
+ "height": bounds.height,
1356
+ });
1357
+ }
1358
+ candidates.push(candidate);
1359
+ }
1360
+ collect_element_observe_candidates(candidates, &element.children, scope, &path, goal);
1361
+ }
1362
+ }
1363
+
1364
+ fn action_for_element(element: &ElementRecord) -> &'static str {
1365
+ if element_is_scrollable(element) {
1366
+ "scroll"
1367
+ } else if element_is_settable(element) {
1368
+ "set_value"
1369
+ } else {
1370
+ "click"
1371
+ }
1372
+ }
1373
+
1374
+ fn element_is_scrollable(element: &ElementRecord) -> bool {
1375
+ let role = element.role.to_ascii_lowercase();
1376
+ role.contains("scroll")
1377
+ || element
1378
+ .states
1379
+ .iter()
1380
+ .any(|state| matches!(state.as_str(), "horizontal" | "vertical" | "scrollable"))
1381
+ }
1382
+
1383
+ fn element_is_settable(element: &ElementRecord) -> bool {
1384
+ let role = element.role.to_ascii_lowercase();
1385
+ role.contains("edit")
1386
+ || role.contains("text")
1387
+ || role.contains("slider")
1388
+ || role.contains("spin_button")
1389
+ || role.contains("range")
1390
+ }
1391
+
1392
+ fn first_matching_node(windows: &[WindowRecord], params: &Value) -> Option<(&'static str, Value)> {
1393
+ for window in windows
1394
+ .iter()
1395
+ .filter(|window| window_matches_params(window, params))
1396
+ {
1397
+ let index = pid_local_window_index(windows, window);
1398
+ if window_node_matches_find_params(window, params) {
1399
+ return Some((
1400
+ "top_level_window_inventory",
1401
+ window_node(window, index, true),
1402
+ ));
1403
+ }
1404
+
1405
+ let mut descendants = Vec::new();
1406
+ collect_descendant_matches(&mut descendants, window, index, params);
1407
+ if let Some(node) = descendants.into_iter().next() {
1408
+ return Some(("native_descendant_tree", node));
1409
+ }
1410
+ }
1411
+
1412
+ None
1413
+ }
1414
+
1415
+ fn first_assertion_node(windows: &[WindowRecord], params: &Value) -> Option<(&'static str, Value)> {
1416
+ for window in windows
1417
+ .iter()
1418
+ .filter(|window| window_matches_params(window, params))
1419
+ {
1420
+ let index = pid_local_window_index(windows, window);
1421
+ if window_node_matches_find_params(window, params) && window_state_filter_matches(params) {
1422
+ return Some((
1423
+ "top_level_window_inventory",
1424
+ window_node(window, index, true),
1425
+ ));
1426
+ }
1427
+
1428
+ let mut descendants = Vec::new();
1429
+ collect_descendant_assertion_matches(&mut descendants, window, index, params);
1430
+ if let Some(node) = descendants.into_iter().next() {
1431
+ return Some(("native_descendant_tree", node));
1432
+ }
1433
+ }
1434
+
1435
+ None
1436
+ }
1437
+
1438
+ fn collect_element_matches(
1439
+ matches: &mut Vec<Value>,
1440
+ elements: &[ElementRecord],
1441
+ scope: &str,
1442
+ app: &str,
1443
+ pid: u32,
1444
+ parent_path: &str,
1445
+ params: &Value,
1446
+ ) {
1447
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
1448
+ for element in elements {
1449
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
1450
+ let path = format!("{parent_path}/{}[{index}]", element.role);
1451
+ *index += 1;
1452
+ if element_matches_find_params(element, params) {
1453
+ matches.push(element_node(element, scope, app, pid, &path, true, false));
1454
+ }
1455
+ collect_element_matches(matches, &element.children, scope, app, pid, &path, params);
1456
+ }
1457
+ }
1458
+
1459
+ fn collect_descendant_assertion_matches(
1460
+ matches: &mut Vec<Value>,
1461
+ window: &WindowRecord,
1462
+ window_index: usize,
1463
+ params: &Value,
1464
+ ) {
1465
+ let scope = format!("pid-{}", window.pid);
1466
+ let path = format!("Window[{window_index}]");
1467
+ collect_element_assertion_matches(
1468
+ matches,
1469
+ &window.children,
1470
+ &scope,
1471
+ &window.title,
1472
+ window.pid,
1473
+ &path,
1474
+ params,
1475
+ );
1476
+ }
1477
+
1478
+ fn collect_element_assertion_matches(
1479
+ matches: &mut Vec<Value>,
1480
+ elements: &[ElementRecord],
1481
+ scope: &str,
1482
+ app: &str,
1483
+ pid: u32,
1484
+ parent_path: &str,
1485
+ params: &Value,
1486
+ ) {
1487
+ let mut role_counts: BTreeMap<&str, usize> = BTreeMap::new();
1488
+ for element in elements {
1489
+ let index = role_counts.entry(element.role.as_str()).or_insert(0);
1490
+ let path = format!("{parent_path}/{}[{index}]", element.role);
1491
+ *index += 1;
1492
+ if element_matches_find_params(element, params)
1493
+ && element_state_filter_matches(element, params)
1494
+ {
1495
+ matches.push(element_node(element, scope, app, pid, &path, true, false));
1496
+ }
1497
+ collect_element_assertion_matches(
1498
+ matches,
1499
+ &element.children,
1500
+ scope,
1501
+ app,
1502
+ pid,
1503
+ &path,
1504
+ params,
1505
+ );
1506
+ }
1507
+ }
1508
+
1509
+ fn element_matches_find_params(element: &ElementRecord, params: &Value) -> bool {
1510
+ let role_filter = params
1511
+ .get("role")
1512
+ .and_then(Value::as_str)
1513
+ .map(|role| role.to_ascii_lowercase());
1514
+ let name_filter = params
1515
+ .get("name")
1516
+ .or_else(|| params.get("title"))
1517
+ .and_then(Value::as_str)
1518
+ .map(|name| name.to_ascii_lowercase());
1519
+
1520
+ role_filter
1521
+ .as_ref()
1522
+ .map_or(true, |role| element.role.to_ascii_lowercase() == *role)
1523
+ && name_filter.as_ref().map_or(true, |name| {
1524
+ element.name.to_ascii_lowercase().contains(name)
1525
+ })
1526
+ && text_matches(&element.name, element.value.as_deref(), params)
1527
+ }
1528
+
1529
+ fn text_matches(name: &str, value: Option<&str>, params: &Value) -> bool {
1530
+ let Some(text) = params.get("text").and_then(Value::as_str) else {
1531
+ return true;
1532
+ };
1533
+ let needle = text.to_ascii_lowercase();
1534
+ value
1535
+ .map(|value| value.to_ascii_lowercase().contains(&needle))
1536
+ .unwrap_or(false)
1537
+ || name.to_ascii_lowercase().contains(&needle)
1538
+ }
1539
+
1540
+ fn element_state_filter_matches(element: &ElementRecord, params: &Value) -> bool {
1541
+ let Some(state) = params.get("state").and_then(Value::as_str) else {
1542
+ return true;
1543
+ };
1544
+ let state = state.to_ascii_lowercase();
1545
+ if state == "appear" {
1546
+ return true;
1547
+ }
1548
+ element
1549
+ .states
1550
+ .iter()
1551
+ .any(|candidate| candidate.to_ascii_lowercase() == state)
1552
+ }
1553
+
1554
+ fn command_exists(program: &str) -> bool {
1555
+ let Some(paths) = env::var_os("PATH") else {
1556
+ return false;
1557
+ };
1558
+ env::split_paths(&paths).any(|path| is_executable(path.join(program)))
1559
+ }
1560
+
1561
+ fn is_executable(path: PathBuf) -> bool {
1562
+ fs::metadata(path)
1563
+ .map(|metadata| metadata.is_file())
1564
+ .unwrap_or(false)
1565
+ }
1566
+
1567
+ #[cfg(test)]
1568
+ mod tests {
1569
+ use super::*;
1570
+
1571
+ #[test]
1572
+ fn apps_response_groups_wmctrl_windows_by_pid() {
1573
+ let windows = parse_wmctrl_windows(
1574
+ "0x03a00007 0 1234 host Terminal\n0x03a00008 0 1234 host Terminal Settings\n0x04b00001 -1 77 host Notes\n",
1575
+ );
1576
+
1577
+ assert_eq!(
1578
+ apps_response_from_windows(&windows),
1579
+ serde_json::json!({
1580
+ "mode": "apps",
1581
+ "count": 2,
1582
+ "apps": [
1583
+ { "name": "Notes", "pid": 77, "windowCount": 1 },
1584
+ { "name": "Terminal", "pid": 1234, "windowCount": 2 },
1585
+ ],
1586
+ }),
1587
+ );
1588
+ }
1589
+
1590
+ #[test]
1591
+ fn snapshot_response_emits_raw_ax_root_with_window_children() {
1592
+ let windows =
1593
+ parse_wmctrl_windows("0x03a00007 0 1234 host Terminal\n0x04b00001 -1 77 host Notes\n");
1594
+
1595
+ assert_eq!(
1596
+ snapshot_response_from_windows(&windows, &serde_json::json!({})),
1597
+ serde_json::json!({
1598
+ "role": "Desktop",
1599
+ "name": "Linux Desktop",
1600
+ "path": "Desktop[0]",
1601
+ "scope": "desktop",
1602
+ "children": [
1603
+ {
1604
+ "role": "Window",
1605
+ "name": "Terminal",
1606
+ "path": "Window[0]",
1607
+ "scope": "pid-1234",
1608
+ "app": "Terminal",
1609
+ "pid": 1234,
1610
+ "states": ["visible"],
1611
+ "metadata": {
1612
+ "id": "0x03a00007",
1613
+ "desktop": "0",
1614
+ "host": "host",
1615
+ },
1616
+ "stable": "desktop-atspi:pid-1234:Window[0]",
1617
+ },
1618
+ {
1619
+ "role": "Window",
1620
+ "name": "Notes",
1621
+ "path": "Window[0]",
1622
+ "scope": "pid-77",
1623
+ "app": "Notes",
1624
+ "pid": 77,
1625
+ "states": ["visible"],
1626
+ "metadata": {
1627
+ "id": "0x04b00001",
1628
+ "desktop": "-1",
1629
+ "host": "host",
1630
+ },
1631
+ "stable": "desktop-atspi:pid-77:Window[0]",
1632
+ },
1633
+ ],
1634
+ }),
1635
+ );
1636
+ }
1637
+
1638
+ #[test]
1639
+ fn snapshot_response_exposes_wmctrl_geometry_when_available() {
1640
+ let windows = parse_wmctrl_windows("0x03a00007 0 1234 10 20 640 480 host Terminal\n");
1641
+
1642
+ assert_eq!(
1643
+ snapshot_response_from_windows(&windows, &serde_json::json!({})),
1644
+ serde_json::json!({
1645
+ "role": "Desktop",
1646
+ "name": "Linux Desktop",
1647
+ "path": "Desktop[0]",
1648
+ "scope": "desktop",
1649
+ "children": [
1650
+ {
1651
+ "role": "Window",
1652
+ "name": "Terminal",
1653
+ "path": "Window[0]",
1654
+ "scope": "pid-1234",
1655
+ "app": "Terminal",
1656
+ "pid": 1234,
1657
+ "bounds": {
1658
+ "x": 10,
1659
+ "y": 20,
1660
+ "width": 640,
1661
+ "height": 480,
1662
+ },
1663
+ "states": ["visible"],
1664
+ "metadata": {
1665
+ "id": "0x03a00007",
1666
+ "desktop": "0",
1667
+ "host": "host",
1668
+ },
1669
+ "stable": "desktop-atspi:pid-1234:Window[0]",
1670
+ },
1671
+ ],
1672
+ }),
1673
+ );
1674
+ }
1675
+
1676
+ #[test]
1677
+ fn snapshot_response_emits_descendant_bounds() {
1678
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
1679
+ windows[0].children = vec![ElementRecord {
1680
+ role: "push_button".into(),
1681
+ name: "Eight".into(),
1682
+ value: None,
1683
+ bounds: Some(ElementBounds {
1684
+ x: 120,
1685
+ y: 220,
1686
+ width: 44,
1687
+ height: 36,
1688
+ }),
1689
+ states: vec!["enabled".into()],
1690
+ children: vec![],
1691
+ }];
1692
+
1693
+ let response = snapshot_response_from_windows(&windows, &serde_json::json!({}));
1694
+
1695
+ assert_eq!(
1696
+ response["children"][0]["children"][0]["bounds"],
1697
+ serde_json::json!({
1698
+ "x": 120,
1699
+ "y": 220,
1700
+ "width": 44,
1701
+ "height": 36,
1702
+ }),
1703
+ );
1704
+ }
1705
+
1706
+ #[test]
1707
+ fn live_atspi_roots_populate_matching_window_descendants() {
1708
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
1709
+ populate_window_descendants_from_live_roots(
1710
+ &mut windows,
1711
+ vec![LiveAtspiNode {
1712
+ role: "frame".into(),
1713
+ name: "Calculator".into(),
1714
+ value: None,
1715
+ bounds: Some(ElementBounds {
1716
+ x: 10,
1717
+ y: 20,
1718
+ width: 640,
1719
+ height: 480,
1720
+ }),
1721
+ states: vec!["active".into(), "showing".into()],
1722
+ children: vec![
1723
+ LiveAtspiNode {
1724
+ role: "push button".into(),
1725
+ name: "Eight".into(),
1726
+ value: None,
1727
+ bounds: Some(ElementBounds {
1728
+ x: 120,
1729
+ y: 220,
1730
+ width: 44,
1731
+ height: 36,
1732
+ }),
1733
+ states: vec!["enabled".into(), "sensitive".into()],
1734
+ children: vec![],
1735
+ },
1736
+ LiveAtspiNode {
1737
+ role: "text".into(),
1738
+ name: "Display".into(),
1739
+ value: Some("8".into()),
1740
+ bounds: None,
1741
+ states: vec!["focusable".into(), "enabled".into()],
1742
+ children: vec![],
1743
+ },
1744
+ ],
1745
+ }],
1746
+ );
1747
+
1748
+ let response = snapshot_response_from_windows(&windows, &serde_json::json!({}));
1749
+
1750
+ assert_eq!(
1751
+ response["children"][0]["children"],
1752
+ serde_json::json!([
1753
+ {
1754
+ "role": "push_button",
1755
+ "name": "Eight",
1756
+ "path": "Window[0]/push_button[0]",
1757
+ "scope": "pid-1234",
1758
+ "app": "Calculator",
1759
+ "pid": 1234,
1760
+ "states": ["enabled", "sensitive"],
1761
+ "bounds": {
1762
+ "x": 120,
1763
+ "y": 220,
1764
+ "width": 44,
1765
+ "height": 36,
1766
+ },
1767
+ "stable": "desktop-atspi:pid-1234:Window[0]/push_button[0]",
1768
+ },
1769
+ {
1770
+ "role": "text",
1771
+ "name": "Display",
1772
+ "value": "8",
1773
+ "path": "Window[0]/text[0]",
1774
+ "scope": "pid-1234",
1775
+ "app": "Calculator",
1776
+ "pid": 1234,
1777
+ "states": ["focusable", "enabled"],
1778
+ "stable": "desktop-atspi:pid-1234:Window[0]/text[0]",
1779
+ },
1780
+ ]),
1781
+ );
1782
+ }
1783
+
1784
+ #[test]
1785
+ fn live_atspi_roots_can_create_windows_without_wmctrl_inventory() {
1786
+ let windows = window_records_from_live_roots(vec![LiveAtspiNode {
1787
+ role: "dialog".into(),
1788
+ name: "Preferences".into(),
1789
+ value: None,
1790
+ bounds: Some(ElementBounds {
1791
+ x: 30,
1792
+ y: 40,
1793
+ width: 500,
1794
+ height: 360,
1795
+ }),
1796
+ states: vec!["showing".into(), "enabled".into()],
1797
+ children: vec![LiveAtspiNode {
1798
+ role: "check box".into(),
1799
+ name: "Enable sync".into(),
1800
+ value: None,
1801
+ bounds: None,
1802
+ states: vec!["checked".into()],
1803
+ children: vec![],
1804
+ }],
1805
+ }]);
1806
+
1807
+ assert_eq!(
1808
+ windows,
1809
+ vec![WindowRecord {
1810
+ id: "atspi-root-0".into(),
1811
+ pid: u32::MAX,
1812
+ title: "Preferences".into(),
1813
+ desktop: "atspi".into(),
1814
+ host: "atspi".into(),
1815
+ bounds: Some(WindowBounds {
1816
+ x: 30,
1817
+ y: 40,
1818
+ width: 500,
1819
+ height: 360,
1820
+ }),
1821
+ children: vec![ElementRecord {
1822
+ role: "check_box".into(),
1823
+ name: "Enable sync".into(),
1824
+ value: None,
1825
+ bounds: None,
1826
+ states: vec!["checked".into()],
1827
+ children: vec![],
1828
+ }],
1829
+ }],
1830
+ );
1831
+ }
1832
+
1833
+ #[test]
1834
+ fn find_response_returns_first_matching_top_level_window() {
1835
+ let response = find_response_from_windows(
1836
+ &parse_wmctrl_windows(
1837
+ "0x03a00007 0 1234 host Terminal\n0x03a00008 0 1234 host Terminal Settings\n0x04b00001 -1 77 host Notes\n",
1838
+ ),
1839
+ &serde_json::json!({
1840
+ "role": "window",
1841
+ "name": "settings",
1842
+ "app": "terminal",
1843
+ "first": true,
1844
+ }),
1845
+ )
1846
+ .expect("matching window");
1847
+
1848
+ assert_eq!(
1849
+ response,
1850
+ serde_json::json!({
1851
+ "role": "Window",
1852
+ "name": "Terminal Settings",
1853
+ "path": "Window[1]",
1854
+ "scope": "pid-1234",
1855
+ "app": "Terminal Settings",
1856
+ "pid": 1234,
1857
+ "states": ["visible"],
1858
+ "metadata": {
1859
+ "id": "0x03a00008",
1860
+ "desktop": "0",
1861
+ "host": "host",
1862
+ },
1863
+ "stable": "desktop-atspi:pid-1234:Window[1]",
1864
+ }),
1865
+ );
1866
+ }
1867
+
1868
+ #[test]
1869
+ fn find_response_returns_descendant_by_role_name_and_value() {
1870
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
1871
+ windows[0].children = vec![
1872
+ ElementRecord {
1873
+ role: "push_button".into(),
1874
+ name: "Eight".into(),
1875
+ value: None,
1876
+ bounds: None,
1877
+ states: vec!["enabled".into()],
1878
+ children: vec![],
1879
+ },
1880
+ ElementRecord {
1881
+ role: "text".into(),
1882
+ name: "Display".into(),
1883
+ value: Some("8".into()),
1884
+ bounds: None,
1885
+ states: vec!["focusable".into(), "enabled".into()],
1886
+ children: vec![],
1887
+ },
1888
+ ];
1889
+
1890
+ let response = find_response_from_windows(
1891
+ &windows,
1892
+ &serde_json::json!({
1893
+ "role": "text",
1894
+ "text": "8",
1895
+ "first": true,
1896
+ }),
1897
+ )
1898
+ .expect("matching descendant");
1899
+
1900
+ assert_eq!(
1901
+ response,
1902
+ serde_json::json!({
1903
+ "role": "text",
1904
+ "name": "Display",
1905
+ "value": "8",
1906
+ "path": "Window[0]/text[0]",
1907
+ "scope": "pid-1234",
1908
+ "stable": "desktop-atspi:pid-1234:Window[0]/text[0]",
1909
+ "app": "Calculator",
1910
+ "pid": 1234,
1911
+ "states": ["focusable", "enabled"],
1912
+ }),
1913
+ );
1914
+ }
1915
+
1916
+ #[test]
1917
+ fn wait_response_returns_first_matching_top_level_window() {
1918
+ let response = wait_response_from_windows(
1919
+ &parse_wmctrl_windows(
1920
+ "0x03a00007 0 1234 host Terminal\n0x03a00008 0 1234 host Terminal Settings\n0x04b00001 -1 77 host Notes\n",
1921
+ ),
1922
+ &serde_json::json!({
1923
+ "role": "window",
1924
+ "name": "settings",
1925
+ "app": "terminal",
1926
+ }),
1927
+ )
1928
+ .expect("matching window");
1929
+
1930
+ assert_eq!(
1931
+ response,
1932
+ serde_json::json!({
1933
+ "matched": true,
1934
+ "via": "top_level_window_inventory",
1935
+ "node": {
1936
+ "role": "Window",
1937
+ "name": "Terminal Settings",
1938
+ "path": "Window[1]",
1939
+ "scope": "pid-1234",
1940
+ "app": "Terminal Settings",
1941
+ "pid": 1234,
1942
+ "states": ["visible"],
1943
+ "metadata": {
1944
+ "id": "0x03a00008",
1945
+ "desktop": "0",
1946
+ "host": "host",
1947
+ },
1948
+ "stable": "desktop-atspi:pid-1234:Window[1]",
1949
+ },
1950
+ }),
1951
+ );
1952
+ }
1953
+
1954
+ #[test]
1955
+ fn wait_response_returns_matching_descendant_by_value() {
1956
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
1957
+ windows[0].children = vec![ElementRecord {
1958
+ role: "text".into(),
1959
+ name: "Display".into(),
1960
+ value: Some("8".into()),
1961
+ bounds: None,
1962
+ states: vec!["focusable".into(), "enabled".into()],
1963
+ children: vec![],
1964
+ }];
1965
+
1966
+ let response = wait_response_from_windows(
1967
+ &windows,
1968
+ &serde_json::json!({
1969
+ "role": "text",
1970
+ "text": "8",
1971
+ }),
1972
+ )
1973
+ .expect("matching descendant");
1974
+
1975
+ assert_eq!(
1976
+ response,
1977
+ serde_json::json!({
1978
+ "matched": true,
1979
+ "via": "native_descendant_tree",
1980
+ "node": {
1981
+ "role": "text",
1982
+ "name": "Display",
1983
+ "value": "8",
1984
+ "path": "Window[0]/text[0]",
1985
+ "scope": "pid-1234",
1986
+ "stable": "desktop-atspi:pid-1234:Window[0]/text[0]",
1987
+ "app": "Calculator",
1988
+ "pid": 1234,
1989
+ "states": ["focusable", "enabled"],
1990
+ },
1991
+ }),
1992
+ );
1993
+ }
1994
+
1995
+ #[test]
1996
+ fn observe_response_ranks_top_level_windows_by_goal() {
1997
+ let response = observe_response_from_windows(
1998
+ &parse_wmctrl_windows(
1999
+ "0x03a00007 0 1234 host Terminal\n0x03a00008 0 1234 host Terminal Settings\n0x04b00001 -1 77 host Notes\n",
2000
+ ),
2001
+ &serde_json::json!({
2002
+ "goal": "terminal settings",
2003
+ "topK": 1,
2004
+ }),
2005
+ );
2006
+
2007
+ assert_eq!(
2008
+ response,
2009
+ serde_json::json!({
2010
+ "goal": "terminal settings",
2011
+ "count": 1,
2012
+ "candidates": [
2013
+ {
2014
+ "action": "click",
2015
+ "ref": "desktop-atspi:pid-1234:Window[1]",
2016
+ "stable": "desktop-atspi:pid-1234:Window[1]",
2017
+ "role": "Window",
2018
+ "name": "Terminal Settings",
2019
+ "confidence": 0.95,
2020
+ "reason": "exact title match",
2021
+ },
2022
+ ],
2023
+ }),
2024
+ );
2025
+ }
2026
+
2027
+ #[test]
2028
+ fn observe_response_ranks_descendant_elements_by_goal() {
2029
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
2030
+ windows[0].children = vec![
2031
+ ElementRecord {
2032
+ role: "push_button".into(),
2033
+ name: "Five".into(),
2034
+ value: None,
2035
+ bounds: None,
2036
+ states: vec!["enabled".into()],
2037
+ children: vec![],
2038
+ },
2039
+ ElementRecord {
2040
+ role: "push_button".into(),
2041
+ name: "Eight".into(),
2042
+ value: None,
2043
+ bounds: None,
2044
+ states: vec!["enabled".into()],
2045
+ children: vec![],
2046
+ },
2047
+ ];
2048
+
2049
+ let response = observe_response_from_windows(
2050
+ &windows,
2051
+ &serde_json::json!({
2052
+ "goal": "eight",
2053
+ "topK": 1,
2054
+ }),
2055
+ );
2056
+
2057
+ assert_eq!(
2058
+ response,
2059
+ serde_json::json!({
2060
+ "goal": "eight",
2061
+ "count": 1,
2062
+ "candidates": [
2063
+ {
2064
+ "action": "click",
2065
+ "ref": "desktop-atspi:pid-1234:Window[0]/push_button[1]",
2066
+ "stable": "desktop-atspi:pid-1234:Window[0]/push_button[1]",
2067
+ "role": "push_button",
2068
+ "name": "Eight",
2069
+ "states": ["enabled"],
2070
+ "confidence": 0.95,
2071
+ "reason": "exact name match",
2072
+ },
2073
+ ],
2074
+ }),
2075
+ );
2076
+ }
2077
+
2078
+ #[test]
2079
+ fn observe_response_marks_scrollable_descendant_action() {
2080
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Terminal Settings\n");
2081
+ windows[0].children = vec![ElementRecord {
2082
+ role: "scroll_pane".into(),
2083
+ name: "Output".into(),
2084
+ value: None,
2085
+ bounds: None,
2086
+ states: vec!["enabled".into(), "vertical".into()],
2087
+ children: vec![],
2088
+ }];
2089
+
2090
+ let response = observe_response_from_windows(
2091
+ &windows,
2092
+ &serde_json::json!({
2093
+ "goal": "output",
2094
+ "topK": 1,
2095
+ }),
2096
+ );
2097
+
2098
+ assert_eq!(response["candidates"][0]["action"], "scroll");
2099
+ assert_eq!(
2100
+ response["candidates"][0]["stable"],
2101
+ "desktop-atspi:pid-1234:Window[0]/scroll_pane[0]",
2102
+ );
2103
+ }
2104
+
2105
+ #[test]
2106
+ fn observe_response_marks_range_descendant_action_as_set_value() {
2107
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Terminal Settings\n");
2108
+ windows[0].children = vec![ElementRecord {
2109
+ role: "slider".into(),
2110
+ name: "Volume".into(),
2111
+ value: Some("35".into()),
2112
+ bounds: None,
2113
+ states: vec!["enabled".into()],
2114
+ children: vec![],
2115
+ }];
2116
+
2117
+ let response = observe_response_from_windows(
2118
+ &windows,
2119
+ &serde_json::json!({
2120
+ "goal": "volume",
2121
+ "topK": 1,
2122
+ }),
2123
+ );
2124
+
2125
+ assert_eq!(response["candidates"][0]["action"], "set_value");
2126
+ assert_eq!(
2127
+ response["candidates"][0]["stable"],
2128
+ "desktop-atspi:pid-1234:Window[0]/slider[0]",
2129
+ );
2130
+ }
2131
+
2132
+ #[test]
2133
+ fn observe_response_preserves_descendant_value_states_and_bounds() {
2134
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Editor\n");
2135
+ windows[0].children = vec![ElementRecord {
2136
+ role: "text".into(),
2137
+ name: "Search".into(),
2138
+ value: Some("filter text".into()),
2139
+ bounds: Some(ElementBounds {
2140
+ x: 10,
2141
+ y: 20,
2142
+ width: 240,
2143
+ height: 32,
2144
+ }),
2145
+ states: vec!["enabled".into(), "focusable".into()],
2146
+ children: vec![],
2147
+ }];
2148
+
2149
+ let response = observe_response_from_windows(
2150
+ &windows,
2151
+ &serde_json::json!({
2152
+ "goal": "search",
2153
+ "topK": 1,
2154
+ }),
2155
+ );
2156
+
2157
+ assert_eq!(response["candidates"][0]["value"], "filter text");
2158
+ assert_eq!(
2159
+ response["candidates"][0]["states"],
2160
+ serde_json::json!(["enabled", "focusable"]),
2161
+ );
2162
+ assert_eq!(
2163
+ response["candidates"][0]["bounds"],
2164
+ serde_json::json!({
2165
+ "x": 10,
2166
+ "y": 20,
2167
+ "width": 240,
2168
+ "height": 32,
2169
+ }),
2170
+ );
2171
+ }
2172
+
2173
+ #[test]
2174
+ fn assert_response_matches_top_level_window_text_and_visible_state() {
2175
+ let response = assert_response_from_windows(
2176
+ &parse_wmctrl_windows(
2177
+ "0x03a00007 0 1234 host Terminal\n0x03a00008 0 1234 host Terminal Settings\n0x04b00001 -1 77 host Notes\n",
2178
+ ),
2179
+ &serde_json::json!({
2180
+ "text": "settings",
2181
+ "app": "terminal",
2182
+ "state": "visible",
2183
+ }),
2184
+ )
2185
+ .expect("asserted window");
2186
+
2187
+ assert_eq!(
2188
+ response,
2189
+ serde_json::json!({
2190
+ "asserted": true,
2191
+ "via": "top_level_window_inventory",
2192
+ "checks": {
2193
+ "text": "settings",
2194
+ "state": "visible",
2195
+ },
2196
+ "node": {
2197
+ "role": "Window",
2198
+ "name": "Terminal Settings",
2199
+ "path": "Window[1]",
2200
+ "scope": "pid-1234",
2201
+ "app": "Terminal Settings",
2202
+ "pid": 1234,
2203
+ "states": ["visible"],
2204
+ "metadata": {
2205
+ "id": "0x03a00008",
2206
+ "desktop": "0",
2207
+ "host": "host",
2208
+ },
2209
+ "stable": "desktop-atspi:pid-1234:Window[1]",
2210
+ },
2211
+ }),
2212
+ );
2213
+ }
2214
+
2215
+ #[test]
2216
+ fn assert_response_matches_descendant_text_value_and_enabled_state() {
2217
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
2218
+ windows[0].children = vec![ElementRecord {
2219
+ role: "text".into(),
2220
+ name: "Display".into(),
2221
+ value: Some("8".into()),
2222
+ bounds: None,
2223
+ states: vec!["focusable".into(), "enabled".into()],
2224
+ children: vec![],
2225
+ }];
2226
+
2227
+ let response = assert_response_from_windows(
2228
+ &windows,
2229
+ &serde_json::json!({
2230
+ "role": "text",
2231
+ "text": "8",
2232
+ "state": "enabled",
2233
+ }),
2234
+ )
2235
+ .expect("asserted descendant");
2236
+
2237
+ assert_eq!(
2238
+ response,
2239
+ serde_json::json!({
2240
+ "asserted": true,
2241
+ "via": "native_descendant_tree",
2242
+ "checks": {
2243
+ "text": "8",
2244
+ "state": "enabled",
2245
+ },
2246
+ "node": {
2247
+ "role": "text",
2248
+ "name": "Display",
2249
+ "value": "8",
2250
+ "path": "Window[0]/text[0]",
2251
+ "scope": "pid-1234",
2252
+ "stable": "desktop-atspi:pid-1234:Window[0]/text[0]",
2253
+ "app": "Calculator",
2254
+ "pid": 1234,
2255
+ "states": ["focusable", "enabled"],
2256
+ },
2257
+ }),
2258
+ );
2259
+ }
2260
+
2261
+ #[test]
2262
+ fn assert_response_resolves_descendant_stable_ref() {
2263
+ let mut windows = parse_wmctrl_windows("0x03a00007 0 1234 host Calculator\n");
2264
+ windows[0].children = vec![ElementRecord {
2265
+ role: "text".into(),
2266
+ name: "Display".into(),
2267
+ value: Some("8".into()),
2268
+ bounds: None,
2269
+ states: vec!["focusable".into(), "enabled".into()],
2270
+ children: vec![],
2271
+ }];
2272
+
2273
+ let response = assert_response_from_windows(
2274
+ &windows,
2275
+ &serde_json::json!({
2276
+ "ref": "desktop-atspi:pid-1234:Window[0]/text[0]",
2277
+ "text": "8",
2278
+ "state": "enabled",
2279
+ }),
2280
+ )
2281
+ .expect("asserted descendant ref");
2282
+
2283
+ assert_eq!(
2284
+ response,
2285
+ serde_json::json!({
2286
+ "asserted": true,
2287
+ "via": "native_descendant_tree",
2288
+ "checks": {
2289
+ "text": "8",
2290
+ "state": "enabled",
2291
+ },
2292
+ "node": {
2293
+ "role": "text",
2294
+ "name": "Display",
2295
+ "value": "8",
2296
+ "path": "Window[0]/text[0]",
2297
+ "scope": "pid-1234",
2298
+ "stable": "desktop-atspi:pid-1234:Window[0]/text[0]",
2299
+ "app": "Calculator",
2300
+ "pid": 1234,
2301
+ "states": ["focusable", "enabled"],
2302
+ },
2303
+ }),
2304
+ );
2305
+ }
2306
+
2307
+ #[test]
2308
+ fn resolves_stable_top_level_window_refs_by_pid_and_pid_local_index() {
2309
+ let windows = parse_wmctrl_windows(
2310
+ "0x03a00007 0 1234 host Terminal\n0x03a00008 0 1234 host Terminal Settings\n0x04b00001 -1 77 host Notes\n",
2311
+ );
2312
+
2313
+ let resolved = resolve_top_level_window_ref(&windows, "desktop-atspi:pid-1234:Window[1]")
2314
+ .expect("stable window ref");
2315
+
2316
+ assert_eq!(resolved.id, "0x03a00008");
2317
+ assert_eq!(resolved.title, "Terminal Settings");
2318
+ }
2319
+ }