1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4 proxy::{
5 accessible::{AccessibleProxy, ObjectRefExt},
6 action::ActionProxy,
7 proxy_ext::ProxyExt,
8 },
9 CoordType, ObjectRef, ObjectRefOwned, StateSet,
10};
11use atspi_connection::AccessibilityConnection;
14use futures_util::{stream, StreamExt};
15use schemars::JsonSchema;
16use serde::Serialize;
17use std::{collections::VecDeque, future::Future, time::Duration};
18use tokio::time::timeout;
19use zbus::{
20 fdo::DBusProxy,
21 names::{BusName, UniqueName},
22 zvariant::{ObjectPath, OwnedObjectPath},
23};
24
25#[derive(Debug, Clone, Serialize, JsonSchema)]
26pub struct AccessibleAppSummary {
27 pub object_ref: String,
28 pub name: Option<String>,
29 pub pid: Option<u32>,
30 pub role: String,
31 pub child_count: i32,
32 pub bounds: Option<Bounds>,
33}
34
35#[derive(Debug, Clone, Serialize, JsonSchema)]
36pub struct AccessibilityNode {
37 pub index: u32,
38 pub parent_index: Option<u32>,
39 pub depth: u32,
40 pub object_ref: String,
41 pub role: String,
42 pub name: Option<String>,
43 pub description: Option<String>,
44 pub child_count: i32,
45 pub bounds: Option<Bounds>,
46 pub states: Vec<String>,
47 pub actions: Vec<AccessibilityAction>,
48 pub value: Option<AccessibilityValue>,
49 pub text: Option<AccessibilityText>,
50 pub supports_editable_text: bool,
51}
52
53#[derive(Debug, Clone, Serialize, JsonSchema)]
54pub struct Bounds {
55 pub x: i32,
56 pub y: i32,
57 pub width: i32,
58 pub height: i32,
59}
60
61#[derive(Debug, Clone, Serialize, JsonSchema)]
62pub struct AccessibilityAction {
63 pub index: i32,
64 pub name: String,
65 pub description: String,
66 pub keybinding: String,
67}
68
69#[derive(Debug, Clone, Serialize, JsonSchema)]
70pub struct AccessibilityValue {
71 pub current: f64,
72 pub minimum: f64,
73 pub maximum: f64,
74 pub minimum_increment: f64,
75 pub text: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, JsonSchema)]
79pub struct AccessibilityText {
80 pub character_count: i32,
81 pub caret_offset: Option<i32>,
82 pub content: Option<String>,
83 pub truncated: bool,
84 pub selections: Vec<AccessibilityTextSelection>,
85}
86
87#[derive(Debug, Clone, Serialize, JsonSchema)]
88pub struct AccessibilityTextSelection {
89 pub start_offset: i32,
90 pub end_offset: i32,
91}
92
93#[derive(Debug, Clone)]
94pub struct ActionInvocation {
95 pub action_index: i32,
96 pub action_name: Option<String>,
97 pub ok: bool,
98}
99
100#[derive(Debug, Clone)]
101pub enum ValueSetInvocation {
102 Numeric { value: f64 },
103 EditableText,
104}
105
106const MAX_TEXT_READBACK_CHARS: i32 = 4096;
107const MAX_TEXT_SELECTIONS: i32 = 8;
108const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
109const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
110const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
111const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
112const CHILD_READ_CONCURRENCY: usize = 16;
113const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
114const MAX_ACTIONS_PER_OBJECT: i32 = 64;
115const ACTION_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
116const MAX_DISCOVERY_ROOTS: usize = 256;
117const ROOT_MATCH_CHILD_LIMIT: usize = 8;
118const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
119
120fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
121 (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
122}
123
124pub(crate) fn snapshot_limits(
125 requested_max_nodes: Option<usize>,
126 requested_max_depth: Option<u32>,
127) -> (usize, u32) {
128 (
129 requested_max_nodes
130 .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
131 .clamp(1, HARD_SNAPSHOT_MAX_NODES),
132 requested_max_depth
133 .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
134 .min(HARD_SNAPSHOT_MAX_DEPTH),
135 )
136}
137
138struct BoundedTraversal<T> {
139 queue: VecDeque<T>,
140 attempted: usize,
141 max_items: usize,
142 dropped: bool,
144}
145
146impl<T> BoundedTraversal<T> {
147 fn new(max_items: usize) -> Self {
148 Self {
149 queue: VecDeque::new(),
150 attempted: 0,
151 max_items,
152 dropped: false,
153 }
154 }
155
156 fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
157 let capacity = self.remaining_capacity();
158 let mut items = items.into_iter();
159 self.queue.extend(items.by_ref().take(capacity));
160 if items.next().is_some() {
161 self.dropped = true;
162 }
163 }
164
165 fn pop(&mut self) -> Option<T> {
166 if self.attempted >= self.max_items {
167 return None;
168 }
169 let item = self.queue.pop_front()?;
170 self.attempted += 1;
171 Some(item)
172 }
173
174 fn remaining_capacity(&self) -> usize {
175 self.max_items
176 .saturating_sub(self.attempted.saturating_add(self.queue.len()))
177 }
178
179 fn truncated(&self) -> bool {
184 self.dropped
185 }
186}
187
188fn bounded_child_count(reported: i32, limit: usize) -> usize {
189 usize::try_from(reported).unwrap_or_default().min(limit)
190}
191
192struct IndexedReadBatch<T> {
193 items: Vec<T>,
194 attempted: usize,
195 incomplete: bool,
199}
200
201impl<T> IndexedReadBatch<T> {
202 fn all_failed(&self) -> bool {
203 self.attempted > 0 && self.items.is_empty()
204 }
205}
206
207async fn fetch_indexed_up_to<T, E, F, Fut>(
208 reported: i32,
209 limit: usize,
210 remaining_attempts: &mut usize,
211 fetch: F,
212) -> IndexedReadBatch<T>
213where
214 F: Fn(i32) -> Fut,
215 Fut: Future<Output = std::result::Result<T, E>>,
216{
217 let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
218 *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
219 let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
220
221 let items = stream::iter(0..end_index)
222 .map(fetch)
223 .buffered(CHILD_READ_CONCURRENCY)
224 .filter_map(|result| async move { result.ok() })
225 .collect()
226 .await;
227
228 IndexedReadBatch {
229 items,
230 attempted: attempt_count,
231 incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
232 }
233}
234
235async fn children_up_to(
236 proxy: &AccessibleProxy<'_>,
237 limit: usize,
238 remaining_attempts: &mut usize,
239) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
240 if limit == 0 || *remaining_attempts == 0 {
241 return Ok(IndexedReadBatch {
245 items: Vec::new(),
246 attempted: 0,
247 incomplete: false,
248 });
249 }
250
251 let child_count = proxy.child_count().await?;
252 Ok(
253 fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
254 child_at_index(proxy, index)
255 })
256 .await,
257 )
258}
259
260async fn child_at_index(proxy: &AccessibleProxy<'_>, index: i32) -> zbus::Result<ObjectRefOwned> {
261 let (name, path): (String, OwnedObjectPath) =
264 proxy.inner().call("GetChildAtIndex", &(index,)).await?;
265 if path.as_str() == "/org/a11y/atspi/null" {
266 return Ok(ObjectRef::Null.into());
267 }
268 match BusName::try_from(name)? {
269 BusName::Unique(name) => Ok(ObjectRef::new_owned(name, path)),
270 name => {
271 let owner = DBusProxy::new(proxy.inner().connection())
272 .await?
273 .get_name_owner(name)
274 .await?;
275 Ok(ObjectRef::new_owned(owner, path))
276 }
277 }
278}
279
280pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
281 let conn = connect().await?;
282 let mut remaining_child_reads = limit;
283 let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
284 let dbus = DBusProxy::new(conn.connection()).await.ok();
285 let mut apps = Vec::new();
286
287 for object_ref in roots.into_iter().take(limit) {
288 if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
289 apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
290 }
291 }
292
293 Ok(apps)
294}
295
296#[derive(Debug, Clone)]
297pub(crate) struct AccessibilitySnapshot {
298 pub nodes: Vec<AccessibilityNode>,
299 pub scoped: bool,
301 pub root_pid: Option<u32>,
305 pub truncated: bool,
308}
309
310pub async fn snapshot_tree(
311 app_name_or_bundle_identifier: Option<&str>,
312 target_pid: Option<u32>,
313 max_nodes: usize,
314 max_depth: u32,
315) -> Result<Vec<AccessibilityNode>> {
316 Ok(snapshot_accessibility_tree(
317 app_name_or_bundle_identifier,
318 target_pid,
319 max_nodes,
320 max_depth,
321 )
322 .await?
323 .nodes)
324}
325
326pub(crate) async fn snapshot_accessibility_tree(
327 app_name_or_bundle_identifier: Option<&str>,
328 target_pid: Option<u32>,
329 max_nodes: usize,
330 max_depth: u32,
331) -> Result<AccessibilitySnapshot> {
332 let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
333 timeout(
334 SNAPSHOT_TIMEOUT,
335 snapshot_tree_inner(
336 app_name_or_bundle_identifier,
337 target_pid,
338 max_nodes,
339 max_depth,
340 ),
341 )
342 .await
343 .context("AT-SPI snapshot exceeded its 10-second deadline")?
344}
345
346async fn snapshot_tree_inner(
347 app_name_or_bundle_identifier: Option<&str>,
348 target_pid: Option<u32>,
349 max_nodes: usize,
350 max_depth: u32,
351) -> Result<AccessibilitySnapshot> {
352 let conn = connect().await?;
353 let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
356 snapshot_child_read_budgets(max_nodes);
357 let roots =
358 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
359 let selected_roots = select_roots(
360 &conn,
361 roots,
362 app_name_or_bundle_identifier,
363 target_pid,
364 &mut remaining_filter_reads,
365 )
366 .await;
367 let scoped = selected_roots.scoped;
368 let root_pid = selected_roots.pid;
369 let mut nodes = Vec::new();
370 let mut truncated = false;
371 let mut traversal = BoundedTraversal::new(max_nodes);
372
373 traversal.enqueue(
374 selected_roots
375 .roots
376 .into_iter()
377 .map(|object_ref| (object_ref, 0_u32, None)),
378 );
379
380 while let Some((object_ref, depth, parent_index)) = traversal.pop() {
381 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
382 continue;
383 };
384 let index = nodes.len() as u32;
385 let remaining = traversal.remaining_capacity();
386 let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
387 let child_refs = if depth < max_depth && remaining > 0 {
388 match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
389 Ok(batch) => {
390 truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
394 batch.items
395 }
396 Err(_) => Vec::new(),
397 }
398 } else {
399 truncated |= node.child_count > 0;
401 Vec::new()
402 };
403
404 nodes.push(node);
405
406 traversal.enqueue(
407 child_refs
408 .into_iter()
409 .map(|child| (child, depth + 1, Some(index))),
410 );
411 }
412 truncated |= traversal.truncated();
413
414 Ok(AccessibilitySnapshot {
415 nodes,
416 scoped,
417 root_pid,
418 truncated,
419 })
420}
421
422#[derive(Debug, Clone, Serialize, JsonSchema)]
425pub struct FocusedElementSummary {
426 pub role: String,
427 pub name: Option<String>,
428 pub editable: bool,
429 pub states: Vec<String>,
430 #[serde(skip)]
433 #[schemars(skip)]
434 pub is_terminal: bool,
435}
436
437const FOCUS_PROBE_MAX_NODES: usize = 400;
438const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
439
440#[derive(Debug, Clone)]
446pub(crate) enum FocusProbe {
447 Found(FocusedElementSummary),
448 NoneFocused,
450 ShellOnly,
452 Incomplete,
455 NoAccessibleApp,
458}
459
460pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
464 focused_element_probe(target_pid, target_pid.is_some()).await
465}
466
467pub(crate) async fn focused_element_summary_in_app(
471 target_pid: u32,
472) -> Result<Option<FocusedElementSummary>> {
473 Ok(match focused_element_probe(Some(target_pid), true).await? {
474 FocusProbe::Found(summary) => Some(summary),
475 _ => None,
476 })
477}
478
479async fn focused_element_probe(
480 target_pid: Option<u32>,
481 require_scoped: bool,
482) -> Result<FocusProbe> {
483 let conn = connect().await?;
484 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
485 let roots =
486 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
487 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
488 let selected_roots =
489 select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
490 if require_scoped && !selected_roots.scoped {
491 return Ok(FocusProbe::NoAccessibleApp);
492 }
493 let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
494 let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
495 let mut incomplete = false;
496 let mut shell_only = true;
497 let mut observed_nodes = 0;
498
499 traversal.enqueue(
500 selected_roots
501 .roots
502 .into_iter()
503 .map(|object_ref| (object_ref, 0_u32, false)),
504 );
505
506 while let Some((object_ref, depth, in_menu)) = traversal.pop() {
507 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
508 incomplete = true;
509 continue;
510 };
511 let Ok(state) = proxy.get_state().await else {
512 incomplete = true;
513 continue;
514 };
515 observed_nodes += 1;
516 let mut child_menu = false;
517 if shell_only {
518 match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
519 Some(menu) => child_menu = menu,
520 None => shell_only = false,
521 }
522 }
523 if state.contains(atspi::State::Focused) {
524 let proxies = proxy.proxies().await.ok();
525 let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
526 return Ok(FocusProbe::Found(FocusedElementSummary {
527 role: role_name(&proxy).await,
528 name: optional_string(proxy.name().await.ok()),
529 editable: supports_editable_text(proxies.as_ref()).await,
530 states: state_labels(state),
531 is_terminal,
532 }));
533 }
534 let remaining = traversal.remaining_capacity();
535 if depth < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
536 if let Ok(batch) =
537 children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
538 {
539 incomplete |= batch.incomplete;
540 if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
545 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
546 }
547 traversal.enqueue(
548 batch
549 .items
550 .into_iter()
551 .map(|child| (child, depth + 1, child_menu)),
552 );
553 } else {
554 incomplete = true;
555 }
556 } else if !incomplete {
557 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
560 }
561 }
562 if incomplete || traversal.truncated() {
563 return Ok(FocusProbe::Incomplete);
564 }
565
566 Ok(if require_scoped && shell_only && observed_nodes > 0 {
567 FocusProbe::ShellOnly
568 } else {
569 FocusProbe::NoneFocused
570 })
571}
572
573pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
576 match role {
577 "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
578 "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
579 | "tearoff menu item" => Some(true),
580 "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
582 _ => None,
583 }
584}
585
586pub async fn perform_action(
587 object_ref_id: &str,
588 requested_action: Option<&str>,
589) -> Result<ActionInvocation> {
590 perform_action_inner(object_ref_id, requested_action, false).await
591}
592
593pub(crate) async fn perform_named_action(
594 object_ref_id: &str,
595 name: &str,
596) -> Result<ActionInvocation> {
597 perform_action_inner(object_ref_id, Some(name), true).await
598}
599
600async fn perform_action_inner(
601 object_ref_id: &str,
602 requested_action: Option<&str>,
603 name_only: bool,
604) -> Result<ActionInvocation> {
605 let object_ref = object_ref_from_id(object_ref_id)?;
606 let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
607 let conn = connect().await?;
608 let proxy = open_accessible(&conn, &object_ref)
609 .await
610 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
611 let action = proxy
612 .proxies()
613 .await?
614 .action()
615 .await
616 .context("element does not expose the AT-SPI Action interface")?;
617 let actions = read_actions(&action).await?;
618 let action_index = if name_only {
619 select_named_action_index(&actions, requested_action.unwrap_or_default())?
620 } else {
621 select_action_index(&actions, requested_action)?
622 };
623 let action_name = actions
624 .get(action_index as usize)
625 .map(|action| action.name.clone());
626 Ok::<_, anyhow::Error>((action, action_index, action_name))
627 })
628 .await
629 .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
630 let ok = action
632 .do_action(action_index)
633 .await
634 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
635
636 Ok(ActionInvocation {
637 action_index,
638 action_name,
639 ok,
640 })
641}
642
643pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
644 let conn = connect().await?;
645 let object_ref = object_ref_from_id(object_ref_id)?;
646 let proxy = open_accessible(&conn, &object_ref)
647 .await
648 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
649 let proxies = proxy.proxies().await?;
650
651 if let Ok(numeric_value) = value.parse::<f64>() {
652 if let Ok(value_proxy) = proxies.value().await {
653 value_proxy
654 .set_current_value(numeric_value)
655 .await
656 .with_context(|| {
657 format!("failed to set AT-SPI numeric value to {numeric_value}")
658 })?;
659 return Ok(ValueSetInvocation::Numeric {
660 value: numeric_value,
661 });
662 }
663 }
664
665 if let Ok(editable_text) = proxies.editable_text().await {
666 let ok = editable_text
667 .set_text_contents(value)
668 .await
669 .context("failed to set AT-SPI editable text contents")?;
670 if ok {
671 return Ok(ValueSetInvocation::EditableText);
672 }
673 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
674 }
675
676 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
677 return Err(anyhow!(
678 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
679 ));
680 }
681
682 Err(anyhow!(
683 "element does not expose AT-SPI Value or EditableText interfaces"
684 ))
685}
686
687async fn connect() -> Result<AccessibilityConnection> {
688 hydrate_session_bus_env();
689 AccessibilityConnection::new()
690 .await
691 .context("failed to connect to AT-SPI bus")
692}
693
694async fn open_accessible<'r>(
707 conn: &AccessibilityConnection,
708 object_ref: &'r ObjectRefOwned,
709) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
710 object_ref.as_accessible_proxy(conn.connection()).await
711}
712
713async fn registry_children(
714 conn: &AccessibilityConnection,
715 limit: usize,
716 remaining_child_reads: &mut usize,
717) -> Result<Vec<ObjectRefOwned>> {
718 let root = conn
719 .root_accessible_on_registry()
720 .await
721 .context("failed to open AT-SPI registry root")?;
722 let batch = children_up_to(&root, limit, remaining_child_reads)
723 .await
724 .context("failed to read AT-SPI registry children")?;
725 if batch.all_failed() {
726 return Err(anyhow!(
727 "AT-SPI registry reported children, but every indexed child read failed"
728 ));
729 }
730 Ok(batch.items)
731}
732
733struct SelectedRoots {
734 roots: Vec<ObjectRefOwned>,
735 scoped: bool,
736 pid: Option<u32>,
738}
739
740fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
743 app_name_or_bundle_identifier
744 .map(str::trim)
745 .filter(|value| !value.is_empty())
746 .map(|value| value.to_ascii_lowercase())
747}
748
749async fn select_roots(
750 conn: &AccessibilityConnection,
751 roots: Vec<ObjectRefOwned>,
752 app_name_or_bundle_identifier: Option<&str>,
753 target_pid: Option<u32>,
754 remaining_child_reads: &mut usize,
755) -> SelectedRoots {
756 let needle = app_name_needle(app_name_or_bundle_identifier);
757 let dbus = DBusProxy::new(conn.connection()).await.ok();
758 let mut remaining = roots;
759
760 if let Some(target_pid) = target_pid {
761 let mut pid_and_filter_matches = Vec::new();
762 let mut pid_matches = Vec::new();
763 let mut non_pid_matches = Vec::new();
764
765 for object_ref in remaining {
766 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
767 if let Some(needle) = needle.as_deref() {
768 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
769 pid_and_filter_matches.push(object_ref);
770 } else {
771 pid_matches.push(object_ref);
772 }
773 } else {
774 pid_matches.push(object_ref);
775 }
776 } else {
777 non_pid_matches.push(object_ref);
778 }
779 }
780
781 if !pid_and_filter_matches.is_empty() {
782 return SelectedRoots {
783 roots: pid_and_filter_matches,
784 scoped: true,
785 pid: Some(target_pid),
786 };
787 }
788 if !pid_matches.is_empty() {
789 return SelectedRoots {
790 roots: pid_matches,
791 scoped: true,
792 pid: Some(target_pid),
793 };
794 }
795
796 remaining = non_pid_matches;
797 }
798
799 let Some(needle) = needle.as_deref() else {
800 return SelectedRoots {
801 roots: remaining,
802 scoped: false,
803 pid: None,
804 };
805 };
806
807 let mut selected = Vec::new();
808 for object_ref in remaining {
809 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
810 selected.push(object_ref);
811 }
812 }
813
814 SelectedRoots {
815 roots: selected,
816 scoped: true,
817 pid: None,
818 }
819}
820
821async fn root_matches(
822 conn: &AccessibilityConnection,
823 object_ref: &ObjectRefOwned,
824 needle: &str,
825 remaining_child_reads: &mut usize,
826) -> bool {
827 let Ok(proxy) = open_accessible(conn, object_ref).await else {
828 return object_ref_id(object_ref)
829 .to_ascii_lowercase()
830 .contains(needle);
831 };
832
833 if proxy_matches(&proxy, object_ref, needle).await {
834 return true;
835 }
836
837 for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
838 .await
839 .map(|batch| batch.items)
840 .unwrap_or_default()
841 {
842 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
843 continue;
844 };
845 if proxy_matches(&child_proxy, &child_ref, needle).await {
846 return true;
847 }
848 }
849
850 false
851}
852
853async fn proxy_matches(
854 proxy: &AccessibleProxy<'_>,
855 object_ref: &ObjectRefOwned,
856 needle: &str,
857) -> bool {
858 let name = proxy.name().await.unwrap_or_default();
859 let role = proxy.get_role_name().await.unwrap_or_default();
860 format!("{} {} {}", object_ref_id(object_ref), name, role)
861 .to_ascii_lowercase()
862 .contains(needle)
863}
864
865async fn read_app_summary(
866 proxy: &AccessibleProxy<'_>,
867 object_ref: &ObjectRefOwned,
868 dbus: Option<&DBusProxy<'_>>,
869) -> AccessibleAppSummary {
870 AccessibleAppSummary {
871 object_ref: object_ref_id(object_ref),
872 name: optional_string(proxy.name().await.ok()),
873 pid: object_ref_pid(dbus, object_ref).await,
874 role: role_name(proxy).await,
875 child_count: proxy.child_count().await.unwrap_or_default(),
876 bounds: bounds(proxy).await,
877 }
878}
879
880async fn read_node(
881 proxy: &AccessibleProxy<'_>,
882 object_ref: &ObjectRefOwned,
883 index: u32,
884 parent_index: Option<u32>,
885 depth: u32,
886) -> AccessibilityNode {
887 let proxies = proxy.proxies().await.ok();
888
889 AccessibilityNode {
890 index,
891 parent_index,
892 depth,
893 object_ref: object_ref_id(object_ref),
894 role: role_name(proxy).await,
895 name: optional_string(proxy.name().await.ok()),
896 description: optional_string(proxy.description().await.ok()),
897 child_count: proxy.child_count().await.unwrap_or_default(),
898 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
899 states: states_from_proxy(proxy).await,
900 actions: actions_from_proxies(proxies.as_ref()).await,
901 value: value_from_proxies(proxies.as_ref()).await,
902 text: text_from_proxies(proxies.as_ref()).await,
903 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
904 }
905}
906
907pub(crate) const UNKNOWN_ROLE: &str = "unknown";
909
910async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
911 if let Ok(role) = proxy.get_role_name().await {
912 if !role.trim().is_empty() {
913 return role;
914 }
915 }
916 proxy
917 .get_role()
918 .await
919 .map(|role| format!("{role:?}"))
920 .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
921}
922
923async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
924 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
925}
926
927pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
930 let object_ref = object_ref_from_id(object_ref_id)?;
931 let conn = connect().await?;
932 let dbus = DBusProxy::new(conn.connection()).await.ok();
933 Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
934}
935
936async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
937 let dbus = dbus?;
938 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
939 dbus.get_connection_unix_process_id(bus_name).await.ok()
940}
941
942async fn bounds_from_proxies(
943 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
944 proxy: &AccessibleProxy<'_>,
945) -> Option<Bounds> {
946 let owned_proxies;
947 let proxies = if let Some(proxies) = proxies {
948 proxies
949 } else {
950 owned_proxies = proxy.proxies().await.ok()?;
951 &owned_proxies
952 };
953 let component = proxies.component().await.ok()?;
954 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
955 normalize_bounds(Bounds {
956 x,
957 y,
958 width,
959 height,
960 })
961}
962
963fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
964 if bounds.width <= 0 || bounds.height <= 0 {
965 return None;
966 }
967 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
968 return None;
969 }
970 Some(bounds)
971}
972
973async fn actions_from_proxies(
974 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
975) -> Vec<AccessibilityAction> {
976 let Some(proxies) = proxies else {
977 return Vec::new();
978 };
979 let Ok(action_proxy) = proxies.action().await else {
980 return Vec::new();
981 };
982
983 read_actions(&action_proxy)
984 .await
985 .unwrap_or_default()
986 .into_iter()
987 .enumerate()
988 .map(|(index, action)| AccessibilityAction {
989 index: index as i32,
990 name: action.name,
991 description: action.description,
992 keybinding: action.keybinding,
993 })
994 .collect()
995}
996
997async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
998 timeout(ACTION_QUERY_TIMEOUT, async {
1002 let count = proxy
1003 .inner()
1004 .get_property::<i32>("NActions")
1005 .await
1006 .context("failed to read AT-SPI action count")?;
1007 if count > MAX_ACTIONS_PER_OBJECT {
1008 return Err(anyhow!(
1009 "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
1010 ));
1011 }
1012 if count < 0 {
1013 return Err(anyhow!("invalid negative AT-SPI action count {count}"));
1014 }
1015 Ok(stream::iter(0..count)
1016 .map(|index| async move {
1017 let (name, description, keybinding) = tokio::join!(
1018 proxy.get_localized_name(index),
1019 proxy.get_description(index),
1020 proxy.get_key_binding(index),
1021 );
1022 atspi::Action {
1023 name: name.unwrap_or_default(),
1024 description: description.unwrap_or_default(),
1025 keybinding: keybinding.unwrap_or_default(),
1026 }
1027 })
1028 .buffered(CHILD_READ_CONCURRENCY)
1029 .collect()
1030 .await)
1031 })
1032 .await
1033 .context("AT-SPI action reads exceeded their 5-second deadline")?
1034}
1035
1036async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
1037 proxy
1038 .get_state()
1039 .await
1040 .map(state_labels)
1041 .unwrap_or_default()
1042}
1043
1044async fn value_from_proxies(
1045 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1046) -> Option<AccessibilityValue> {
1047 let value = proxies?.value().await.ok()?;
1048 Some(AccessibilityValue {
1049 current: value.current_value().await.ok()?,
1050 minimum: value.minimum_value().await.ok()?,
1051 maximum: value.maximum_value().await.ok()?,
1052 minimum_increment: value.minimum_increment().await.ok()?,
1053 text: optional_string(value.text().await.ok()),
1054 })
1055}
1056
1057async fn text_from_proxies(
1058 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1059) -> Option<AccessibilityText> {
1060 let text = proxies?.text().await.ok()?;
1061 let character_count = text.character_count().await.ok()?.max(0);
1062 let caret_offset = text.caret_offset().await.ok();
1063 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
1064 let content = if capped_count > 0 {
1065 optional_string(text.get_text(0, capped_count).await.ok())
1066 } else {
1067 None
1068 };
1069 let selection_count = text
1070 .get_nselections()
1071 .await
1072 .unwrap_or_default()
1073 .clamp(0, MAX_TEXT_SELECTIONS);
1074 let mut selections = Vec::new();
1075 for index in 0..selection_count {
1076 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1077 selections.push(AccessibilityTextSelection {
1078 start_offset,
1079 end_offset,
1080 });
1081 }
1082 }
1083
1084 Some(AccessibilityText {
1085 character_count,
1086 caret_offset,
1087 content,
1088 truncated: character_count > MAX_TEXT_READBACK_CHARS,
1089 selections,
1090 })
1091}
1092
1093async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1094 let Some(proxies) = proxies else {
1095 return false;
1096 };
1097 proxies.editable_text().await.is_ok()
1098}
1099
1100fn state_labels(state_set: StateSet) -> Vec<String> {
1101 state_set.iter().map(|state| state.to_string()).collect()
1102}
1103
1104fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1105 if actions.is_empty() {
1106 return Err(anyhow!("element exposes no AT-SPI actions"));
1107 }
1108
1109 if let Some(requested_action) = requested_action
1110 .map(str::trim)
1111 .filter(|value| !value.is_empty())
1112 {
1113 let requested_action = requested_action.to_ascii_lowercase();
1114 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1115 action.name.to_ascii_lowercase() == requested_action
1116 || action.description.to_ascii_lowercase() == requested_action
1117 }) {
1118 return Ok(index as i32);
1119 }
1120
1121 if let Ok(index) = requested_action.parse::<usize>() {
1122 if index < actions.len() {
1123 return Ok(index as i32);
1124 }
1125 }
1126
1127 return Err(anyhow!(
1128 "requested AT-SPI action was not found; available actions: {}",
1129 actions
1130 .iter()
1131 .map(|action| action.name.as_str())
1132 .collect::<Vec<_>>()
1133 .join(", ")
1134 ));
1135 }
1136
1137 Ok(if actions.len() > 1 { 1 } else { 0 })
1138}
1139
1140fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1141 actions
1142 .iter()
1143 .position(|action| action.name.eq_ignore_ascii_case(name))
1144 .map(|index| index as i32)
1145 .ok_or_else(|| {
1146 anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1147 })
1148}
1149
1150fn optional_string(value: Option<String>) -> Option<String> {
1151 value
1152 .map(|value| value.trim().to_string())
1153 .filter(|value| !value.is_empty())
1154}
1155
1156fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1157 let (name, path) = split_object_ref_id(object_ref_id)?;
1158 let name = UniqueName::try_from(name.to_string())
1159 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1160 let path = ObjectPath::try_from(path.to_string())
1161 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1162 Ok(ObjectRef::new_owned(name, path))
1163}
1164
1165fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1166 let Some(path_start) = object_ref_id.find('/') else {
1167 return Err(anyhow!(
1168 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1169 ));
1170 };
1171 let (name, path) = object_ref_id.split_at(path_start);
1172 if name.is_empty() || path.is_empty() {
1173 return Err(anyhow!(
1174 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1175 ));
1176 }
1177 Ok((name, path))
1178}
1179
1180fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1181 format!(
1182 "{}{}",
1183 object_ref.name_as_str().unwrap_or(""),
1184 object_ref.path_as_str()
1185 )
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191 use std::{
1192 io::{BufRead, BufReader},
1193 process::{Child, Command, Stdio},
1194 sync::{
1195 atomic::{AtomicUsize, Ordering},
1196 Arc,
1197 },
1198 };
1199
1200 struct PrivateBus(Child);
1201
1202 impl PrivateBus {
1203 fn start() -> (Self, String) {
1204 let mut bus = Self(
1205 Command::new("dbus-daemon")
1206 .args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
1207 .stdout(Stdio::piped())
1208 .spawn()
1209 .unwrap(),
1210 );
1211 let mut address = String::new();
1212 BufReader::new(bus.0.stdout.take().unwrap())
1213 .read_line(&mut address)
1214 .unwrap();
1215 (bus, address.trim().to_string())
1216 }
1217 }
1218
1219 impl Drop for PrivateBus {
1220 fn drop(&mut self) {
1221 let _ = self.0.kill();
1222 let _ = self.0.wait();
1223 }
1224 }
1225
1226 struct FakeAccessible {
1227 name: String,
1228 children: Vec<(String, OwnedObjectPath)>,
1229 has_action: bool,
1230 }
1231
1232 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1233 impl FakeAccessible {
1234 #[zbus(property)]
1235 fn child_count(&self) -> i32 {
1236 self.children.len() as i32
1237 }
1238
1239 #[zbus(property)]
1240 fn name(&self) -> &str {
1241 &self.name
1242 }
1243
1244 fn get_interfaces(&self) -> Vec<&str> {
1245 let mut interfaces = vec!["org.a11y.atspi.Accessible"];
1246 if self.has_action {
1247 interfaces.push("org.a11y.atspi.Action");
1248 }
1249 interfaces
1250 }
1251
1252 fn get_child_at_index(
1253 &self,
1254 index: i32,
1255 ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
1256 self.children
1257 .get(index as usize)
1258 .cloned()
1259 .map(|child| (child,))
1260 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
1261 }
1262 }
1263
1264 struct FakeWebKitAction {
1265 count: i32,
1266 stall_indexed_reads: bool,
1267 name_reads: Arc<AtomicUsize>,
1268 }
1269
1270 impl Default for FakeWebKitAction {
1271 fn default() -> Self {
1272 Self {
1273 count: 1,
1274 stall_indexed_reads: false,
1275 name_reads: Arc::default(),
1276 }
1277 }
1278 }
1279
1280 #[zbus::interface(name = "org.a11y.atspi.Action")]
1281 impl FakeWebKitAction {
1282 async fn get_actions(&self) -> Vec<atspi::Action> {
1283 std::future::pending().await
1284 }
1285
1286 #[zbus(property, name = "NActions")]
1287 fn nactions(&self) -> i32 {
1288 self.count
1289 }
1290
1291 fn get_name(&self, index: i32) -> &str {
1292 assert!((0..self.count).contains(&index));
1293 "click"
1294 }
1295
1296 async fn get_localized_name(&self, index: i32) -> &str {
1297 assert!((0..self.count).contains(&index));
1298 self.name_reads.fetch_add(1, Ordering::SeqCst);
1299 if self.stall_indexed_reads {
1300 std::future::pending::<()>().await;
1301 }
1302 "Activate"
1303 }
1304
1305 fn get_description(&self, index: i32) -> &str {
1306 assert!((0..self.count).contains(&index));
1307 "Activate the control"
1308 }
1309
1310 fn get_key_binding(&self, index: i32) -> &str {
1311 assert!((0..self.count).contains(&index));
1312 "Return"
1313 }
1314
1315 fn do_action(&self, index: i32) -> bool {
1316 index == 0
1317 }
1318 }
1319
1320 #[tokio::test]
1321 async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
1322 let (_bus, address) = PrivateBus::start();
1323
1324 let webkit_name = "org.webkit.MockWebProcess";
1325 let child_path = "/org/a11y/webkit/accessible/child";
1326 let webkit = zbus::connection::Builder::address(address.trim())
1327 .unwrap()
1328 .name(webkit_name)
1329 .unwrap()
1330 .serve_at(
1331 child_path,
1332 FakeAccessible {
1333 name: "WebKit page".into(),
1334 children: Vec::new(),
1335 has_action: true,
1336 },
1337 )
1338 .unwrap()
1339 .serve_at(child_path, FakeWebKitAction::default())
1340 .unwrap()
1341 .build()
1342 .await
1343 .unwrap();
1344 let owner = webkit.unique_name().unwrap().to_string();
1345 let gtk = zbus::connection::Builder::address(address.trim())
1346 .unwrap()
1347 .serve_at(
1348 "/org/a11y/atspi/accessible/root",
1349 FakeAccessible {
1350 name: "GTK socket".into(),
1351 has_action: false,
1352 children: vec![
1353 (webkit_name.into(), child_path.try_into().unwrap()),
1354 (owner.clone(), child_path.try_into().unwrap()),
1355 (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
1356 (
1357 "org.webkit.UnownedWebProcess".into(),
1358 child_path.try_into().unwrap(),
1359 ),
1360 ],
1361 },
1362 )
1363 .unwrap()
1364 .build()
1365 .await
1366 .unwrap();
1367 let client = zbus::connection::Builder::address(address.trim())
1368 .unwrap()
1369 .build()
1370 .await
1371 .unwrap();
1372 let proxy = AccessibleProxy::builder(&client)
1373 .destination(gtk.unique_name().unwrap().clone())
1374 .unwrap()
1375 .path("/org/a11y/atspi/accessible/root")
1376 .unwrap()
1377 .build()
1378 .await
1379 .unwrap();
1380 let mut remaining = 4;
1381 let reply = proxy
1382 .inner()
1383 .call_method("GetChildAtIndex", &(0_i32,))
1384 .await
1385 .unwrap();
1386 assert_eq!(reply.body().signature().to_string(), "(so)");
1387 let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
1388
1389 assert_eq!(batch.attempted, 4);
1390 assert_eq!(remaining, 0);
1391 assert!(!batch.incomplete);
1392 assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
1393 assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
1394 assert_eq!(batch.items[0].path_as_str(), child_path);
1395 assert_eq!(batch.items[0], batch.items[1]);
1396 assert!(batch.items[2].is_null());
1397
1398 let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
1400 assert_eq!(child.name().await.unwrap(), "WebKit page");
1401 assert_eq!(
1402 object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
1403 batch.items[0]
1404 );
1405 let action = child.proxies().await.unwrap().action().await.unwrap();
1406 let actions = timeout(Duration::from_secs(1), read_actions(&action))
1407 .await
1408 .expect("indexed action reads must not wait for GetActions")
1409 .unwrap();
1410 assert_eq!(actions.len(), 1);
1411 assert_eq!(actions[0].name, "Activate");
1412 assert_eq!(actions[0].description, "Activate the control");
1413 assert_eq!(actions[0].keybinding, "Return");
1414 assert_eq!(select_named_action_index(&actions, "Activate").unwrap(), 0);
1415 assert!(action.do_action(0).await.unwrap());
1416 }
1417
1418 #[tokio::test]
1419 async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
1420 let (_bus, address) = PrivateBus::start();
1421 let name_reads = Arc::new(AtomicUsize::new(0));
1422 let counts = [
1423 -1,
1424 MAX_ACTIONS_PER_OBJECT + 1,
1425 i32::MAX,
1426 0,
1427 MAX_ACTIONS_PER_OBJECT,
1428 ];
1429 let mut builder = zbus::connection::Builder::address(address.as_str()).unwrap();
1430 for (index, count) in counts.iter().enumerate() {
1431 builder = builder
1432 .serve_at(
1433 format!("/actions/{index}"),
1434 FakeWebKitAction {
1435 count: *count,
1436 name_reads: Arc::clone(&name_reads),
1437 ..Default::default()
1438 },
1439 )
1440 .unwrap();
1441 }
1442 let peer = builder.build().await.unwrap();
1443 let client = zbus::connection::Builder::address(address.as_str())
1444 .unwrap()
1445 .build()
1446 .await
1447 .unwrap();
1448
1449 for (index, count) in counts.iter().enumerate() {
1450 let action = ActionProxy::builder(&client)
1451 .destination(peer.unique_name().unwrap().clone())
1452 .unwrap()
1453 .path(format!("/actions/{index}"))
1454 .unwrap()
1455 .cache_properties(zbus::proxy::CacheProperties::No)
1456 .build()
1457 .await
1458 .unwrap();
1459 let result = timeout(Duration::from_secs(1), read_actions(&action))
1460 .await
1461 .expect("untrusted counts must not cause unbounded work");
1462 if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
1463 assert_eq!(result.unwrap().len(), *count as usize);
1464 } else {
1465 let error = result.unwrap_err().to_string();
1466 assert!(error.contains("action count"), "{error}");
1467 assert_eq!(name_reads.load(Ordering::SeqCst), 0);
1468 }
1469 }
1470 assert_eq!(
1471 name_reads.load(Ordering::SeqCst),
1472 MAX_ACTIONS_PER_OBJECT as usize
1473 );
1474 }
1475
1476 #[tokio::test]
1477 async fn indexed_actions_time_out_the_entire_read() {
1478 let (_bus, address) = PrivateBus::start();
1479 let name_reads = Arc::new(AtomicUsize::new(0));
1480 let peer = zbus::connection::Builder::address(address.as_str())
1481 .unwrap()
1482 .serve_at(
1483 "/actions/stalled",
1484 FakeWebKitAction {
1485 stall_indexed_reads: true,
1486 name_reads: Arc::clone(&name_reads),
1487 ..Default::default()
1488 },
1489 )
1490 .unwrap()
1491 .build()
1492 .await
1493 .unwrap();
1494 let client = zbus::connection::Builder::address(address.as_str())
1495 .unwrap()
1496 .build()
1497 .await
1498 .unwrap();
1499 let action = ActionProxy::builder(&client)
1500 .destination(peer.unique_name().unwrap().clone())
1501 .unwrap()
1502 .path("/actions/stalled")
1503 .unwrap()
1504 .cache_properties(zbus::proxy::CacheProperties::No)
1505 .build()
1506 .await
1507 .unwrap();
1508
1509 let error = timeout(
1510 ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
1511 read_actions(&action),
1512 )
1513 .await
1514 .expect("indexed reads must have an overall deadline")
1515 .unwrap_err();
1516 assert!(error.to_string().contains("5-second deadline"));
1517 assert_eq!(name_reads.load(Ordering::SeqCst), 1);
1518 }
1519
1520 #[test]
1521 fn split_object_ref_id_separates_bus_name_and_path() {
1522 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
1523
1524 assert_eq!(name, ":1.42");
1525 assert_eq!(path, "/org/a11y/atspi/accessible/7");
1526 }
1527
1528 #[test]
1529 fn select_action_index_uses_named_action() {
1530 let actions = vec![
1531 atspi::Action {
1532 name: "click".to_string(),
1533 description: "Clicks".to_string(),
1534 keybinding: String::new(),
1535 },
1536 atspi::Action {
1537 name: "show-menu".to_string(),
1538 description: "Shows menu".to_string(),
1539 keybinding: String::new(),
1540 },
1541 ];
1542
1543 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
1544 }
1545
1546 #[test]
1547 fn cached_action_name_is_revalidated_against_current_actions() {
1548 let mut actions = vec![atspi::Action {
1549 name: "show-menu".into(),
1550 description: "Click".into(),
1551 keybinding: String::new(),
1552 }];
1553 assert!(select_named_action_index(&actions, "Click").is_err());
1555 actions.push(atspi::Action {
1556 name: "click".into(),
1557 description: String::new(),
1558 keybinding: String::new(),
1559 });
1560 assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
1561 }
1562
1563 #[test]
1564 fn select_action_index_defaults_to_secondary_when_available() {
1565 let actions = vec![
1566 atspi::Action {
1567 name: "click".to_string(),
1568 description: String::new(),
1569 keybinding: String::new(),
1570 },
1571 atspi::Action {
1572 name: "show-menu".to_string(),
1573 description: String::new(),
1574 keybinding: String::new(),
1575 },
1576 ];
1577
1578 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
1579 }
1580
1581 #[test]
1582 fn state_labels_serialize_in_bit_order() {
1583 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
1584
1585 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
1586 }
1587
1588 #[test]
1589 fn default_snapshot_limits_cover_deep_gtk4_trees() {
1590 assert_eq!(snapshot_limits(None, None), (1_000, 32));
1593 }
1594
1595 #[test]
1596 fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
1597 assert_eq!(app_name_needle(None), None);
1598 assert_eq!(app_name_needle(Some("")), None);
1599 assert_eq!(app_name_needle(Some(" ")), None);
1600 assert_eq!(
1601 app_name_needle(Some("Calculator")).as_deref(),
1602 Some("calculator")
1603 );
1604 assert_eq!(
1605 app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
1606 Some(":1.64/org/a11y/atspi/accessible/root")
1607 );
1608 }
1609
1610 #[test]
1611 fn requested_snapshot_limits_remain_bounded() {
1612 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
1613 assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
1614 }
1615
1616 #[test]
1617 fn app_discovery_budget_is_independent_of_requested_tree_size() {
1618 assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
1619 }
1620
1621 #[test]
1622 fn traversal_attempts_and_queue_share_one_work_budget() {
1623 let mut traversal = BoundedTraversal::new(4);
1624 traversal.enqueue([1]);
1625
1626 assert_eq!(traversal.pop(), Some(1));
1627 traversal.enqueue(2..=10_000);
1628 assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1629
1630 assert_eq!(traversal.pop(), Some(2));
1631 traversal.enqueue(5..=10_000);
1632 assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1633 assert_eq!(traversal.pop(), Some(3));
1634 assert_eq!(traversal.pop(), Some(4));
1635 assert_eq!(traversal.pop(), None);
1636 assert_eq!(traversal.attempted, 4);
1637 assert!(
1638 traversal.truncated(),
1639 "dropped enqueue items must be reported"
1640 );
1641 }
1642
1643 #[test]
1644 fn traversal_that_drains_within_budget_is_not_truncated() {
1645 let mut traversal = BoundedTraversal::new(3);
1646 traversal.enqueue([1, 2, 3]);
1647 assert_eq!(traversal.pop(), Some(1));
1648 assert_eq!(traversal.pop(), Some(2));
1649 assert_eq!(traversal.pop(), Some(3));
1650 assert_eq!(traversal.pop(), None);
1651 assert!(
1652 !traversal.truncated(),
1653 "exactly max_items real nodes is complete"
1654 );
1655 }
1656
1657 #[test]
1658 fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
1659 let mut traversal = BoundedTraversal::new(2);
1660 traversal.enqueue([1, 2]);
1661 assert_eq!(traversal.pop(), Some(1));
1662 assert_eq!(traversal.pop(), Some(2));
1663 assert!(!traversal.truncated(), "nothing dropped yet");
1664 traversal.enqueue([3]);
1667 assert!(traversal.queue.is_empty());
1668 assert_eq!(traversal.pop(), None);
1669 assert!(traversal.truncated());
1670 }
1671
1672 #[test]
1673 fn child_count_is_clamped_before_indexed_reads() {
1674 assert_eq!(bounded_child_count(-1, 4), 0);
1675 assert_eq!(bounded_child_count(3, 4), 3);
1676 assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1677 }
1678
1679 #[tokio::test]
1680 async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1681 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1682 let mut remaining_attempts = 3;
1683 let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1684 let calls = calls.clone();
1685 move |index| {
1686 let calls = calls.clone();
1687 async move {
1688 calls.lock().unwrap().push(index);
1689 if index == 1 {
1690 Err(())
1691 } else {
1692 Ok(index)
1693 }
1694 }
1695 }
1696 })
1697 .await;
1698
1699 assert_eq!(batch.items, vec![0, 2]);
1700 assert_eq!(batch.attempted, 3);
1701 assert!(
1702 batch.incomplete,
1703 "100 reported children, 3 attempted: budget cut must be reported"
1704 );
1705 assert!(!batch.all_failed());
1706 assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1707 assert_eq!(remaining_attempts, 0);
1708
1709 let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1710 Ok::<_, ()>(99)
1711 })
1712 .await;
1713 assert!(no_children.items.is_empty());
1714 assert_eq!(no_children.attempted, 0);
1715 assert!(!no_children.all_failed());
1716
1717 let mut failed_attempts = 2;
1718 let all_failed =
1719 fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1720 assert!(all_failed.all_failed());
1721 }
1722}