@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,1004 @@
1
+ use std::env;
2
+ use std::fs;
3
+ use std::path::PathBuf;
4
+ use std::process::Command;
5
+
6
+ use serde_json::Value;
7
+ use unicli_shared::SidecarRequest;
8
+
9
+ use crate::errors::{backend_unavailable, AtspiError, HandlerResult};
10
+ #[cfg(target_os = "linux")]
11
+ use crate::invoke::resolve_live_descendant_accessible;
12
+ use crate::tree::{
13
+ enumerate_top_level_windows, resolve_descendant_element_ref, resolve_top_level_window_ref,
14
+ ElementRecord, State, WindowRecord,
15
+ };
16
+
17
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
18
+ enum DisplayServer {
19
+ Wayland,
20
+ X11,
21
+ Headless,
22
+ }
23
+
24
+ #[derive(Debug, Clone, PartialEq, Eq)]
25
+ struct CommandPlan {
26
+ program: String,
27
+ args: Vec<String>,
28
+ }
29
+
30
+ pub fn handle_press(request: &SidecarRequest) -> HandlerResult {
31
+ if !cfg!(target_os = "linux") {
32
+ return Err(backend_unavailable());
33
+ }
34
+
35
+ let combo = read_combo(&request.params)?;
36
+ let plan = press_command_for(display_server_from_env(), &combo, command_exists)?;
37
+ run_command(&plan)?;
38
+ Ok(serde_json::json!({
39
+ "pressed": true,
40
+ "combo": combo,
41
+ "backend": plan.program
42
+ }))
43
+ }
44
+
45
+ pub(crate) fn handle_type_text(request: &SidecarRequest) -> HandlerResult {
46
+ if !cfg!(target_os = "linux") {
47
+ return Err(backend_unavailable());
48
+ }
49
+
50
+ let text = read_text(&request.params)?;
51
+ let plan = text_command_for(display_server_from_env(), &text, command_exists)?;
52
+ run_command(&plan)?;
53
+ Ok(serde_json::json!({
54
+ "typed": true,
55
+ "backend": plan.program,
56
+ "chars": text.chars().count()
57
+ }))
58
+ }
59
+
60
+ pub(crate) fn click_screen_point(x: i32, y: i32) -> HandlerResult {
61
+ if !cfg!(target_os = "linux") {
62
+ return Err(backend_unavailable());
63
+ }
64
+
65
+ let plan = click_command_for(display_server_from_env(), x, y, command_exists)?;
66
+ run_command(&plan)?;
67
+ Ok(serde_json::json!({
68
+ "clicked": true,
69
+ "backend": plan.program,
70
+ "x": x,
71
+ "y": y,
72
+ }))
73
+ }
74
+
75
+ pub fn handle_scroll(_state: &mut State, request: &SidecarRequest) -> HandlerResult {
76
+ if !cfg!(target_os = "linux") {
77
+ return Err(backend_unavailable());
78
+ }
79
+
80
+ let stable = read_optional_stable_ref(&request.params)?;
81
+ if let Some(stable) = stable {
82
+ let windows = enumerate_top_level_windows()?;
83
+ let direction = read_direction(&request.params);
84
+ let amount = read_amount(&request.params);
85
+ if let Some((window, element, path)) = resolve_descendant_element_ref(&windows, &stable) {
86
+ if let Some(scroll) = try_native_scroll_descendant(window, &stable, &direction, amount)?
87
+ {
88
+ return Ok(scroll_response_for_native_descendant(
89
+ window, element, &stable, &path, &direction, amount, scroll,
90
+ ));
91
+ }
92
+ crate::invoke::focus_top_level_window(window)?;
93
+ let scrolled = scroll_with_params(&request.params)?;
94
+ return Ok(scroll_response_for_descendant(
95
+ window, element, &stable, &path, scrolled,
96
+ ));
97
+ }
98
+ let window = resolve_top_level_window_ref(&windows, &stable)
99
+ .ok_or_else(|| AtspiError::no_element(stable.clone()))?;
100
+ crate::invoke::focus_top_level_window(window)?;
101
+ let scrolled = scroll_with_params(&request.params)?;
102
+ return Ok(scroll_response_for_window(window, &stable, scrolled));
103
+ }
104
+
105
+ scroll_with_params(&request.params)
106
+ }
107
+
108
+ fn scroll_with_params(params: &Value) -> HandlerResult {
109
+ let direction = read_direction(params);
110
+ let amount = read_amount(params);
111
+ let plan = scroll_command_for(
112
+ display_server_from_env(),
113
+ &direction,
114
+ amount,
115
+ command_exists,
116
+ )?;
117
+ run_command(&plan)?;
118
+ Ok(serde_json::json!({
119
+ "scrolled": true,
120
+ "direction": direction,
121
+ "amount": amount,
122
+ "backend": plan.program
123
+ }))
124
+ }
125
+
126
+ fn read_optional_stable_ref(params: &Value) -> Result<Option<String>, AtspiError> {
127
+ let Some(value) = params
128
+ .get("stable")
129
+ .or_else(|| params.get("ref"))
130
+ .and_then(Value::as_str)
131
+ else {
132
+ return Ok(None);
133
+ };
134
+ if value.starts_with("desktop-atspi:") {
135
+ return Ok(Some(value.to_string()));
136
+ }
137
+ Err(AtspiError::invalid_input(
138
+ "atspi_scroll requires a desktop-atspi stable top-level window ref when ref is provided",
139
+ ))
140
+ }
141
+
142
+ fn scroll_response_for_window(
143
+ window: &WindowRecord,
144
+ stable: &str,
145
+ scroll: serde_json::Value,
146
+ ) -> serde_json::Value {
147
+ serde_json::json!({
148
+ "scrolled": true,
149
+ "via": "top_level_window_scroll_helper",
150
+ "stable": stable,
151
+ "id": window.id,
152
+ "pid": window.pid,
153
+ "title": window.title,
154
+ "scroll": scroll,
155
+ })
156
+ }
157
+
158
+ fn scroll_response_for_descendant(
159
+ window: &WindowRecord,
160
+ element: &ElementRecord,
161
+ stable: &str,
162
+ path: &str,
163
+ scroll: serde_json::Value,
164
+ ) -> serde_json::Value {
165
+ let mut target = descendant_target_node(element, path);
166
+ if let Some(bounds) = &element.bounds {
167
+ target["bounds"] = serde_json::json!({
168
+ "x": bounds.x,
169
+ "y": bounds.y,
170
+ "width": bounds.width,
171
+ "height": bounds.height,
172
+ });
173
+ }
174
+ serde_json::json!({
175
+ "scrolled": true,
176
+ "via": "descendant_scroll_helper",
177
+ "stable": stable,
178
+ "id": window.id,
179
+ "pid": window.pid,
180
+ "title": window.title,
181
+ "target": target,
182
+ "scroll": scroll,
183
+ })
184
+ }
185
+
186
+ fn scroll_response_for_native_descendant(
187
+ window: &WindowRecord,
188
+ element: &ElementRecord,
189
+ stable: &str,
190
+ path: &str,
191
+ direction: &str,
192
+ amount: u32,
193
+ scroll: serde_json::Value,
194
+ ) -> serde_json::Value {
195
+ let mut target = descendant_target_node(element, path);
196
+ if let Some(bounds) = &element.bounds {
197
+ target["bounds"] = serde_json::json!({
198
+ "x": bounds.x,
199
+ "y": bounds.y,
200
+ "width": bounds.width,
201
+ "height": bounds.height,
202
+ });
203
+ }
204
+ serde_json::json!({
205
+ "scrolled": true,
206
+ "via": "atspi_component_scroll_to",
207
+ "stable": stable,
208
+ "id": window.id,
209
+ "pid": window.pid,
210
+ "title": window.title,
211
+ "target": target,
212
+ "direction": direction,
213
+ "amount": amount,
214
+ "scroll": scroll,
215
+ })
216
+ }
217
+
218
+ fn descendant_target_node(element: &ElementRecord, path: &str) -> serde_json::Value {
219
+ let mut target = serde_json::json!({
220
+ "role": element.role,
221
+ "name": element.name,
222
+ "path": path,
223
+ });
224
+ if let Some(value) = &element.value {
225
+ target["value"] = serde_json::json!(value);
226
+ }
227
+ target
228
+ }
229
+
230
+ fn read_combo(params: &Value) -> Result<String, AtspiError> {
231
+ params
232
+ .get("combo")
233
+ .and_then(Value::as_str)
234
+ .map(str::trim)
235
+ .filter(|combo| !combo.is_empty())
236
+ .map(String::from)
237
+ .ok_or_else(|| AtspiError::unavailable("atspi_press requires a non-empty combo"))
238
+ }
239
+
240
+ fn read_text(params: &Value) -> Result<String, AtspiError> {
241
+ params
242
+ .get("text")
243
+ .or_else(|| params.get("value"))
244
+ .and_then(Value::as_str)
245
+ .map(String::from)
246
+ .filter(|text| !text.is_empty())
247
+ .ok_or_else(|| AtspiError::unavailable("atspi_set_value requires non-empty text"))
248
+ }
249
+
250
+ fn read_direction(params: &Value) -> String {
251
+ params
252
+ .get("direction")
253
+ .and_then(Value::as_str)
254
+ .unwrap_or("down")
255
+ .to_ascii_lowercase()
256
+ }
257
+
258
+ fn read_amount(params: &Value) -> u32 {
259
+ params
260
+ .get("amount")
261
+ .and_then(Value::as_u64)
262
+ .and_then(|amount| u32::try_from(amount).ok())
263
+ .filter(|amount| *amount > 0)
264
+ .unwrap_or(300)
265
+ }
266
+
267
+ fn display_server_from_env() -> DisplayServer {
268
+ display_server_from_iter(env::vars())
269
+ }
270
+
271
+ #[cfg(test)]
272
+ fn display_server_from_pairs<I>(pairs: I) -> DisplayServer
273
+ where
274
+ I: IntoIterator<Item = (&'static str, &'static str)>,
275
+ {
276
+ display_server_from_iter(pairs)
277
+ }
278
+
279
+ fn display_server_from_iter<K, V, I>(pairs: I) -> DisplayServer
280
+ where
281
+ K: AsRef<str>,
282
+ V: AsRef<str>,
283
+ I: IntoIterator<Item = (K, V)>,
284
+ {
285
+ let mut has_x11 = false;
286
+ for (key, value) in pairs {
287
+ let key = key.as_ref();
288
+ let value = value.as_ref();
289
+ if value.is_empty() {
290
+ continue;
291
+ }
292
+ if key == "WAYLAND_DISPLAY" {
293
+ return DisplayServer::Wayland;
294
+ }
295
+ if key == "DISPLAY" {
296
+ has_x11 = true;
297
+ }
298
+ }
299
+ if has_x11 {
300
+ DisplayServer::X11
301
+ } else {
302
+ DisplayServer::Headless
303
+ }
304
+ }
305
+
306
+ fn press_command_for(
307
+ server: DisplayServer,
308
+ combo: &str,
309
+ exists: impl Fn(&str) -> bool,
310
+ ) -> Result<CommandPlan, AtspiError> {
311
+ match server {
312
+ DisplayServer::X11 => x11_press_command(combo, exists),
313
+ DisplayServer::Wayland => wayland_press_command(combo, exists),
314
+ DisplayServer::Headless => Err(AtspiError::unavailable(
315
+ "no WAYLAND_DISPLAY or DISPLAY environment is available for input dispatch",
316
+ )),
317
+ }
318
+ }
319
+
320
+ fn text_command_for(
321
+ server: DisplayServer,
322
+ text: &str,
323
+ exists: impl Fn(&str) -> bool,
324
+ ) -> Result<CommandPlan, AtspiError> {
325
+ match server {
326
+ DisplayServer::X11 => x11_text_command(text, exists),
327
+ DisplayServer::Wayland => wayland_text_command(text, exists),
328
+ DisplayServer::Headless => Err(AtspiError::unavailable(
329
+ "no WAYLAND_DISPLAY or DISPLAY environment is available for text dispatch",
330
+ )),
331
+ }
332
+ }
333
+
334
+ fn scroll_command_for(
335
+ server: DisplayServer,
336
+ direction: &str,
337
+ amount: u32,
338
+ exists: impl Fn(&str) -> bool,
339
+ ) -> Result<CommandPlan, AtspiError> {
340
+ match server {
341
+ DisplayServer::X11 => x11_scroll_command(direction, amount, exists),
342
+ DisplayServer::Wayland => wayland_scroll_command(direction, amount, exists),
343
+ DisplayServer::Headless => Err(AtspiError::unavailable(
344
+ "no WAYLAND_DISPLAY or DISPLAY environment is available for scroll dispatch",
345
+ )),
346
+ }
347
+ }
348
+
349
+ #[cfg(any(target_os = "linux", test))]
350
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
351
+ enum NativeComponentScrollType {
352
+ Top,
353
+ Bottom,
354
+ Left,
355
+ Right,
356
+ }
357
+
358
+ #[cfg(any(target_os = "linux", test))]
359
+ fn native_component_scroll_type_for_direction(
360
+ direction: &str,
361
+ ) -> Result<NativeComponentScrollType, AtspiError> {
362
+ match direction {
363
+ "up" => Ok(NativeComponentScrollType::Top),
364
+ "down" => Ok(NativeComponentScrollType::Bottom),
365
+ "left" => Ok(NativeComponentScrollType::Left),
366
+ "right" => Ok(NativeComponentScrollType::Right),
367
+ other => Err(AtspiError::unavailable(format!(
368
+ "unsupported scroll direction {other}; expected up, down, left, or right"
369
+ ))),
370
+ }
371
+ }
372
+
373
+ #[cfg(target_os = "linux")]
374
+ fn try_native_scroll_descendant(
375
+ window: &WindowRecord,
376
+ stable: &str,
377
+ direction: &str,
378
+ _amount: u32,
379
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
380
+ let scroll_type = native_component_scroll_type_for_direction(direction)?;
381
+ let result = futures_lite::future::block_on(async {
382
+ let connection = atspi::AccessibilityConnection::new().await?;
383
+ let element = resolve_live_descendant_accessible(&connection, window, stable).await?;
384
+ let proxies = atspi::proxy::proxy_ext::ProxyExt::proxies(&element).await?;
385
+ let component = proxies.component().await?;
386
+ let scrolled = component.scroll_to(atspi_scroll_type(scroll_type)).await?;
387
+ Ok::<bool, atspi::AtspiError>(scrolled)
388
+ });
389
+
390
+ match result {
391
+ Ok(true) => Ok(Some(serde_json::json!({
392
+ "scrolled": true,
393
+ "type": native_component_scroll_type_name(scroll_type),
394
+ }))),
395
+ Ok(false) | Err(_) => Ok(None),
396
+ }
397
+ }
398
+
399
+ #[cfg(not(target_os = "linux"))]
400
+ fn try_native_scroll_descendant(
401
+ _window: &WindowRecord,
402
+ _stable: &str,
403
+ _direction: &str,
404
+ _amount: u32,
405
+ ) -> Result<Option<serde_json::Value>, AtspiError> {
406
+ Ok(None)
407
+ }
408
+
409
+ #[cfg(target_os = "linux")]
410
+ fn atspi_scroll_type(scroll_type: NativeComponentScrollType) -> atspi::ScrollType {
411
+ match scroll_type {
412
+ NativeComponentScrollType::Top => atspi::ScrollType::TopEdge,
413
+ NativeComponentScrollType::Bottom => atspi::ScrollType::BottomEdge,
414
+ NativeComponentScrollType::Left => atspi::ScrollType::LeftEdge,
415
+ NativeComponentScrollType::Right => atspi::ScrollType::RightEdge,
416
+ }
417
+ }
418
+
419
+ #[cfg(any(target_os = "linux", test))]
420
+ fn native_component_scroll_type_name(scroll_type: NativeComponentScrollType) -> &'static str {
421
+ match scroll_type {
422
+ NativeComponentScrollType::Top => "TopEdge",
423
+ NativeComponentScrollType::Bottom => "BottomEdge",
424
+ NativeComponentScrollType::Left => "LeftEdge",
425
+ NativeComponentScrollType::Right => "RightEdge",
426
+ }
427
+ }
428
+
429
+ fn click_command_for(
430
+ server: DisplayServer,
431
+ x: i32,
432
+ y: i32,
433
+ exists: impl Fn(&str) -> bool,
434
+ ) -> Result<CommandPlan, AtspiError> {
435
+ match server {
436
+ DisplayServer::X11 => x11_click_command(x, y, exists),
437
+ DisplayServer::Wayland => wayland_click_command(x, y, exists),
438
+ DisplayServer::Headless => Err(AtspiError::unavailable(
439
+ "no WAYLAND_DISPLAY or DISPLAY environment is available for click dispatch",
440
+ )),
441
+ }
442
+ }
443
+
444
+ fn x11_press_command(
445
+ combo: &str,
446
+ exists: impl Fn(&str) -> bool,
447
+ ) -> Result<CommandPlan, AtspiError> {
448
+ if !exists("xdotool") {
449
+ return Err(AtspiError::x11_input_missing(
450
+ "xdotool is required for AT-SPI X11 key dispatch",
451
+ ));
452
+ }
453
+ Ok(CommandPlan {
454
+ program: "xdotool".into(),
455
+ args: vec!["key".into(), combo.into()],
456
+ })
457
+ }
458
+
459
+ fn x11_text_command(text: &str, exists: impl Fn(&str) -> bool) -> Result<CommandPlan, AtspiError> {
460
+ if !exists("xdotool") {
461
+ return Err(AtspiError::x11_input_missing(
462
+ "xdotool is required for AT-SPI X11 text dispatch",
463
+ ));
464
+ }
465
+ Ok(CommandPlan {
466
+ program: "xdotool".into(),
467
+ args: vec!["type".into(), "--clearmodifiers".into(), text.into()],
468
+ })
469
+ }
470
+
471
+ fn x11_scroll_command(
472
+ direction: &str,
473
+ amount: u32,
474
+ exists: impl Fn(&str) -> bool,
475
+ ) -> Result<CommandPlan, AtspiError> {
476
+ if !exists("xdotool") {
477
+ return Err(AtspiError::x11_input_missing(
478
+ "xdotool is required for AT-SPI X11 scroll dispatch",
479
+ ));
480
+ }
481
+ Ok(CommandPlan {
482
+ program: "xdotool".into(),
483
+ args: vec![
484
+ "click".into(),
485
+ "--repeat".into(),
486
+ wheel_steps(amount).to_string(),
487
+ wheel_button(direction)?.into(),
488
+ ],
489
+ })
490
+ }
491
+
492
+ fn x11_click_command(
493
+ x: i32,
494
+ y: i32,
495
+ exists: impl Fn(&str) -> bool,
496
+ ) -> Result<CommandPlan, AtspiError> {
497
+ if !exists("xdotool") {
498
+ return Err(AtspiError::x11_input_missing(
499
+ "xdotool is required for AT-SPI X11 click dispatch",
500
+ ));
501
+ }
502
+ Ok(CommandPlan {
503
+ program: "xdotool".into(),
504
+ args: vec![
505
+ "mousemove".into(),
506
+ "--sync".into(),
507
+ x.to_string(),
508
+ y.to_string(),
509
+ "click".into(),
510
+ "1".into(),
511
+ ],
512
+ })
513
+ }
514
+
515
+ fn wayland_press_command(
516
+ combo: &str,
517
+ exists: impl Fn(&str) -> bool,
518
+ ) -> Result<CommandPlan, AtspiError> {
519
+ if is_printable_key(combo) && exists("wtype") {
520
+ return Ok(CommandPlan {
521
+ program: "wtype".into(),
522
+ args: vec![combo.into()],
523
+ });
524
+ }
525
+ if exists("ydotool") {
526
+ if let Some(args) = ydotool_key_args(combo) {
527
+ return Ok(CommandPlan {
528
+ program: "ydotool".into(),
529
+ args,
530
+ });
531
+ }
532
+ }
533
+ Err(AtspiError::wayland_input_missing(
534
+ "wtype is required for printable Wayland key dispatch; ydotool is required for supported modifier combos",
535
+ ))
536
+ }
537
+
538
+ fn wayland_text_command(
539
+ text: &str,
540
+ exists: impl Fn(&str) -> bool,
541
+ ) -> Result<CommandPlan, AtspiError> {
542
+ if !exists("wtype") {
543
+ return Err(AtspiError::wayland_input_missing(
544
+ "wtype is required for AT-SPI Wayland text dispatch",
545
+ ));
546
+ }
547
+ Ok(CommandPlan {
548
+ program: "wtype".into(),
549
+ args: vec![text.into()],
550
+ })
551
+ }
552
+
553
+ fn wayland_scroll_command(
554
+ direction: &str,
555
+ amount: u32,
556
+ exists: impl Fn(&str) -> bool,
557
+ ) -> Result<CommandPlan, AtspiError> {
558
+ if !exists("ydotool") {
559
+ return Err(AtspiError::wayland_input_missing(
560
+ "ydotool is required for AT-SPI Wayland scroll dispatch",
561
+ ));
562
+ }
563
+ let button = wheel_button(direction)?;
564
+ let mut args = Vec::with_capacity(1 + wheel_steps(amount) as usize);
565
+ args.push("click".into());
566
+ for _ in 0..wheel_steps(amount) {
567
+ args.push(button.into());
568
+ }
569
+ Ok(CommandPlan {
570
+ program: "ydotool".into(),
571
+ args,
572
+ })
573
+ }
574
+
575
+ fn wayland_click_command(
576
+ x: i32,
577
+ y: i32,
578
+ exists: impl Fn(&str) -> bool,
579
+ ) -> Result<CommandPlan, AtspiError> {
580
+ if !exists("ydotool") {
581
+ return Err(AtspiError::wayland_input_missing(
582
+ "ydotool is required for AT-SPI Wayland click dispatch",
583
+ ));
584
+ }
585
+ Ok(CommandPlan {
586
+ program: "ydotool".into(),
587
+ args: vec![
588
+ "mousemove".into(),
589
+ "--absolute".into(),
590
+ x.to_string(),
591
+ y.to_string(),
592
+ "click".into(),
593
+ "0xC0".into(),
594
+ ],
595
+ })
596
+ }
597
+
598
+ fn wheel_steps(amount: u32) -> u32 {
599
+ amount.div_ceil(120).max(1)
600
+ }
601
+
602
+ fn wheel_button(direction: &str) -> Result<&'static str, AtspiError> {
603
+ match direction {
604
+ "up" => Ok("4"),
605
+ "down" => Ok("5"),
606
+ "left" => Ok("6"),
607
+ "right" => Ok("7"),
608
+ other => Err(AtspiError::unavailable(format!(
609
+ "unsupported scroll direction {other}; expected up, down, left, or right"
610
+ ))),
611
+ }
612
+ }
613
+
614
+ fn is_printable_key(combo: &str) -> bool {
615
+ let mut chars = combo.chars();
616
+ matches!((chars.next(), chars.next()), (Some(ch), None) if !ch.is_control())
617
+ }
618
+
619
+ fn ydotool_key_args(combo: &str) -> Option<Vec<String>> {
620
+ let mut modifier_codes = Vec::new();
621
+ let mut key_code = None;
622
+
623
+ for raw_part in combo.split('+') {
624
+ let part = raw_part.trim().to_ascii_lowercase();
625
+ let key = part.as_str();
626
+ let code = key_code_for(key)?;
627
+ if is_modifier(key) {
628
+ modifier_codes.push(code);
629
+ } else if key_code.replace(code).is_some() {
630
+ return None;
631
+ }
632
+ }
633
+
634
+ let key_code = key_code?;
635
+ let mut args = Vec::with_capacity(1 + (modifier_codes.len() * 2) + 2);
636
+ args.push("key".into());
637
+ for code in &modifier_codes {
638
+ args.push(format!("{code}:1"));
639
+ }
640
+ args.push(format!("{key_code}:1"));
641
+ args.push(format!("{key_code}:0"));
642
+ for code in modifier_codes.iter().rev() {
643
+ args.push(format!("{code}:0"));
644
+ }
645
+ Some(args)
646
+ }
647
+
648
+ fn is_modifier(key: &str) -> bool {
649
+ matches!(
650
+ key,
651
+ "ctrl"
652
+ | "control"
653
+ | "shift"
654
+ | "alt"
655
+ | "option"
656
+ | "cmd"
657
+ | "command"
658
+ | "super"
659
+ | "meta"
660
+ | "win"
661
+ | "windows"
662
+ )
663
+ }
664
+
665
+ fn key_code_for(key: &str) -> Option<u16> {
666
+ Some(match key {
667
+ "ctrl" | "control" => 29,
668
+ "shift" => 42,
669
+ "alt" | "option" => 56,
670
+ "cmd" | "command" | "super" | "meta" | "win" | "windows" => 125,
671
+ "esc" | "escape" => 1,
672
+ "1" => 2,
673
+ "2" => 3,
674
+ "3" => 4,
675
+ "4" => 5,
676
+ "5" => 6,
677
+ "6" => 7,
678
+ "7" => 8,
679
+ "8" => 9,
680
+ "9" => 10,
681
+ "0" => 11,
682
+ "backspace" => 14,
683
+ "tab" => 15,
684
+ "q" => 16,
685
+ "w" => 17,
686
+ "e" => 18,
687
+ "r" => 19,
688
+ "t" => 20,
689
+ "y" => 21,
690
+ "u" => 22,
691
+ "i" => 23,
692
+ "o" => 24,
693
+ "p" => 25,
694
+ "enter" | "return" => 28,
695
+ "a" => 30,
696
+ "s" => 31,
697
+ "d" => 32,
698
+ "f" => 33,
699
+ "g" => 34,
700
+ "h" => 35,
701
+ "j" => 36,
702
+ "k" => 37,
703
+ "l" => 38,
704
+ "z" => 44,
705
+ "x" => 45,
706
+ "c" => 46,
707
+ "v" => 47,
708
+ "b" => 48,
709
+ "n" => 49,
710
+ "m" => 50,
711
+ "space" => 57,
712
+ "delete" => 111,
713
+ _ => return None,
714
+ })
715
+ }
716
+
717
+ fn command_exists(program: &str) -> bool {
718
+ let Some(paths) = env::var_os("PATH") else {
719
+ return false;
720
+ };
721
+ env::split_paths(&paths).any(|path| is_executable(path.join(program)))
722
+ }
723
+
724
+ fn is_executable(path: PathBuf) -> bool {
725
+ fs::metadata(path)
726
+ .map(|metadata| metadata.is_file())
727
+ .unwrap_or(false)
728
+ }
729
+
730
+ fn run_command(plan: &CommandPlan) -> Result<(), AtspiError> {
731
+ let status = Command::new(&plan.program)
732
+ .args(&plan.args)
733
+ .status()
734
+ .map_err(|err| {
735
+ AtspiError::unavailable(format!("failed to start {}: {err}", plan.program))
736
+ })?;
737
+ if status.success() {
738
+ Ok(())
739
+ } else {
740
+ Err(AtspiError::unavailable(format!(
741
+ "{} exited with status {status}",
742
+ plan.program
743
+ )))
744
+ }
745
+ }
746
+
747
+ #[cfg(test)]
748
+ mod tests {
749
+ use serde_json::Value;
750
+
751
+ use super::*;
752
+ use crate::errors::IntoSidecarResponse;
753
+
754
+ #[test]
755
+ fn display_server_prefers_wayland_over_x11() {
756
+ let server =
757
+ display_server_from_pairs([("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":1")]);
758
+
759
+ assert_eq!(server, DisplayServer::Wayland);
760
+ }
761
+
762
+ #[test]
763
+ fn display_server_detects_x11_when_wayland_is_absent() {
764
+ let server = display_server_from_pairs([("DISPLAY", ":1")]);
765
+
766
+ assert_eq!(server, DisplayServer::X11);
767
+ }
768
+
769
+ #[test]
770
+ fn display_server_is_headless_without_display_environment() {
771
+ let server = display_server_from_pairs([]);
772
+
773
+ assert_eq!(server, DisplayServer::Headless);
774
+ }
775
+
776
+ #[test]
777
+ fn x11_press_uses_xdotool_key() {
778
+ let plan = press_command_for(DisplayServer::X11, "ctrl+s", |program| program == "xdotool")
779
+ .expect("xdotool plan");
780
+
781
+ assert_eq!(plan.program, "xdotool");
782
+ assert_eq!(plan.args, vec!["key", "ctrl+s"]);
783
+ }
784
+
785
+ #[test]
786
+ fn wayland_printable_press_uses_wtype() {
787
+ let plan = press_command_for(DisplayServer::Wayland, "a", |program| program == "wtype")
788
+ .expect("wtype plan");
789
+
790
+ assert_eq!(plan.program, "wtype");
791
+ assert_eq!(plan.args, vec!["a"]);
792
+ }
793
+
794
+ #[test]
795
+ fn wayland_modifier_press_uses_ydotool_scancodes() {
796
+ let plan = press_command_for(DisplayServer::Wayland, "ctrl+shift+p", |program| {
797
+ program == "ydotool"
798
+ })
799
+ .expect("ydotool plan");
800
+
801
+ assert_eq!(plan.program, "ydotool");
802
+ assert_eq!(
803
+ plan.args,
804
+ vec!["key", "29:1", "42:1", "25:1", "25:0", "42:0", "29:0"]
805
+ );
806
+ }
807
+
808
+ #[test]
809
+ fn wayland_modifier_press_requires_ydotool() {
810
+ let error = press_command_for(DisplayServer::Wayland, "ctrl+s", |program| {
811
+ program == "wtype"
812
+ })
813
+ .expect_err("modifier combo without ydotool should fail");
814
+ let response = Err::<Value, _>(error).into_response(5, "atspi_press".into());
815
+ let envelope = response.error.expect("error envelope");
816
+
817
+ assert_eq!(envelope.minimum_capability, "desktop-atspi.wayland-input");
818
+ }
819
+
820
+ #[test]
821
+ fn x11_text_uses_xdotool_type() {
822
+ let plan = text_command_for(DisplayServer::X11, "hello", |program| program == "xdotool")
823
+ .expect("xdotool type plan");
824
+
825
+ assert_eq!(plan.program, "xdotool");
826
+ assert_eq!(plan.args, vec!["type", "--clearmodifiers", "hello"]);
827
+ }
828
+
829
+ #[test]
830
+ fn wayland_text_uses_wtype() {
831
+ let plan = text_command_for(DisplayServer::Wayland, "hello", |program| {
832
+ program == "wtype"
833
+ })
834
+ .expect("wtype text plan");
835
+
836
+ assert_eq!(plan.program, "wtype");
837
+ assert_eq!(plan.args, vec!["hello"]);
838
+ }
839
+
840
+ #[test]
841
+ fn missing_wayland_text_helper_returns_semantic_error() {
842
+ let error = text_command_for(DisplayServer::Wayland, "hello", |_| false)
843
+ .expect_err("missing wtype should return an error");
844
+ let response = Err::<Value, _>(error).into_response(6, "atspi_set_value".into());
845
+ let envelope = response.error.expect("error envelope");
846
+
847
+ assert_eq!(envelope.minimum_capability, "desktop-atspi.wayland-input");
848
+ }
849
+
850
+ #[test]
851
+ fn x11_scroll_down_uses_xdotool_wheel_button() {
852
+ let plan = scroll_command_for(DisplayServer::X11, "down", 300, |program| {
853
+ program == "xdotool"
854
+ })
855
+ .expect("xdotool scroll plan");
856
+
857
+ assert_eq!(plan.program, "xdotool");
858
+ assert_eq!(plan.args, vec!["click", "--repeat", "3", "5"]);
859
+ }
860
+
861
+ #[test]
862
+ fn x11_scroll_up_uses_xdotool_wheel_button() {
863
+ let plan = scroll_command_for(DisplayServer::X11, "up", 120, |program| {
864
+ program == "xdotool"
865
+ })
866
+ .expect("xdotool scroll plan");
867
+
868
+ assert_eq!(plan.program, "xdotool");
869
+ assert_eq!(plan.args, vec!["click", "--repeat", "1", "4"]);
870
+ }
871
+
872
+ #[test]
873
+ fn wayland_scroll_down_uses_ydotool_wheel_button() {
874
+ let plan = scroll_command_for(DisplayServer::Wayland, "down", 240, |program| {
875
+ program == "ydotool"
876
+ })
877
+ .expect("ydotool scroll plan");
878
+
879
+ assert_eq!(plan.program, "ydotool");
880
+ assert_eq!(plan.args, vec!["click", "5", "5"]);
881
+ }
882
+
883
+ #[test]
884
+ fn scroll_response_includes_target_window_metadata() {
885
+ let response = scroll_response_for_window(
886
+ &crate::tree::WindowRecord {
887
+ id: "0x03a00008".into(),
888
+ pid: 1234,
889
+ title: "Terminal Settings".into(),
890
+ desktop: "0".into(),
891
+ host: "host".into(),
892
+ bounds: None,
893
+ children: vec![],
894
+ },
895
+ "desktop-atspi:pid-1234:Window[1]",
896
+ serde_json::json!({
897
+ "scrolled": true,
898
+ "direction": "down",
899
+ "amount": 240,
900
+ "backend": "ydotool",
901
+ }),
902
+ );
903
+
904
+ assert_eq!(
905
+ response,
906
+ serde_json::json!({
907
+ "scrolled": true,
908
+ "via": "top_level_window_scroll_helper",
909
+ "stable": "desktop-atspi:pid-1234:Window[1]",
910
+ "id": "0x03a00008",
911
+ "pid": 1234,
912
+ "title": "Terminal Settings",
913
+ "scroll": {
914
+ "scrolled": true,
915
+ "direction": "down",
916
+ "amount": 240,
917
+ "backend": "ydotool",
918
+ },
919
+ }),
920
+ );
921
+ }
922
+
923
+ #[test]
924
+ fn native_component_scroll_type_maps_direction_to_edge() {
925
+ assert_eq!(
926
+ native_component_scroll_type_for_direction("down").expect("down type"),
927
+ NativeComponentScrollType::Bottom,
928
+ );
929
+ assert_eq!(
930
+ native_component_scroll_type_for_direction("left").expect("left type"),
931
+ NativeComponentScrollType::Left,
932
+ );
933
+ assert_eq!(
934
+ native_component_scroll_type_name(NativeComponentScrollType::Bottom),
935
+ "BottomEdge",
936
+ );
937
+ }
938
+
939
+ #[test]
940
+ fn scroll_response_can_report_native_descendant_component_scroll() {
941
+ let response = scroll_response_for_native_descendant(
942
+ &crate::tree::WindowRecord {
943
+ id: "0x03a00008".into(),
944
+ pid: 1234,
945
+ title: "Terminal Settings".into(),
946
+ desktop: "0".into(),
947
+ host: "host".into(),
948
+ bounds: None,
949
+ children: vec![],
950
+ },
951
+ &crate::tree::ElementRecord {
952
+ role: "scroll_pane".into(),
953
+ name: "Output".into(),
954
+ value: None,
955
+ bounds: None,
956
+ states: vec!["enabled".into()],
957
+ children: vec![],
958
+ },
959
+ "desktop-atspi:pid-1234:Window[1]/scroll_pane[0]",
960
+ "Window[1]/scroll_pane[0]",
961
+ "down",
962
+ 300,
963
+ serde_json::json!({
964
+ "scrolled": true,
965
+ "type": "BottomEdge",
966
+ }),
967
+ );
968
+
969
+ assert_eq!(response["scrolled"], true);
970
+ assert_eq!(response["via"], "atspi_component_scroll_to");
971
+ assert_eq!(response["target"]["role"], "scroll_pane");
972
+ assert_eq!(response["scroll"]["type"], "BottomEdge");
973
+ }
974
+
975
+ #[test]
976
+ fn missing_x11_scroll_helper_returns_semantic_error() {
977
+ let error = scroll_command_for(DisplayServer::X11, "down", 300, |_| false)
978
+ .expect_err("missing xdotool should return an error");
979
+ let response = Err::<Value, _>(error).into_response(7, "atspi_scroll".into());
980
+ let envelope = response.error.expect("error envelope");
981
+
982
+ assert_eq!(envelope.minimum_capability, "desktop-atspi.x11-input");
983
+ }
984
+
985
+ #[test]
986
+ fn missing_x11_helper_returns_semantic_error() {
987
+ let error = press_command_for(DisplayServer::X11, "ctrl+s", |_| false)
988
+ .expect_err("missing helper should return an error");
989
+ let response = Err::<Value, _>(error).into_response(3, "atspi_press".into());
990
+ let envelope = response.error.expect("error envelope");
991
+
992
+ assert_eq!(envelope.minimum_capability, "desktop-atspi.x11-input");
993
+ }
994
+
995
+ #[test]
996
+ fn missing_wayland_helper_returns_semantic_error() {
997
+ let error = press_command_for(DisplayServer::Wayland, "a", |_| false)
998
+ .expect_err("missing helper should return an error");
999
+ let response = Err::<Value, _>(error).into_response(4, "atspi_press".into());
1000
+ let envelope = response.error.expect("error envelope");
1001
+
1002
+ assert_eq!(envelope.minimum_capability, "desktop-atspi.wayland-input");
1003
+ }
1004
+ }