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 truncated: bool,
281}
282
283pub async fn snapshot_tree(
284 app_name_or_bundle_identifier: Option<&str>,
285 target_pid: Option<u32>,
286 max_nodes: usize,
287 max_depth: u32,
288) -> Result<Vec<AccessibilityNode>> {
289 Ok(snapshot_accessibility_tree(
290 app_name_or_bundle_identifier,
291 target_pid,
292 max_nodes,
293 max_depth,
294 )
295 .await?
296 .nodes)
297}
298
299pub(crate) async fn snapshot_accessibility_tree(
300 app_name_or_bundle_identifier: Option<&str>,
301 target_pid: Option<u32>,
302 max_nodes: usize,
303 max_depth: u32,
304) -> Result<AccessibilitySnapshot> {
305 let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
306 timeout(
307 SNAPSHOT_TIMEOUT,
308 snapshot_tree_inner(
309 app_name_or_bundle_identifier,
310 target_pid,
311 max_nodes,
312 max_depth,
313 ),
314 )
315 .await
316 .context("AT-SPI snapshot exceeded its 10-second deadline")?
317}
318
319async fn snapshot_tree_inner(
320 app_name_or_bundle_identifier: Option<&str>,
321 target_pid: Option<u32>,
322 max_nodes: usize,
323 max_depth: u32,
324) -> Result<AccessibilitySnapshot> {
325 let conn = connect().await?;
326 let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
329 snapshot_child_read_budgets(max_nodes);
330 let roots =
331 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
332 let selected_roots = select_roots(
333 &conn,
334 roots,
335 app_name_or_bundle_identifier,
336 target_pid,
337 &mut remaining_filter_reads,
338 )
339 .await;
340 let scoped = selected_roots.scoped;
341 let mut nodes = Vec::new();
342 let mut truncated = false;
343 let mut traversal = BoundedTraversal::new(max_nodes);
344
345 traversal.enqueue(
346 selected_roots
347 .roots
348 .into_iter()
349 .map(|object_ref| (object_ref, 0_u32, None)),
350 );
351
352 while let Some((object_ref, depth, parent_index)) = traversal.pop() {
353 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
354 continue;
355 };
356 let index = nodes.len() as u32;
357 let remaining = traversal.remaining_capacity();
358 let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
359 let child_refs = if depth < max_depth && remaining > 0 {
360 match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
361 Ok(batch) => {
362 truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
366 batch.items
367 }
368 Err(_) => Vec::new(),
369 }
370 } else {
371 truncated |= node.child_count > 0;
373 Vec::new()
374 };
375
376 nodes.push(node);
377
378 traversal.enqueue(
379 child_refs
380 .into_iter()
381 .map(|child| (child, depth + 1, Some(index))),
382 );
383 }
384 truncated |= traversal.truncated();
385
386 Ok(AccessibilitySnapshot {
387 nodes,
388 scoped,
389 truncated,
390 })
391}
392
393#[derive(Debug, Clone, Serialize, JsonSchema)]
396pub struct FocusedElementSummary {
397 pub role: String,
398 pub name: Option<String>,
399 pub editable: bool,
400 pub states: Vec<String>,
401}
402
403const FOCUS_PROBE_MAX_NODES: usize = 400;
404const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
405
406pub async fn focused_element_summary(
411 target_pid: Option<u32>,
412) -> Result<Option<FocusedElementSummary>> {
413 let conn = connect().await?;
414 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
415 let roots =
416 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
417 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
418 let selected_roots =
419 select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
420 let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
421 let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
422
423 traversal.enqueue(
424 selected_roots
425 .roots
426 .into_iter()
427 .map(|object_ref| (object_ref, 0_u32)),
428 );
429
430 while let Some((object_ref, depth)) = traversal.pop() {
431 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
432 continue;
433 };
434 let Ok(state) = proxy.get_state().await else {
435 continue;
436 };
437 if state.contains(atspi::State::Focused) {
438 let proxies = proxy.proxies().await.ok();
439 return Ok(Some(FocusedElementSummary {
440 role: role_name(&proxy).await,
441 name: optional_string(proxy.name().await.ok()),
442 editable: supports_editable_text(proxies.as_ref()).await,
443 states: state_labels(state),
444 }));
445 }
446 if depth < FOCUS_PROBE_MAX_DEPTH {
447 let remaining = traversal.remaining_capacity();
448 let children = children_up_to(&proxy, remaining, &mut remaining_traversal_reads)
449 .await
450 .map(|batch| batch.items)
451 .unwrap_or_default();
452 traversal.enqueue(children.into_iter().map(|child| (child, depth + 1)));
453 }
454 }
455
456 Ok(None)
457}
458
459pub async fn perform_action(
460 object_ref_id: &str,
461 requested_action: Option<&str>,
462) -> Result<ActionInvocation> {
463 perform_action_inner(object_ref_id, requested_action, false).await
464}
465
466pub(crate) async fn perform_named_action(
467 object_ref_id: &str,
468 name: &str,
469) -> Result<ActionInvocation> {
470 perform_action_inner(object_ref_id, Some(name), true).await
471}
472
473async fn perform_action_inner(
474 object_ref_id: &str,
475 requested_action: Option<&str>,
476 name_only: bool,
477) -> Result<ActionInvocation> {
478 let conn = connect().await?;
479 let object_ref = object_ref_from_id(object_ref_id)?;
480 let proxy = open_accessible(&conn, &object_ref)
481 .await
482 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
483 let action = proxy
484 .proxies()
485 .await?
486 .action()
487 .await
488 .context("element does not expose the AT-SPI Action interface")?;
489 let actions = action.get_actions().await.unwrap_or_default();
490 let action_index = if name_only {
491 select_named_action_index(&actions, requested_action.unwrap_or_default())?
492 } else {
493 select_action_index(&actions, requested_action)?
494 };
495 let action_name = actions
496 .get(action_index as usize)
497 .map(|action| action.name.clone());
498 let ok = action
499 .do_action(action_index)
500 .await
501 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
502
503 Ok(ActionInvocation {
504 action_index,
505 action_name,
506 ok,
507 })
508}
509
510pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
511 let conn = connect().await?;
512 let object_ref = object_ref_from_id(object_ref_id)?;
513 let proxy = open_accessible(&conn, &object_ref)
514 .await
515 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
516 let proxies = proxy.proxies().await?;
517
518 if let Ok(numeric_value) = value.parse::<f64>() {
519 if let Ok(value_proxy) = proxies.value().await {
520 value_proxy
521 .set_current_value(numeric_value)
522 .await
523 .with_context(|| {
524 format!("failed to set AT-SPI numeric value to {numeric_value}")
525 })?;
526 return Ok(ValueSetInvocation::Numeric {
527 value: numeric_value,
528 });
529 }
530 }
531
532 if let Ok(editable_text) = proxies.editable_text().await {
533 let ok = editable_text
534 .set_text_contents(value)
535 .await
536 .context("failed to set AT-SPI editable text contents")?;
537 if ok {
538 return Ok(ValueSetInvocation::EditableText);
539 }
540 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
541 }
542
543 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
544 return Err(anyhow!(
545 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
546 ));
547 }
548
549 Err(anyhow!(
550 "element does not expose AT-SPI Value or EditableText interfaces"
551 ))
552}
553
554async fn connect() -> Result<AccessibilityConnection> {
555 hydrate_session_bus_env();
556 AccessibilityConnection::new()
557 .await
558 .context("failed to connect to AT-SPI bus")
559}
560
561async fn open_accessible<'r>(
574 conn: &AccessibilityConnection,
575 object_ref: &'r ObjectRefOwned,
576) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
577 object_ref.as_accessible_proxy(conn.connection()).await
578}
579
580async fn registry_children(
581 conn: &AccessibilityConnection,
582 limit: usize,
583 remaining_child_reads: &mut usize,
584) -> Result<Vec<ObjectRefOwned>> {
585 let root = conn
586 .root_accessible_on_registry()
587 .await
588 .context("failed to open AT-SPI registry root")?;
589 let batch = children_up_to(&root, limit, remaining_child_reads)
590 .await
591 .context("failed to read AT-SPI registry children")?;
592 if batch.all_failed() {
593 return Err(anyhow!(
594 "AT-SPI registry reported children, but every indexed child read failed"
595 ));
596 }
597 Ok(batch.items)
598}
599
600struct SelectedRoots {
601 roots: Vec<ObjectRefOwned>,
602 scoped: bool,
603}
604
605fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
608 app_name_or_bundle_identifier
609 .map(str::trim)
610 .filter(|value| !value.is_empty())
611 .map(|value| value.to_ascii_lowercase())
612}
613
614async fn select_roots(
615 conn: &AccessibilityConnection,
616 roots: Vec<ObjectRefOwned>,
617 app_name_or_bundle_identifier: Option<&str>,
618 target_pid: Option<u32>,
619 remaining_child_reads: &mut usize,
620) -> SelectedRoots {
621 let needle = app_name_needle(app_name_or_bundle_identifier);
622 let dbus = DBusProxy::new(conn.connection()).await.ok();
623 let mut remaining = roots;
624
625 if let Some(target_pid) = target_pid {
626 let mut pid_and_filter_matches = Vec::new();
627 let mut pid_matches = Vec::new();
628 let mut non_pid_matches = Vec::new();
629
630 for object_ref in remaining {
631 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
632 if let Some(needle) = needle.as_deref() {
633 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
634 pid_and_filter_matches.push(object_ref);
635 } else {
636 pid_matches.push(object_ref);
637 }
638 } else {
639 pid_matches.push(object_ref);
640 }
641 } else {
642 non_pid_matches.push(object_ref);
643 }
644 }
645
646 if !pid_and_filter_matches.is_empty() {
647 return SelectedRoots {
648 roots: pid_and_filter_matches,
649 scoped: true,
650 };
651 }
652 if !pid_matches.is_empty() {
653 return SelectedRoots {
654 roots: pid_matches,
655 scoped: true,
656 };
657 }
658
659 remaining = non_pid_matches;
660 }
661
662 let Some(needle) = needle.as_deref() else {
663 return SelectedRoots {
664 roots: remaining,
665 scoped: false,
666 };
667 };
668
669 let mut selected = Vec::new();
670 for object_ref in remaining {
671 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
672 selected.push(object_ref);
673 }
674 }
675
676 SelectedRoots {
677 roots: selected,
678 scoped: true,
679 }
680}
681
682async fn root_matches(
683 conn: &AccessibilityConnection,
684 object_ref: &ObjectRefOwned,
685 needle: &str,
686 remaining_child_reads: &mut usize,
687) -> bool {
688 let Ok(proxy) = open_accessible(conn, object_ref).await else {
689 return object_ref_id(object_ref)
690 .to_ascii_lowercase()
691 .contains(needle);
692 };
693
694 if proxy_matches(&proxy, object_ref, needle).await {
695 return true;
696 }
697
698 for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
699 .await
700 .map(|batch| batch.items)
701 .unwrap_or_default()
702 {
703 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
704 continue;
705 };
706 if proxy_matches(&child_proxy, &child_ref, needle).await {
707 return true;
708 }
709 }
710
711 false
712}
713
714async fn proxy_matches(
715 proxy: &AccessibleProxy<'_>,
716 object_ref: &ObjectRefOwned,
717 needle: &str,
718) -> bool {
719 let name = proxy.name().await.unwrap_or_default();
720 let role = proxy.get_role_name().await.unwrap_or_default();
721 format!("{} {} {}", object_ref_id(object_ref), name, role)
722 .to_ascii_lowercase()
723 .contains(needle)
724}
725
726async fn read_app_summary(
727 proxy: &AccessibleProxy<'_>,
728 object_ref: &ObjectRefOwned,
729 dbus: Option<&DBusProxy<'_>>,
730) -> AccessibleAppSummary {
731 AccessibleAppSummary {
732 object_ref: object_ref_id(object_ref),
733 name: optional_string(proxy.name().await.ok()),
734 pid: object_ref_pid(dbus, object_ref).await,
735 role: role_name(proxy).await,
736 child_count: proxy.child_count().await.unwrap_or_default(),
737 bounds: bounds(proxy).await,
738 }
739}
740
741async fn read_node(
742 proxy: &AccessibleProxy<'_>,
743 object_ref: &ObjectRefOwned,
744 index: u32,
745 parent_index: Option<u32>,
746 depth: u32,
747) -> AccessibilityNode {
748 let proxies = proxy.proxies().await.ok();
749
750 AccessibilityNode {
751 index,
752 parent_index,
753 depth,
754 object_ref: object_ref_id(object_ref),
755 role: role_name(proxy).await,
756 name: optional_string(proxy.name().await.ok()),
757 description: optional_string(proxy.description().await.ok()),
758 child_count: proxy.child_count().await.unwrap_or_default(),
759 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
760 states: states_from_proxy(proxy).await,
761 actions: actions_from_proxies(proxies.as_ref()).await,
762 value: value_from_proxies(proxies.as_ref()).await,
763 text: text_from_proxies(proxies.as_ref()).await,
764 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
765 }
766}
767
768async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
769 if let Ok(role) = proxy.get_role_name().await {
770 if !role.trim().is_empty() {
771 return role;
772 }
773 }
774 proxy
775 .get_role()
776 .await
777 .map(|role| format!("{role:?}"))
778 .unwrap_or_else(|_| "unknown".to_string())
779}
780
781async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
782 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
783}
784
785async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
786 let dbus = dbus?;
787 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
788 dbus.get_connection_unix_process_id(bus_name).await.ok()
789}
790
791async fn bounds_from_proxies(
792 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
793 proxy: &AccessibleProxy<'_>,
794) -> Option<Bounds> {
795 let owned_proxies;
796 let proxies = if let Some(proxies) = proxies {
797 proxies
798 } else {
799 owned_proxies = proxy.proxies().await.ok()?;
800 &owned_proxies
801 };
802 let component = proxies.component().await.ok()?;
803 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
804 normalize_bounds(Bounds {
805 x,
806 y,
807 width,
808 height,
809 })
810}
811
812fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
813 if bounds.width <= 0 || bounds.height <= 0 {
814 return None;
815 }
816 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
817 return None;
818 }
819 Some(bounds)
820}
821
822async fn actions_from_proxies(
823 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
824) -> Vec<AccessibilityAction> {
825 let Some(proxies) = proxies else {
826 return Vec::new();
827 };
828 let Ok(action_proxy) = proxies.action().await else {
829 return Vec::new();
830 };
831
832 action_proxy
833 .get_actions()
834 .await
835 .unwrap_or_default()
836 .into_iter()
837 .enumerate()
838 .map(|(index, action)| AccessibilityAction {
839 index: index as i32,
840 name: action.name,
841 description: action.description,
842 keybinding: action.keybinding,
843 })
844 .collect()
845}
846
847async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
848 proxy
849 .get_state()
850 .await
851 .map(state_labels)
852 .unwrap_or_default()
853}
854
855async fn value_from_proxies(
856 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
857) -> Option<AccessibilityValue> {
858 let value = proxies?.value().await.ok()?;
859 Some(AccessibilityValue {
860 current: value.current_value().await.ok()?,
861 minimum: value.minimum_value().await.ok()?,
862 maximum: value.maximum_value().await.ok()?,
863 minimum_increment: value.minimum_increment().await.ok()?,
864 text: optional_string(value.text().await.ok()),
865 })
866}
867
868async fn text_from_proxies(
869 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
870) -> Option<AccessibilityText> {
871 let text = proxies?.text().await.ok()?;
872 let character_count = text.character_count().await.ok()?.max(0);
873 let caret_offset = text.caret_offset().await.ok();
874 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
875 let content = if capped_count > 0 {
876 optional_string(text.get_text(0, capped_count).await.ok())
877 } else {
878 None
879 };
880 let selection_count = text
881 .get_nselections()
882 .await
883 .unwrap_or_default()
884 .clamp(0, MAX_TEXT_SELECTIONS);
885 let mut selections = Vec::new();
886 for index in 0..selection_count {
887 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
888 selections.push(AccessibilityTextSelection {
889 start_offset,
890 end_offset,
891 });
892 }
893 }
894
895 Some(AccessibilityText {
896 character_count,
897 caret_offset,
898 content,
899 truncated: character_count > MAX_TEXT_READBACK_CHARS,
900 selections,
901 })
902}
903
904async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
905 let Some(proxies) = proxies else {
906 return false;
907 };
908 proxies.editable_text().await.is_ok()
909}
910
911fn state_labels(state_set: StateSet) -> Vec<String> {
912 state_set.iter().map(|state| state.to_string()).collect()
913}
914
915fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
916 if actions.is_empty() {
917 return Err(anyhow!("element exposes no AT-SPI actions"));
918 }
919
920 if let Some(requested_action) = requested_action
921 .map(str::trim)
922 .filter(|value| !value.is_empty())
923 {
924 let requested_action = requested_action.to_ascii_lowercase();
925 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
926 action.name.to_ascii_lowercase() == requested_action
927 || action.description.to_ascii_lowercase() == requested_action
928 }) {
929 return Ok(index as i32);
930 }
931
932 if let Ok(index) = requested_action.parse::<usize>() {
933 if index < actions.len() {
934 return Ok(index as i32);
935 }
936 }
937
938 return Err(anyhow!(
939 "requested AT-SPI action was not found; available actions: {}",
940 actions
941 .iter()
942 .map(|action| action.name.as_str())
943 .collect::<Vec<_>>()
944 .join(", ")
945 ));
946 }
947
948 Ok(if actions.len() > 1 { 1 } else { 0 })
949}
950
951fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
952 actions
953 .iter()
954 .position(|action| action.name.eq_ignore_ascii_case(name))
955 .map(|index| index as i32)
956 .ok_or_else(|| {
957 anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
958 })
959}
960
961fn optional_string(value: Option<String>) -> Option<String> {
962 value
963 .map(|value| value.trim().to_string())
964 .filter(|value| !value.is_empty())
965}
966
967fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
968 let (name, path) = split_object_ref_id(object_ref_id)?;
969 let name = UniqueName::try_from(name.to_string())
970 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
971 let path = ObjectPath::try_from(path.to_string())
972 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
973 Ok(ObjectRef::new_owned(name, path))
974}
975
976fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
977 let Some(path_start) = object_ref_id.find('/') else {
978 return Err(anyhow!(
979 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
980 ));
981 };
982 let (name, path) = object_ref_id.split_at(path_start);
983 if name.is_empty() || path.is_empty() {
984 return Err(anyhow!(
985 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
986 ));
987 }
988 Ok((name, path))
989}
990
991fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
992 format!(
993 "{}{}",
994 object_ref.name_as_str().unwrap_or(""),
995 object_ref.path_as_str()
996 )
997}
998
999#[cfg(test)]
1000mod tests {
1001 use super::*;
1002
1003 #[test]
1004 fn split_object_ref_id_separates_bus_name_and_path() {
1005 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
1006
1007 assert_eq!(name, ":1.42");
1008 assert_eq!(path, "/org/a11y/atspi/accessible/7");
1009 }
1010
1011 #[test]
1012 fn select_action_index_uses_named_action() {
1013 let actions = vec![
1014 atspi::Action {
1015 name: "click".to_string(),
1016 description: "Clicks".to_string(),
1017 keybinding: String::new(),
1018 },
1019 atspi::Action {
1020 name: "show-menu".to_string(),
1021 description: "Shows menu".to_string(),
1022 keybinding: String::new(),
1023 },
1024 ];
1025
1026 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
1027 }
1028
1029 #[test]
1030 fn cached_action_name_is_revalidated_against_current_actions() {
1031 let mut actions = vec![atspi::Action {
1032 name: "show-menu".into(),
1033 description: "Click".into(),
1034 keybinding: String::new(),
1035 }];
1036 assert!(select_named_action_index(&actions, "Click").is_err());
1038 actions.push(atspi::Action {
1039 name: "click".into(),
1040 description: String::new(),
1041 keybinding: String::new(),
1042 });
1043 assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
1044 }
1045
1046 #[test]
1047 fn select_action_index_defaults_to_secondary_when_available() {
1048 let actions = vec![
1049 atspi::Action {
1050 name: "click".to_string(),
1051 description: String::new(),
1052 keybinding: String::new(),
1053 },
1054 atspi::Action {
1055 name: "show-menu".to_string(),
1056 description: String::new(),
1057 keybinding: String::new(),
1058 },
1059 ];
1060
1061 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
1062 }
1063
1064 #[test]
1065 fn state_labels_serialize_in_bit_order() {
1066 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
1067
1068 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
1069 }
1070
1071 #[test]
1072 fn default_snapshot_limits_cover_deep_gtk4_trees() {
1073 assert_eq!(snapshot_limits(None, None), (1_000, 32));
1076 }
1077
1078 #[test]
1079 fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
1080 assert_eq!(app_name_needle(None), None);
1081 assert_eq!(app_name_needle(Some("")), None);
1082 assert_eq!(app_name_needle(Some(" ")), None);
1083 assert_eq!(
1084 app_name_needle(Some("Calculator")).as_deref(),
1085 Some("calculator")
1086 );
1087 assert_eq!(
1088 app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
1089 Some(":1.64/org/a11y/atspi/accessible/root")
1090 );
1091 }
1092
1093 #[test]
1094 fn requested_snapshot_limits_remain_bounded() {
1095 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
1096 assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
1097 }
1098
1099 #[test]
1100 fn app_discovery_budget_is_independent_of_requested_tree_size() {
1101 assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
1102 }
1103
1104 #[test]
1105 fn traversal_attempts_and_queue_share_one_work_budget() {
1106 let mut traversal = BoundedTraversal::new(4);
1107 traversal.enqueue([1]);
1108
1109 assert_eq!(traversal.pop(), Some(1));
1110 traversal.enqueue(2..=10_000);
1111 assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1112
1113 assert_eq!(traversal.pop(), Some(2));
1114 traversal.enqueue(5..=10_000);
1115 assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1116 assert_eq!(traversal.pop(), Some(3));
1117 assert_eq!(traversal.pop(), Some(4));
1118 assert_eq!(traversal.pop(), None);
1119 assert_eq!(traversal.attempted, 4);
1120 assert!(
1121 traversal.truncated(),
1122 "dropped enqueue items must be reported"
1123 );
1124 }
1125
1126 #[test]
1127 fn traversal_that_drains_within_budget_is_not_truncated() {
1128 let mut traversal = BoundedTraversal::new(3);
1129 traversal.enqueue([1, 2, 3]);
1130 assert_eq!(traversal.pop(), Some(1));
1131 assert_eq!(traversal.pop(), Some(2));
1132 assert_eq!(traversal.pop(), Some(3));
1133 assert_eq!(traversal.pop(), None);
1134 assert!(
1135 !traversal.truncated(),
1136 "exactly max_items real nodes is complete"
1137 );
1138 }
1139
1140 #[test]
1141 fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
1142 let mut traversal = BoundedTraversal::new(2);
1143 traversal.enqueue([1, 2]);
1144 assert_eq!(traversal.pop(), Some(1));
1145 assert_eq!(traversal.pop(), Some(2));
1146 assert!(!traversal.truncated(), "nothing dropped yet");
1147 traversal.enqueue([3]);
1150 assert!(traversal.queue.is_empty());
1151 assert_eq!(traversal.pop(), None);
1152 assert!(traversal.truncated());
1153 }
1154
1155 #[test]
1156 fn child_count_is_clamped_before_indexed_reads() {
1157 assert_eq!(bounded_child_count(-1, 4), 0);
1158 assert_eq!(bounded_child_count(3, 4), 3);
1159 assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1160 }
1161
1162 #[tokio::test]
1163 async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1164 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1165 let mut remaining_attempts = 3;
1166 let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1167 let calls = calls.clone();
1168 move |index| {
1169 let calls = calls.clone();
1170 async move {
1171 calls.lock().unwrap().push(index);
1172 if index == 1 {
1173 Err(())
1174 } else {
1175 Ok(index)
1176 }
1177 }
1178 }
1179 })
1180 .await;
1181
1182 assert_eq!(batch.items, vec![0, 2]);
1183 assert_eq!(batch.attempted, 3);
1184 assert!(
1185 batch.incomplete,
1186 "100 reported children, 3 attempted: budget cut must be reported"
1187 );
1188 assert!(!batch.all_failed());
1189 assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1190 assert_eq!(remaining_attempts, 0);
1191
1192 let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1193 Ok::<_, ()>(99)
1194 })
1195 .await;
1196 assert!(no_children.items.is_empty());
1197 assert_eq!(no_children.attempted, 0);
1198 assert!(!no_children.all_failed());
1199
1200 let mut failed_attempts = 2;
1201 let all_failed =
1202 fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1203 assert!(all_failed.all_failed());
1204 }
1205}