1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4 proxy::{
5 accessible::{AccessibleProxy, ObjectRefExt},
6 proxy_ext::ProxyExt,
7 },
8 CoordType, ObjectRef, ObjectRefOwned, StateSet,
9};
10use atspi_connection::AccessibilityConnection;
13use schemars::JsonSchema;
14use serde::Serialize;
15use std::collections::VecDeque;
16use zbus::{
17 fdo::DBusProxy,
18 names::{BusName, UniqueName},
19 zvariant::ObjectPath,
20};
21
22#[derive(Debug, Clone, Serialize, JsonSchema)]
23pub struct AccessibleAppSummary {
24 pub object_ref: String,
25 pub name: Option<String>,
26 pub pid: Option<u32>,
27 pub role: String,
28 pub child_count: i32,
29 pub bounds: Option<Bounds>,
30}
31
32#[derive(Debug, Clone, Serialize, JsonSchema)]
33pub struct AccessibilityNode {
34 pub index: u32,
35 pub parent_index: Option<u32>,
36 pub depth: u32,
37 pub object_ref: String,
38 pub role: String,
39 pub name: Option<String>,
40 pub description: Option<String>,
41 pub child_count: i32,
42 pub bounds: Option<Bounds>,
43 pub states: Vec<String>,
44 pub actions: Vec<AccessibilityAction>,
45 pub value: Option<AccessibilityValue>,
46 pub text: Option<AccessibilityText>,
47 pub supports_editable_text: bool,
48}
49
50#[derive(Debug, Clone, Serialize, JsonSchema)]
51pub struct Bounds {
52 pub x: i32,
53 pub y: i32,
54 pub width: i32,
55 pub height: i32,
56}
57
58#[derive(Debug, Clone, Serialize, JsonSchema)]
59pub struct AccessibilityAction {
60 pub index: i32,
61 pub name: String,
62 pub description: String,
63 pub keybinding: String,
64}
65
66#[derive(Debug, Clone, Serialize, JsonSchema)]
67pub struct AccessibilityValue {
68 pub current: f64,
69 pub minimum: f64,
70 pub maximum: f64,
71 pub minimum_increment: f64,
72 pub text: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, JsonSchema)]
76pub struct AccessibilityText {
77 pub character_count: i32,
78 pub caret_offset: Option<i32>,
79 pub content: Option<String>,
80 pub truncated: bool,
81 pub selections: Vec<AccessibilityTextSelection>,
82}
83
84#[derive(Debug, Clone, Serialize, JsonSchema)]
85pub struct AccessibilityTextSelection {
86 pub start_offset: i32,
87 pub end_offset: i32,
88}
89
90#[derive(Debug, Clone)]
91pub struct ActionInvocation {
92 pub action_index: i32,
93 pub action_name: Option<String>,
94 pub ok: bool,
95}
96
97#[derive(Debug, Clone)]
98pub enum ValueSetInvocation {
99 Numeric { value: f64 },
100 EditableText,
101}
102
103const MAX_TEXT_READBACK_CHARS: i32 = 4096;
104const MAX_TEXT_SELECTIONS: i32 = 8;
105
106pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
107 let conn = connect().await?;
108 let roots = registry_children(&conn).await?;
109 let dbus = DBusProxy::new(conn.connection()).await.ok();
110 let mut apps = Vec::new();
111
112 for object_ref in roots.into_iter().take(limit) {
113 if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
114 apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
115 }
116 }
117
118 Ok(apps)
119}
120
121pub async fn snapshot_tree(
122 app_name_or_bundle_identifier: Option<&str>,
123 target_pid: Option<u32>,
124 max_nodes: usize,
125 max_depth: u32,
126) -> Result<Vec<AccessibilityNode>> {
127 let conn = connect().await?;
128 let roots = registry_children(&conn).await?;
129 let selected_roots =
130 select_roots(&conn, roots, app_name_or_bundle_identifier, target_pid).await;
131 let mut nodes = Vec::new();
132 let mut queue = VecDeque::new();
133
134 for object_ref in selected_roots {
135 queue.push_back((object_ref, 0_u32, None));
136 }
137
138 while let Some((object_ref, depth, parent_index)) = queue.pop_front() {
139 if nodes.len() >= max_nodes {
140 break;
141 }
142
143 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
144 continue;
145 };
146 let index = nodes.len() as u32;
147 let child_refs = if depth < max_depth {
148 proxy.get_children().await.unwrap_or_default()
149 } else {
150 Vec::new()
151 };
152
153 nodes.push(read_node(&proxy, &object_ref, index, parent_index, depth).await);
154
155 for child in child_refs {
156 queue.push_back((child, depth + 1, Some(index)));
157 }
158 }
159
160 Ok(nodes)
161}
162
163#[derive(Debug, Clone, Serialize, JsonSchema)]
166pub struct FocusedElementSummary {
167 pub role: String,
168 pub name: Option<String>,
169 pub editable: bool,
170 pub states: Vec<String>,
171}
172
173const FOCUS_PROBE_MAX_NODES: usize = 400;
174const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
175
176pub async fn focused_element_summary(
181 target_pid: Option<u32>,
182) -> Result<Option<FocusedElementSummary>> {
183 let conn = connect().await?;
184 let roots = registry_children(&conn).await?;
185 let selected_roots = select_roots(&conn, roots, None, target_pid).await;
186 let mut visited = 0_usize;
187 let mut queue = VecDeque::new();
188
189 for object_ref in selected_roots {
190 queue.push_back((object_ref, 0_u32));
191 }
192
193 while let Some((object_ref, depth)) = queue.pop_front() {
194 if visited >= FOCUS_PROBE_MAX_NODES {
195 break;
196 }
197 visited += 1;
198
199 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
200 continue;
201 };
202 let Ok(state) = proxy.get_state().await else {
203 continue;
204 };
205 if state.contains(atspi::State::Focused) {
206 let proxies = proxy.proxies().await.ok();
207 return Ok(Some(FocusedElementSummary {
208 role: role_name(&proxy).await,
209 name: optional_string(proxy.name().await.ok()),
210 editable: supports_editable_text(proxies.as_ref()).await,
211 states: state_labels(state),
212 }));
213 }
214 if depth < FOCUS_PROBE_MAX_DEPTH {
215 for child in proxy.get_children().await.unwrap_or_default() {
216 queue.push_back((child, depth + 1));
217 }
218 }
219 }
220
221 Ok(None)
222}
223
224pub async fn perform_action(
225 object_ref_id: &str,
226 requested_action: Option<&str>,
227) -> Result<ActionInvocation> {
228 let conn = connect().await?;
229 let object_ref = object_ref_from_id(object_ref_id)?;
230 let proxy = open_accessible(&conn, &object_ref)
231 .await
232 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
233 let action = proxy
234 .proxies()
235 .await?
236 .action()
237 .await
238 .context("element does not expose the AT-SPI Action interface")?;
239 let actions = action.get_actions().await.unwrap_or_default();
240 let action_index = select_action_index(&actions, requested_action)?;
241 let action_name = actions
242 .get(action_index as usize)
243 .map(|action| action.name.clone());
244 let ok = action
245 .do_action(action_index)
246 .await
247 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
248
249 Ok(ActionInvocation {
250 action_index,
251 action_name,
252 ok,
253 })
254}
255
256pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
257 let conn = connect().await?;
258 let object_ref = object_ref_from_id(object_ref_id)?;
259 let proxy = open_accessible(&conn, &object_ref)
260 .await
261 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
262 let proxies = proxy.proxies().await?;
263
264 if let Ok(numeric_value) = value.parse::<f64>() {
265 if let Ok(value_proxy) = proxies.value().await {
266 value_proxy
267 .set_current_value(numeric_value)
268 .await
269 .with_context(|| {
270 format!("failed to set AT-SPI numeric value to {numeric_value}")
271 })?;
272 return Ok(ValueSetInvocation::Numeric {
273 value: numeric_value,
274 });
275 }
276 }
277
278 if let Ok(editable_text) = proxies.editable_text().await {
279 let ok = editable_text
280 .set_text_contents(value)
281 .await
282 .context("failed to set AT-SPI editable text contents")?;
283 if ok {
284 return Ok(ValueSetInvocation::EditableText);
285 }
286 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
287 }
288
289 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
290 return Err(anyhow!(
291 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
292 ));
293 }
294
295 Err(anyhow!(
296 "element does not expose AT-SPI Value or EditableText interfaces"
297 ))
298}
299
300async fn connect() -> Result<AccessibilityConnection> {
301 hydrate_session_bus_env();
302 AccessibilityConnection::new()
303 .await
304 .context("failed to connect to AT-SPI bus")
305}
306
307async fn open_accessible<'r>(
320 conn: &AccessibilityConnection,
321 object_ref: &'r ObjectRefOwned,
322) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
323 object_ref.as_accessible_proxy(conn.connection()).await
324}
325
326async fn registry_children(conn: &AccessibilityConnection) -> Result<Vec<ObjectRefOwned>> {
327 let root = conn
328 .root_accessible_on_registry()
329 .await
330 .context("failed to open AT-SPI registry root")?;
331 root.get_children()
332 .await
333 .context("failed to read AT-SPI registry children")
334}
335
336async fn select_roots(
337 conn: &AccessibilityConnection,
338 roots: Vec<ObjectRefOwned>,
339 app_name_or_bundle_identifier: Option<&str>,
340 target_pid: Option<u32>,
341) -> Vec<ObjectRefOwned> {
342 let needle = app_name_or_bundle_identifier
343 .map(str::trim)
344 .filter(|value| !value.is_empty())
345 .map(|value| value.to_ascii_lowercase());
346 let dbus = DBusProxy::new(conn.connection()).await.ok();
347 let mut remaining = roots;
348
349 if let Some(target_pid) = target_pid {
350 let mut pid_and_filter_matches = Vec::new();
351 let mut pid_matches = Vec::new();
352 let mut non_pid_matches = Vec::new();
353
354 for object_ref in remaining {
355 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
356 if let Some(needle) = needle.as_deref() {
357 if root_matches(conn, &object_ref, needle).await {
358 pid_and_filter_matches.push(object_ref);
359 } else {
360 pid_matches.push(object_ref);
361 }
362 } else {
363 pid_matches.push(object_ref);
364 }
365 } else {
366 non_pid_matches.push(object_ref);
367 }
368 }
369
370 if !pid_and_filter_matches.is_empty() {
371 return pid_and_filter_matches;
372 }
373 if !pid_matches.is_empty() {
374 return pid_matches;
375 }
376
377 remaining = non_pid_matches;
378 }
379
380 let Some(needle) = needle.as_deref() else {
381 return remaining;
382 };
383
384 let mut selected = Vec::new();
385 for object_ref in remaining {
386 if root_matches(conn, &object_ref, needle).await {
387 selected.push(object_ref);
388 }
389 }
390
391 selected
392}
393
394async fn root_matches(
395 conn: &AccessibilityConnection,
396 object_ref: &ObjectRefOwned,
397 needle: &str,
398) -> bool {
399 let Ok(proxy) = open_accessible(conn, object_ref).await else {
400 return object_ref_id(object_ref)
401 .to_ascii_lowercase()
402 .contains(needle);
403 };
404
405 if proxy_matches(&proxy, object_ref, needle).await {
406 return true;
407 }
408
409 let children = proxy.get_children().await.unwrap_or_default();
410 for child_ref in children.into_iter().take(8) {
411 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
412 continue;
413 };
414 if proxy_matches(&child_proxy, &child_ref, needle).await {
415 return true;
416 }
417 }
418
419 false
420}
421
422async fn proxy_matches(
423 proxy: &AccessibleProxy<'_>,
424 object_ref: &ObjectRefOwned,
425 needle: &str,
426) -> bool {
427 let name = proxy.name().await.unwrap_or_default();
428 let role = proxy.get_role_name().await.unwrap_or_default();
429 format!("{} {} {}", object_ref_id(object_ref), name, role)
430 .to_ascii_lowercase()
431 .contains(needle)
432}
433
434async fn read_app_summary(
435 proxy: &AccessibleProxy<'_>,
436 object_ref: &ObjectRefOwned,
437 dbus: Option<&DBusProxy<'_>>,
438) -> AccessibleAppSummary {
439 AccessibleAppSummary {
440 object_ref: object_ref_id(object_ref),
441 name: optional_string(proxy.name().await.ok()),
442 pid: object_ref_pid(dbus, object_ref).await,
443 role: role_name(proxy).await,
444 child_count: proxy.child_count().await.unwrap_or_default(),
445 bounds: bounds(proxy).await,
446 }
447}
448
449async fn read_node(
450 proxy: &AccessibleProxy<'_>,
451 object_ref: &ObjectRefOwned,
452 index: u32,
453 parent_index: Option<u32>,
454 depth: u32,
455) -> AccessibilityNode {
456 let proxies = proxy.proxies().await.ok();
457
458 AccessibilityNode {
459 index,
460 parent_index,
461 depth,
462 object_ref: object_ref_id(object_ref),
463 role: role_name(proxy).await,
464 name: optional_string(proxy.name().await.ok()),
465 description: optional_string(proxy.description().await.ok()),
466 child_count: proxy.child_count().await.unwrap_or_default(),
467 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
468 states: states_from_proxy(proxy).await,
469 actions: actions_from_proxies(proxies.as_ref()).await,
470 value: value_from_proxies(proxies.as_ref()).await,
471 text: text_from_proxies(proxies.as_ref()).await,
472 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
473 }
474}
475
476async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
477 if let Ok(role) = proxy.get_role_name().await {
478 if !role.trim().is_empty() {
479 return role;
480 }
481 }
482 proxy
483 .get_role()
484 .await
485 .map(|role| format!("{role:?}"))
486 .unwrap_or_else(|_| "unknown".to_string())
487}
488
489async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
490 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
491}
492
493async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
494 let dbus = dbus?;
495 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
496 dbus.get_connection_unix_process_id(bus_name).await.ok()
497}
498
499async fn bounds_from_proxies(
500 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
501 proxy: &AccessibleProxy<'_>,
502) -> Option<Bounds> {
503 let owned_proxies;
504 let proxies = if let Some(proxies) = proxies {
505 proxies
506 } else {
507 owned_proxies = proxy.proxies().await.ok()?;
508 &owned_proxies
509 };
510 let component = proxies.component().await.ok()?;
511 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
512 normalize_bounds(Bounds {
513 x,
514 y,
515 width,
516 height,
517 })
518}
519
520fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
521 if bounds.width <= 0 || bounds.height <= 0 {
522 return None;
523 }
524 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
525 return None;
526 }
527 Some(bounds)
528}
529
530async fn actions_from_proxies(
531 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
532) -> Vec<AccessibilityAction> {
533 let Some(proxies) = proxies else {
534 return Vec::new();
535 };
536 let Ok(action_proxy) = proxies.action().await else {
537 return Vec::new();
538 };
539
540 action_proxy
541 .get_actions()
542 .await
543 .unwrap_or_default()
544 .into_iter()
545 .enumerate()
546 .map(|(index, action)| AccessibilityAction {
547 index: index as i32,
548 name: action.name,
549 description: action.description,
550 keybinding: action.keybinding,
551 })
552 .collect()
553}
554
555async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
556 proxy
557 .get_state()
558 .await
559 .map(state_labels)
560 .unwrap_or_default()
561}
562
563async fn value_from_proxies(
564 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
565) -> Option<AccessibilityValue> {
566 let value = proxies?.value().await.ok()?;
567 Some(AccessibilityValue {
568 current: value.current_value().await.ok()?,
569 minimum: value.minimum_value().await.ok()?,
570 maximum: value.maximum_value().await.ok()?,
571 minimum_increment: value.minimum_increment().await.ok()?,
572 text: optional_string(value.text().await.ok()),
573 })
574}
575
576async fn text_from_proxies(
577 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
578) -> Option<AccessibilityText> {
579 let text = proxies?.text().await.ok()?;
580 let character_count = text.character_count().await.ok()?.max(0);
581 let caret_offset = text.caret_offset().await.ok();
582 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
583 let content = if capped_count > 0 {
584 optional_string(text.get_text(0, capped_count).await.ok())
585 } else {
586 None
587 };
588 let selection_count = text
589 .get_nselections()
590 .await
591 .unwrap_or_default()
592 .clamp(0, MAX_TEXT_SELECTIONS);
593 let mut selections = Vec::new();
594 for index in 0..selection_count {
595 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
596 selections.push(AccessibilityTextSelection {
597 start_offset,
598 end_offset,
599 });
600 }
601 }
602
603 Some(AccessibilityText {
604 character_count,
605 caret_offset,
606 content,
607 truncated: character_count > MAX_TEXT_READBACK_CHARS,
608 selections,
609 })
610}
611
612async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
613 let Some(proxies) = proxies else {
614 return false;
615 };
616 proxies.editable_text().await.is_ok()
617}
618
619fn state_labels(state_set: StateSet) -> Vec<String> {
620 state_set.iter().map(|state| state.to_string()).collect()
621}
622
623fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
624 if actions.is_empty() {
625 return Err(anyhow!("element exposes no AT-SPI actions"));
626 }
627
628 if let Some(requested_action) = requested_action
629 .map(str::trim)
630 .filter(|value| !value.is_empty())
631 {
632 let requested_action = requested_action.to_ascii_lowercase();
633 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
634 action.name.to_ascii_lowercase() == requested_action
635 || action.description.to_ascii_lowercase() == requested_action
636 }) {
637 return Ok(index as i32);
638 }
639
640 if let Ok(index) = requested_action.parse::<usize>() {
641 if index < actions.len() {
642 return Ok(index as i32);
643 }
644 }
645
646 return Err(anyhow!(
647 "requested AT-SPI action was not found; available actions: {}",
648 actions
649 .iter()
650 .map(|action| action.name.as_str())
651 .collect::<Vec<_>>()
652 .join(", ")
653 ));
654 }
655
656 Ok(if actions.len() > 1 { 1 } else { 0 })
657}
658
659fn optional_string(value: Option<String>) -> Option<String> {
660 value
661 .map(|value| value.trim().to_string())
662 .filter(|value| !value.is_empty())
663}
664
665fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
666 let (name, path) = split_object_ref_id(object_ref_id)?;
667 let name = UniqueName::try_from(name.to_string())
668 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
669 let path = ObjectPath::try_from(path.to_string())
670 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
671 Ok(ObjectRef::new_owned(name, path))
672}
673
674fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
675 let Some(path_start) = object_ref_id.find('/') else {
676 return Err(anyhow!(
677 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
678 ));
679 };
680 let (name, path) = object_ref_id.split_at(path_start);
681 if name.is_empty() || path.is_empty() {
682 return Err(anyhow!(
683 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
684 ));
685 }
686 Ok((name, path))
687}
688
689fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
690 format!(
691 "{}{}",
692 object_ref.name_as_str().unwrap_or(""),
693 object_ref.path_as_str()
694 )
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn split_object_ref_id_separates_bus_name_and_path() {
703 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
704
705 assert_eq!(name, ":1.42");
706 assert_eq!(path, "/org/a11y/atspi/accessible/7");
707 }
708
709 #[test]
710 fn select_action_index_uses_named_action() {
711 let actions = vec![
712 atspi::Action {
713 name: "click".to_string(),
714 description: "Clicks".to_string(),
715 keybinding: String::new(),
716 },
717 atspi::Action {
718 name: "show-menu".to_string(),
719 description: "Shows menu".to_string(),
720 keybinding: String::new(),
721 },
722 ];
723
724 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
725 }
726
727 #[test]
728 fn select_action_index_defaults_to_secondary_when_available() {
729 let actions = vec![
730 atspi::Action {
731 name: "click".to_string(),
732 description: String::new(),
733 keybinding: String::new(),
734 },
735 atspi::Action {
736 name: "show-menu".to_string(),
737 description: String::new(),
738 keybinding: String::new(),
739 },
740 ];
741
742 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
743 }
744
745 #[test]
746 fn state_labels_serialize_in_bit_order() {
747 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
748
749 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
750 }
751}