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 futures_util::{stream, StreamExt};
14use schemars::JsonSchema;
15use serde::Serialize;
16use std::{collections::VecDeque, future::Future, time::Duration};
17use tokio::time::timeout;
18use zbus::{
19 fdo::DBusProxy,
20 names::{BusName, UniqueName},
21 zvariant::ObjectPath,
22};
23
24#[derive(Debug, Clone, Serialize, JsonSchema)]
25pub struct AccessibleAppSummary {
26 pub object_ref: String,
27 pub name: Option<String>,
28 pub pid: Option<u32>,
29 pub role: String,
30 pub child_count: i32,
31 pub bounds: Option<Bounds>,
32}
33
34#[derive(Debug, Clone, Serialize, JsonSchema)]
35pub struct AccessibilityNode {
36 pub index: u32,
37 pub parent_index: Option<u32>,
38 pub depth: u32,
39 pub object_ref: String,
40 pub role: String,
41 pub name: Option<String>,
42 pub description: Option<String>,
43 pub child_count: i32,
44 pub bounds: Option<Bounds>,
45 pub states: Vec<String>,
46 pub actions: Vec<AccessibilityAction>,
47 pub value: Option<AccessibilityValue>,
48 pub text: Option<AccessibilityText>,
49 pub supports_editable_text: bool,
50}
51
52#[derive(Debug, Clone, Serialize, JsonSchema)]
53pub struct Bounds {
54 pub x: i32,
55 pub y: i32,
56 pub width: i32,
57 pub height: i32,
58}
59
60#[derive(Debug, Clone, Serialize, JsonSchema)]
61pub struct AccessibilityAction {
62 pub index: i32,
63 pub name: String,
64 pub description: String,
65 pub keybinding: String,
66}
67
68#[derive(Debug, Clone, Serialize, JsonSchema)]
69pub struct AccessibilityValue {
70 pub current: f64,
71 pub minimum: f64,
72 pub maximum: f64,
73 pub minimum_increment: f64,
74 pub text: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize, JsonSchema)]
78pub struct AccessibilityText {
79 pub character_count: i32,
80 pub caret_offset: Option<i32>,
81 pub content: Option<String>,
82 pub truncated: bool,
83 pub selections: Vec<AccessibilityTextSelection>,
84}
85
86#[derive(Debug, Clone, Serialize, JsonSchema)]
87pub struct AccessibilityTextSelection {
88 pub start_offset: i32,
89 pub end_offset: i32,
90}
91
92#[derive(Debug, Clone)]
93pub struct ActionInvocation {
94 pub action_index: i32,
95 pub action_name: Option<String>,
96 pub ok: bool,
97}
98
99#[derive(Debug, Clone)]
100pub enum ValueSetInvocation {
101 Numeric { value: f64 },
102 EditableText,
103}
104
105const MAX_TEXT_READBACK_CHARS: i32 = 4096;
106const MAX_TEXT_SELECTIONS: i32 = 8;
107const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
108const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
109const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
110const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
111const CHILD_READ_CONCURRENCY: usize = 16;
112const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
113const MAX_DISCOVERY_ROOTS: usize = 256;
114const ROOT_MATCH_CHILD_LIMIT: usize = 8;
115const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
116
117fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
118 (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
119}
120
121pub(crate) fn snapshot_limits(
122 requested_max_nodes: Option<usize>,
123 requested_max_depth: Option<u32>,
124) -> (usize, u32) {
125 (
126 requested_max_nodes
127 .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
128 .clamp(1, HARD_SNAPSHOT_MAX_NODES),
129 requested_max_depth
130 .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
131 .min(HARD_SNAPSHOT_MAX_DEPTH),
132 )
133}
134
135struct BoundedTraversal<T> {
136 queue: VecDeque<T>,
137 attempted: usize,
138 max_items: usize,
139 dropped: bool,
141}
142
143impl<T> BoundedTraversal<T> {
144 fn new(max_items: usize) -> Self {
145 Self {
146 queue: VecDeque::new(),
147 attempted: 0,
148 max_items,
149 dropped: false,
150 }
151 }
152
153 fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
154 let capacity = self.remaining_capacity();
155 let mut items = items.into_iter();
156 self.queue.extend(items.by_ref().take(capacity));
157 if items.next().is_some() {
158 self.dropped = true;
159 }
160 }
161
162 fn pop(&mut self) -> Option<T> {
163 if self.attempted >= self.max_items {
164 return None;
165 }
166 let item = self.queue.pop_front()?;
167 self.attempted += 1;
168 Some(item)
169 }
170
171 fn remaining_capacity(&self) -> usize {
172 self.max_items
173 .saturating_sub(self.attempted.saturating_add(self.queue.len()))
174 }
175
176 fn truncated(&self) -> bool {
181 self.dropped
182 }
183}
184
185fn bounded_child_count(reported: i32, limit: usize) -> usize {
186 usize::try_from(reported).unwrap_or_default().min(limit)
187}
188
189struct IndexedReadBatch<T> {
190 items: Vec<T>,
191 attempted: usize,
192 incomplete: bool,
196}
197
198impl<T> IndexedReadBatch<T> {
199 fn all_failed(&self) -> bool {
200 self.attempted > 0 && self.items.is_empty()
201 }
202}
203
204async fn fetch_indexed_up_to<T, E, F, Fut>(
205 reported: i32,
206 limit: usize,
207 remaining_attempts: &mut usize,
208 fetch: F,
209) -> IndexedReadBatch<T>
210where
211 F: Fn(i32) -> Fut,
212 Fut: Future<Output = std::result::Result<T, E>>,
213{
214 let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
215 *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
216 let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
217
218 let items = stream::iter(0..end_index)
219 .map(fetch)
220 .buffered(CHILD_READ_CONCURRENCY)
221 .filter_map(|result| async move { result.ok() })
222 .collect()
223 .await;
224
225 IndexedReadBatch {
226 items,
227 attempted: attempt_count,
228 incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
229 }
230}
231
232async fn children_up_to(
233 proxy: &AccessibleProxy<'_>,
234 limit: usize,
235 remaining_attempts: &mut usize,
236) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
237 if limit == 0 || *remaining_attempts == 0 {
238 return Ok(IndexedReadBatch {
242 items: Vec::new(),
243 attempted: 0,
244 incomplete: false,
245 });
246 }
247
248 let child_count = proxy.child_count().await?;
249 Ok(
250 fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
251 proxy.get_child_at_index(index)
252 })
253 .await,
254 )
255}
256
257pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
258 let conn = connect().await?;
259 let mut remaining_child_reads = limit;
260 let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
261 let dbus = DBusProxy::new(conn.connection()).await.ok();
262 let mut apps = Vec::new();
263
264 for object_ref in roots.into_iter().take(limit) {
265 if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
266 apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
267 }
268 }
269
270 Ok(apps)
271}
272
273#[derive(Debug, Clone)]
274pub(crate) struct AccessibilitySnapshot {
275 pub nodes: Vec<AccessibilityNode>,
276 pub scoped: bool,
278 pub root_pid: Option<u32>,
282 pub truncated: bool,
285}
286
287pub async fn snapshot_tree(
288 app_name_or_bundle_identifier: Option<&str>,
289 target_pid: Option<u32>,
290 max_nodes: usize,
291 max_depth: u32,
292) -> Result<Vec<AccessibilityNode>> {
293 Ok(snapshot_accessibility_tree(
294 app_name_or_bundle_identifier,
295 target_pid,
296 max_nodes,
297 max_depth,
298 )
299 .await?
300 .nodes)
301}
302
303pub(crate) async fn snapshot_accessibility_tree(
304 app_name_or_bundle_identifier: Option<&str>,
305 target_pid: Option<u32>,
306 max_nodes: usize,
307 max_depth: u32,
308) -> Result<AccessibilitySnapshot> {
309 let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
310 timeout(
311 SNAPSHOT_TIMEOUT,
312 snapshot_tree_inner(
313 app_name_or_bundle_identifier,
314 target_pid,
315 max_nodes,
316 max_depth,
317 ),
318 )
319 .await
320 .context("AT-SPI snapshot exceeded its 10-second deadline")?
321}
322
323async fn snapshot_tree_inner(
324 app_name_or_bundle_identifier: Option<&str>,
325 target_pid: Option<u32>,
326 max_nodes: usize,
327 max_depth: u32,
328) -> Result<AccessibilitySnapshot> {
329 let conn = connect().await?;
330 let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
333 snapshot_child_read_budgets(max_nodes);
334 let roots =
335 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
336 let selected_roots = select_roots(
337 &conn,
338 roots,
339 app_name_or_bundle_identifier,
340 target_pid,
341 &mut remaining_filter_reads,
342 )
343 .await;
344 let scoped = selected_roots.scoped;
345 let root_pid = selected_roots.pid;
346 let mut nodes = Vec::new();
347 let mut truncated = false;
348 let mut traversal = BoundedTraversal::new(max_nodes);
349
350 traversal.enqueue(
351 selected_roots
352 .roots
353 .into_iter()
354 .map(|object_ref| (object_ref, 0_u32, None)),
355 );
356
357 while let Some((object_ref, depth, parent_index)) = traversal.pop() {
358 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
359 continue;
360 };
361 let index = nodes.len() as u32;
362 let remaining = traversal.remaining_capacity();
363 let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
364 let child_refs = if depth < max_depth && remaining > 0 {
365 match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
366 Ok(batch) => {
367 truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
371 batch.items
372 }
373 Err(_) => Vec::new(),
374 }
375 } else {
376 truncated |= node.child_count > 0;
378 Vec::new()
379 };
380
381 nodes.push(node);
382
383 traversal.enqueue(
384 child_refs
385 .into_iter()
386 .map(|child| (child, depth + 1, Some(index))),
387 );
388 }
389 truncated |= traversal.truncated();
390
391 Ok(AccessibilitySnapshot {
392 nodes,
393 scoped,
394 root_pid,
395 truncated,
396 })
397}
398
399#[derive(Debug, Clone, Serialize, JsonSchema)]
402pub struct FocusedElementSummary {
403 pub role: String,
404 pub name: Option<String>,
405 pub editable: bool,
406 pub states: Vec<String>,
407 #[serde(skip)]
410 #[schemars(skip)]
411 pub is_terminal: bool,
412}
413
414const FOCUS_PROBE_MAX_NODES: usize = 400;
415const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
416
417#[derive(Debug, Clone)]
423pub(crate) enum FocusProbe {
424 Found(FocusedElementSummary),
425 NoneFocused,
427 ShellOnly,
429 Incomplete,
432 NoAccessibleApp,
435}
436
437pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
441 focused_element_probe(target_pid, target_pid.is_some()).await
442}
443
444pub(crate) async fn focused_element_summary_in_app(
448 target_pid: u32,
449) -> Result<Option<FocusedElementSummary>> {
450 Ok(match focused_element_probe(Some(target_pid), true).await? {
451 FocusProbe::Found(summary) => Some(summary),
452 _ => None,
453 })
454}
455
456async fn focused_element_probe(
457 target_pid: Option<u32>,
458 require_scoped: bool,
459) -> Result<FocusProbe> {
460 let conn = connect().await?;
461 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
462 let roots =
463 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
464 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
465 let selected_roots =
466 select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
467 if require_scoped && !selected_roots.scoped {
468 return Ok(FocusProbe::NoAccessibleApp);
469 }
470 let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
471 let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
472 let mut incomplete = false;
473 let mut shell_only = true;
474 let mut observed_nodes = 0;
475
476 traversal.enqueue(
477 selected_roots
478 .roots
479 .into_iter()
480 .map(|object_ref| (object_ref, 0_u32, false)),
481 );
482
483 while let Some((object_ref, depth, in_menu)) = traversal.pop() {
484 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
485 incomplete = true;
486 continue;
487 };
488 let Ok(state) = proxy.get_state().await else {
489 incomplete = true;
490 continue;
491 };
492 observed_nodes += 1;
493 let mut child_menu = false;
494 if shell_only {
495 match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
496 Some(menu) => child_menu = menu,
497 None => shell_only = false,
498 }
499 }
500 if state.contains(atspi::State::Focused) {
501 let proxies = proxy.proxies().await.ok();
502 let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
503 return Ok(FocusProbe::Found(FocusedElementSummary {
504 role: role_name(&proxy).await,
505 name: optional_string(proxy.name().await.ok()),
506 editable: supports_editable_text(proxies.as_ref()).await,
507 states: state_labels(state),
508 is_terminal,
509 }));
510 }
511 let remaining = traversal.remaining_capacity();
512 if depth < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
513 if let Ok(batch) =
514 children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
515 {
516 incomplete |= batch.incomplete;
517 if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
522 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
523 }
524 traversal.enqueue(
525 batch
526 .items
527 .into_iter()
528 .map(|child| (child, depth + 1, child_menu)),
529 );
530 } else {
531 incomplete = true;
532 }
533 } else if !incomplete {
534 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
537 }
538 }
539 if incomplete || traversal.truncated() {
540 return Ok(FocusProbe::Incomplete);
541 }
542
543 Ok(if require_scoped && shell_only && observed_nodes > 0 {
544 FocusProbe::ShellOnly
545 } else {
546 FocusProbe::NoneFocused
547 })
548}
549
550pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
553 match role {
554 "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
555 "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
556 | "tearoff menu item" => Some(true),
557 "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
559 _ => None,
560 }
561}
562
563pub async fn perform_action(
564 object_ref_id: &str,
565 requested_action: Option<&str>,
566) -> Result<ActionInvocation> {
567 perform_action_inner(object_ref_id, requested_action, false).await
568}
569
570pub(crate) async fn perform_named_action(
571 object_ref_id: &str,
572 name: &str,
573) -> Result<ActionInvocation> {
574 perform_action_inner(object_ref_id, Some(name), true).await
575}
576
577async fn perform_action_inner(
578 object_ref_id: &str,
579 requested_action: Option<&str>,
580 name_only: bool,
581) -> Result<ActionInvocation> {
582 let conn = connect().await?;
583 let object_ref = object_ref_from_id(object_ref_id)?;
584 let proxy = open_accessible(&conn, &object_ref)
585 .await
586 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
587 let action = proxy
588 .proxies()
589 .await?
590 .action()
591 .await
592 .context("element does not expose the AT-SPI Action interface")?;
593 let actions = action.get_actions().await.unwrap_or_default();
594 let action_index = if name_only {
595 select_named_action_index(&actions, requested_action.unwrap_or_default())?
596 } else {
597 select_action_index(&actions, requested_action)?
598 };
599 let action_name = actions
600 .get(action_index as usize)
601 .map(|action| action.name.clone());
602 let ok = action
603 .do_action(action_index)
604 .await
605 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
606
607 Ok(ActionInvocation {
608 action_index,
609 action_name,
610 ok,
611 })
612}
613
614pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
615 let conn = connect().await?;
616 let object_ref = object_ref_from_id(object_ref_id)?;
617 let proxy = open_accessible(&conn, &object_ref)
618 .await
619 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
620 let proxies = proxy.proxies().await?;
621
622 if let Ok(numeric_value) = value.parse::<f64>() {
623 if let Ok(value_proxy) = proxies.value().await {
624 value_proxy
625 .set_current_value(numeric_value)
626 .await
627 .with_context(|| {
628 format!("failed to set AT-SPI numeric value to {numeric_value}")
629 })?;
630 return Ok(ValueSetInvocation::Numeric {
631 value: numeric_value,
632 });
633 }
634 }
635
636 if let Ok(editable_text) = proxies.editable_text().await {
637 let ok = editable_text
638 .set_text_contents(value)
639 .await
640 .context("failed to set AT-SPI editable text contents")?;
641 if ok {
642 return Ok(ValueSetInvocation::EditableText);
643 }
644 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
645 }
646
647 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
648 return Err(anyhow!(
649 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
650 ));
651 }
652
653 Err(anyhow!(
654 "element does not expose AT-SPI Value or EditableText interfaces"
655 ))
656}
657
658async fn connect() -> Result<AccessibilityConnection> {
659 hydrate_session_bus_env();
660 AccessibilityConnection::new()
661 .await
662 .context("failed to connect to AT-SPI bus")
663}
664
665async fn open_accessible<'r>(
678 conn: &AccessibilityConnection,
679 object_ref: &'r ObjectRefOwned,
680) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
681 object_ref.as_accessible_proxy(conn.connection()).await
682}
683
684async fn registry_children(
685 conn: &AccessibilityConnection,
686 limit: usize,
687 remaining_child_reads: &mut usize,
688) -> Result<Vec<ObjectRefOwned>> {
689 let root = conn
690 .root_accessible_on_registry()
691 .await
692 .context("failed to open AT-SPI registry root")?;
693 let batch = children_up_to(&root, limit, remaining_child_reads)
694 .await
695 .context("failed to read AT-SPI registry children")?;
696 if batch.all_failed() {
697 return Err(anyhow!(
698 "AT-SPI registry reported children, but every indexed child read failed"
699 ));
700 }
701 Ok(batch.items)
702}
703
704struct SelectedRoots {
705 roots: Vec<ObjectRefOwned>,
706 scoped: bool,
707 pid: Option<u32>,
709}
710
711fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
714 app_name_or_bundle_identifier
715 .map(str::trim)
716 .filter(|value| !value.is_empty())
717 .map(|value| value.to_ascii_lowercase())
718}
719
720async fn select_roots(
721 conn: &AccessibilityConnection,
722 roots: Vec<ObjectRefOwned>,
723 app_name_or_bundle_identifier: Option<&str>,
724 target_pid: Option<u32>,
725 remaining_child_reads: &mut usize,
726) -> SelectedRoots {
727 let needle = app_name_needle(app_name_or_bundle_identifier);
728 let dbus = DBusProxy::new(conn.connection()).await.ok();
729 let mut remaining = roots;
730
731 if let Some(target_pid) = target_pid {
732 let mut pid_and_filter_matches = Vec::new();
733 let mut pid_matches = Vec::new();
734 let mut non_pid_matches = Vec::new();
735
736 for object_ref in remaining {
737 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
738 if let Some(needle) = needle.as_deref() {
739 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
740 pid_and_filter_matches.push(object_ref);
741 } else {
742 pid_matches.push(object_ref);
743 }
744 } else {
745 pid_matches.push(object_ref);
746 }
747 } else {
748 non_pid_matches.push(object_ref);
749 }
750 }
751
752 if !pid_and_filter_matches.is_empty() {
753 return SelectedRoots {
754 roots: pid_and_filter_matches,
755 scoped: true,
756 pid: Some(target_pid),
757 };
758 }
759 if !pid_matches.is_empty() {
760 return SelectedRoots {
761 roots: pid_matches,
762 scoped: true,
763 pid: Some(target_pid),
764 };
765 }
766
767 remaining = non_pid_matches;
768 }
769
770 let Some(needle) = needle.as_deref() else {
771 return SelectedRoots {
772 roots: remaining,
773 scoped: false,
774 pid: None,
775 };
776 };
777
778 let mut selected = Vec::new();
779 for object_ref in remaining {
780 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
781 selected.push(object_ref);
782 }
783 }
784
785 SelectedRoots {
786 roots: selected,
787 scoped: true,
788 pid: None,
789 }
790}
791
792async fn root_matches(
793 conn: &AccessibilityConnection,
794 object_ref: &ObjectRefOwned,
795 needle: &str,
796 remaining_child_reads: &mut usize,
797) -> bool {
798 let Ok(proxy) = open_accessible(conn, object_ref).await else {
799 return object_ref_id(object_ref)
800 .to_ascii_lowercase()
801 .contains(needle);
802 };
803
804 if proxy_matches(&proxy, object_ref, needle).await {
805 return true;
806 }
807
808 for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
809 .await
810 .map(|batch| batch.items)
811 .unwrap_or_default()
812 {
813 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
814 continue;
815 };
816 if proxy_matches(&child_proxy, &child_ref, needle).await {
817 return true;
818 }
819 }
820
821 false
822}
823
824async fn proxy_matches(
825 proxy: &AccessibleProxy<'_>,
826 object_ref: &ObjectRefOwned,
827 needle: &str,
828) -> bool {
829 let name = proxy.name().await.unwrap_or_default();
830 let role = proxy.get_role_name().await.unwrap_or_default();
831 format!("{} {} {}", object_ref_id(object_ref), name, role)
832 .to_ascii_lowercase()
833 .contains(needle)
834}
835
836async fn read_app_summary(
837 proxy: &AccessibleProxy<'_>,
838 object_ref: &ObjectRefOwned,
839 dbus: Option<&DBusProxy<'_>>,
840) -> AccessibleAppSummary {
841 AccessibleAppSummary {
842 object_ref: object_ref_id(object_ref),
843 name: optional_string(proxy.name().await.ok()),
844 pid: object_ref_pid(dbus, object_ref).await,
845 role: role_name(proxy).await,
846 child_count: proxy.child_count().await.unwrap_or_default(),
847 bounds: bounds(proxy).await,
848 }
849}
850
851async fn read_node(
852 proxy: &AccessibleProxy<'_>,
853 object_ref: &ObjectRefOwned,
854 index: u32,
855 parent_index: Option<u32>,
856 depth: u32,
857) -> AccessibilityNode {
858 let proxies = proxy.proxies().await.ok();
859
860 AccessibilityNode {
861 index,
862 parent_index,
863 depth,
864 object_ref: object_ref_id(object_ref),
865 role: role_name(proxy).await,
866 name: optional_string(proxy.name().await.ok()),
867 description: optional_string(proxy.description().await.ok()),
868 child_count: proxy.child_count().await.unwrap_or_default(),
869 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
870 states: states_from_proxy(proxy).await,
871 actions: actions_from_proxies(proxies.as_ref()).await,
872 value: value_from_proxies(proxies.as_ref()).await,
873 text: text_from_proxies(proxies.as_ref()).await,
874 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
875 }
876}
877
878pub(crate) const UNKNOWN_ROLE: &str = "unknown";
880
881async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
882 if let Ok(role) = proxy.get_role_name().await {
883 if !role.trim().is_empty() {
884 return role;
885 }
886 }
887 proxy
888 .get_role()
889 .await
890 .map(|role| format!("{role:?}"))
891 .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
892}
893
894async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
895 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
896}
897
898pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
901 let object_ref = object_ref_from_id(object_ref_id)?;
902 let conn = connect().await?;
903 let dbus = DBusProxy::new(conn.connection()).await.ok();
904 Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
905}
906
907async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
908 let dbus = dbus?;
909 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
910 dbus.get_connection_unix_process_id(bus_name).await.ok()
911}
912
913async fn bounds_from_proxies(
914 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
915 proxy: &AccessibleProxy<'_>,
916) -> Option<Bounds> {
917 let owned_proxies;
918 let proxies = if let Some(proxies) = proxies {
919 proxies
920 } else {
921 owned_proxies = proxy.proxies().await.ok()?;
922 &owned_proxies
923 };
924 let component = proxies.component().await.ok()?;
925 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
926 normalize_bounds(Bounds {
927 x,
928 y,
929 width,
930 height,
931 })
932}
933
934fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
935 if bounds.width <= 0 || bounds.height <= 0 {
936 return None;
937 }
938 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
939 return None;
940 }
941 Some(bounds)
942}
943
944async fn actions_from_proxies(
945 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
946) -> Vec<AccessibilityAction> {
947 let Some(proxies) = proxies else {
948 return Vec::new();
949 };
950 let Ok(action_proxy) = proxies.action().await else {
951 return Vec::new();
952 };
953
954 action_proxy
955 .get_actions()
956 .await
957 .unwrap_or_default()
958 .into_iter()
959 .enumerate()
960 .map(|(index, action)| AccessibilityAction {
961 index: index as i32,
962 name: action.name,
963 description: action.description,
964 keybinding: action.keybinding,
965 })
966 .collect()
967}
968
969async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
970 proxy
971 .get_state()
972 .await
973 .map(state_labels)
974 .unwrap_or_default()
975}
976
977async fn value_from_proxies(
978 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
979) -> Option<AccessibilityValue> {
980 let value = proxies?.value().await.ok()?;
981 Some(AccessibilityValue {
982 current: value.current_value().await.ok()?,
983 minimum: value.minimum_value().await.ok()?,
984 maximum: value.maximum_value().await.ok()?,
985 minimum_increment: value.minimum_increment().await.ok()?,
986 text: optional_string(value.text().await.ok()),
987 })
988}
989
990async fn text_from_proxies(
991 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
992) -> Option<AccessibilityText> {
993 let text = proxies?.text().await.ok()?;
994 let character_count = text.character_count().await.ok()?.max(0);
995 let caret_offset = text.caret_offset().await.ok();
996 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
997 let content = if capped_count > 0 {
998 optional_string(text.get_text(0, capped_count).await.ok())
999 } else {
1000 None
1001 };
1002 let selection_count = text
1003 .get_nselections()
1004 .await
1005 .unwrap_or_default()
1006 .clamp(0, MAX_TEXT_SELECTIONS);
1007 let mut selections = Vec::new();
1008 for index in 0..selection_count {
1009 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1010 selections.push(AccessibilityTextSelection {
1011 start_offset,
1012 end_offset,
1013 });
1014 }
1015 }
1016
1017 Some(AccessibilityText {
1018 character_count,
1019 caret_offset,
1020 content,
1021 truncated: character_count > MAX_TEXT_READBACK_CHARS,
1022 selections,
1023 })
1024}
1025
1026async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1027 let Some(proxies) = proxies else {
1028 return false;
1029 };
1030 proxies.editable_text().await.is_ok()
1031}
1032
1033fn state_labels(state_set: StateSet) -> Vec<String> {
1034 state_set.iter().map(|state| state.to_string()).collect()
1035}
1036
1037fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1038 if actions.is_empty() {
1039 return Err(anyhow!("element exposes no AT-SPI actions"));
1040 }
1041
1042 if let Some(requested_action) = requested_action
1043 .map(str::trim)
1044 .filter(|value| !value.is_empty())
1045 {
1046 let requested_action = requested_action.to_ascii_lowercase();
1047 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1048 action.name.to_ascii_lowercase() == requested_action
1049 || action.description.to_ascii_lowercase() == requested_action
1050 }) {
1051 return Ok(index as i32);
1052 }
1053
1054 if let Ok(index) = requested_action.parse::<usize>() {
1055 if index < actions.len() {
1056 return Ok(index as i32);
1057 }
1058 }
1059
1060 return Err(anyhow!(
1061 "requested AT-SPI action was not found; available actions: {}",
1062 actions
1063 .iter()
1064 .map(|action| action.name.as_str())
1065 .collect::<Vec<_>>()
1066 .join(", ")
1067 ));
1068 }
1069
1070 Ok(if actions.len() > 1 { 1 } else { 0 })
1071}
1072
1073fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1074 actions
1075 .iter()
1076 .position(|action| action.name.eq_ignore_ascii_case(name))
1077 .map(|index| index as i32)
1078 .ok_or_else(|| {
1079 anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1080 })
1081}
1082
1083fn optional_string(value: Option<String>) -> Option<String> {
1084 value
1085 .map(|value| value.trim().to_string())
1086 .filter(|value| !value.is_empty())
1087}
1088
1089fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1090 let (name, path) = split_object_ref_id(object_ref_id)?;
1091 let name = UniqueName::try_from(name.to_string())
1092 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1093 let path = ObjectPath::try_from(path.to_string())
1094 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1095 Ok(ObjectRef::new_owned(name, path))
1096}
1097
1098fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1099 let Some(path_start) = object_ref_id.find('/') else {
1100 return Err(anyhow!(
1101 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1102 ));
1103 };
1104 let (name, path) = object_ref_id.split_at(path_start);
1105 if name.is_empty() || path.is_empty() {
1106 return Err(anyhow!(
1107 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1108 ));
1109 }
1110 Ok((name, path))
1111}
1112
1113fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1114 format!(
1115 "{}{}",
1116 object_ref.name_as_str().unwrap_or(""),
1117 object_ref.path_as_str()
1118 )
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124
1125 #[test]
1126 fn split_object_ref_id_separates_bus_name_and_path() {
1127 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
1128
1129 assert_eq!(name, ":1.42");
1130 assert_eq!(path, "/org/a11y/atspi/accessible/7");
1131 }
1132
1133 #[test]
1134 fn select_action_index_uses_named_action() {
1135 let actions = vec![
1136 atspi::Action {
1137 name: "click".to_string(),
1138 description: "Clicks".to_string(),
1139 keybinding: String::new(),
1140 },
1141 atspi::Action {
1142 name: "show-menu".to_string(),
1143 description: "Shows menu".to_string(),
1144 keybinding: String::new(),
1145 },
1146 ];
1147
1148 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
1149 }
1150
1151 #[test]
1152 fn cached_action_name_is_revalidated_against_current_actions() {
1153 let mut actions = vec![atspi::Action {
1154 name: "show-menu".into(),
1155 description: "Click".into(),
1156 keybinding: String::new(),
1157 }];
1158 assert!(select_named_action_index(&actions, "Click").is_err());
1160 actions.push(atspi::Action {
1161 name: "click".into(),
1162 description: String::new(),
1163 keybinding: String::new(),
1164 });
1165 assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
1166 }
1167
1168 #[test]
1169 fn select_action_index_defaults_to_secondary_when_available() {
1170 let actions = vec![
1171 atspi::Action {
1172 name: "click".to_string(),
1173 description: String::new(),
1174 keybinding: String::new(),
1175 },
1176 atspi::Action {
1177 name: "show-menu".to_string(),
1178 description: String::new(),
1179 keybinding: String::new(),
1180 },
1181 ];
1182
1183 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
1184 }
1185
1186 #[test]
1187 fn state_labels_serialize_in_bit_order() {
1188 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
1189
1190 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
1191 }
1192
1193 #[test]
1194 fn default_snapshot_limits_cover_deep_gtk4_trees() {
1195 assert_eq!(snapshot_limits(None, None), (1_000, 32));
1198 }
1199
1200 #[test]
1201 fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
1202 assert_eq!(app_name_needle(None), None);
1203 assert_eq!(app_name_needle(Some("")), None);
1204 assert_eq!(app_name_needle(Some(" ")), None);
1205 assert_eq!(
1206 app_name_needle(Some("Calculator")).as_deref(),
1207 Some("calculator")
1208 );
1209 assert_eq!(
1210 app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
1211 Some(":1.64/org/a11y/atspi/accessible/root")
1212 );
1213 }
1214
1215 #[test]
1216 fn requested_snapshot_limits_remain_bounded() {
1217 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
1218 assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
1219 }
1220
1221 #[test]
1222 fn app_discovery_budget_is_independent_of_requested_tree_size() {
1223 assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
1224 }
1225
1226 #[test]
1227 fn traversal_attempts_and_queue_share_one_work_budget() {
1228 let mut traversal = BoundedTraversal::new(4);
1229 traversal.enqueue([1]);
1230
1231 assert_eq!(traversal.pop(), Some(1));
1232 traversal.enqueue(2..=10_000);
1233 assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1234
1235 assert_eq!(traversal.pop(), Some(2));
1236 traversal.enqueue(5..=10_000);
1237 assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1238 assert_eq!(traversal.pop(), Some(3));
1239 assert_eq!(traversal.pop(), Some(4));
1240 assert_eq!(traversal.pop(), None);
1241 assert_eq!(traversal.attempted, 4);
1242 assert!(
1243 traversal.truncated(),
1244 "dropped enqueue items must be reported"
1245 );
1246 }
1247
1248 #[test]
1249 fn traversal_that_drains_within_budget_is_not_truncated() {
1250 let mut traversal = BoundedTraversal::new(3);
1251 traversal.enqueue([1, 2, 3]);
1252 assert_eq!(traversal.pop(), Some(1));
1253 assert_eq!(traversal.pop(), Some(2));
1254 assert_eq!(traversal.pop(), Some(3));
1255 assert_eq!(traversal.pop(), None);
1256 assert!(
1257 !traversal.truncated(),
1258 "exactly max_items real nodes is complete"
1259 );
1260 }
1261
1262 #[test]
1263 fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
1264 let mut traversal = BoundedTraversal::new(2);
1265 traversal.enqueue([1, 2]);
1266 assert_eq!(traversal.pop(), Some(1));
1267 assert_eq!(traversal.pop(), Some(2));
1268 assert!(!traversal.truncated(), "nothing dropped yet");
1269 traversal.enqueue([3]);
1272 assert!(traversal.queue.is_empty());
1273 assert_eq!(traversal.pop(), None);
1274 assert!(traversal.truncated());
1275 }
1276
1277 #[test]
1278 fn child_count_is_clamped_before_indexed_reads() {
1279 assert_eq!(bounded_child_count(-1, 4), 0);
1280 assert_eq!(bounded_child_count(3, 4), 3);
1281 assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1282 }
1283
1284 #[tokio::test]
1285 async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1286 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1287 let mut remaining_attempts = 3;
1288 let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1289 let calls = calls.clone();
1290 move |index| {
1291 let calls = calls.clone();
1292 async move {
1293 calls.lock().unwrap().push(index);
1294 if index == 1 {
1295 Err(())
1296 } else {
1297 Ok(index)
1298 }
1299 }
1300 }
1301 })
1302 .await;
1303
1304 assert_eq!(batch.items, vec![0, 2]);
1305 assert_eq!(batch.attempted, 3);
1306 assert!(
1307 batch.incomplete,
1308 "100 reported children, 3 attempted: budget cut must be reported"
1309 );
1310 assert!(!batch.all_failed());
1311 assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1312 assert_eq!(remaining_attempts, 0);
1313
1314 let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1315 Ok::<_, ()>(99)
1316 })
1317 .await;
1318 assert!(no_children.items.is_empty());
1319 assert_eq!(no_children.attempted, 0);
1320 assert!(!no_children.all_failed());
1321
1322 let mut failed_attempts = 2;
1323 let all_failed =
1324 fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1325 assert!(all_failed.all_failed());
1326 }
1327}