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}
140
141impl<T> BoundedTraversal<T> {
142 fn new(max_items: usize) -> Self {
143 Self {
144 queue: VecDeque::new(),
145 attempted: 0,
146 max_items,
147 }
148 }
149
150 fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
151 self.queue
152 .extend(items.into_iter().take(self.remaining_capacity()));
153 }
154
155 fn pop(&mut self) -> Option<T> {
156 if self.attempted >= self.max_items {
157 return None;
158 }
159 let item = self.queue.pop_front()?;
160 self.attempted += 1;
161 Some(item)
162 }
163
164 fn remaining_capacity(&self) -> usize {
165 self.max_items
166 .saturating_sub(self.attempted.saturating_add(self.queue.len()))
167 }
168}
169
170fn bounded_child_count(reported: i32, limit: usize) -> usize {
171 usize::try_from(reported).unwrap_or_default().min(limit)
172}
173
174struct IndexedReadBatch<T> {
175 items: Vec<T>,
176 attempted: usize,
177}
178
179impl<T> IndexedReadBatch<T> {
180 fn all_failed(&self) -> bool {
181 self.attempted > 0 && self.items.is_empty()
182 }
183}
184
185async fn fetch_indexed_up_to<T, E, F, Fut>(
186 reported: i32,
187 limit: usize,
188 remaining_attempts: &mut usize,
189 fetch: F,
190) -> IndexedReadBatch<T>
191where
192 F: Fn(i32) -> Fut,
193 Fut: Future<Output = std::result::Result<T, E>>,
194{
195 let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
196 *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
197 let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
198
199 let items = stream::iter(0..end_index)
200 .map(fetch)
201 .buffered(CHILD_READ_CONCURRENCY)
202 .filter_map(|result| async move { result.ok() })
203 .collect()
204 .await;
205
206 IndexedReadBatch {
207 items,
208 attempted: attempt_count,
209 }
210}
211
212async fn children_up_to(
213 proxy: &AccessibleProxy<'_>,
214 limit: usize,
215 remaining_attempts: &mut usize,
216) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
217 if limit == 0 || *remaining_attempts == 0 {
218 return Ok(IndexedReadBatch {
219 items: Vec::new(),
220 attempted: 0,
221 });
222 }
223
224 let child_count = proxy.child_count().await?;
225 Ok(
226 fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
227 proxy.get_child_at_index(index)
228 })
229 .await,
230 )
231}
232
233pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
234 let conn = connect().await?;
235 let mut remaining_child_reads = limit;
236 let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
237 let dbus = DBusProxy::new(conn.connection()).await.ok();
238 let mut apps = Vec::new();
239
240 for object_ref in roots.into_iter().take(limit) {
241 if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
242 apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
243 }
244 }
245
246 Ok(apps)
247}
248
249pub async fn snapshot_tree(
250 app_name_or_bundle_identifier: Option<&str>,
251 target_pid: Option<u32>,
252 max_nodes: usize,
253 max_depth: u32,
254) -> Result<Vec<AccessibilityNode>> {
255 let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
256 timeout(
257 SNAPSHOT_TIMEOUT,
258 snapshot_tree_inner(
259 app_name_or_bundle_identifier,
260 target_pid,
261 max_nodes,
262 max_depth,
263 ),
264 )
265 .await
266 .context("AT-SPI snapshot exceeded its 10-second deadline")?
267}
268
269async fn snapshot_tree_inner(
270 app_name_or_bundle_identifier: Option<&str>,
271 target_pid: Option<u32>,
272 max_nodes: usize,
273 max_depth: u32,
274) -> Result<Vec<AccessibilityNode>> {
275 let conn = connect().await?;
276 let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
279 snapshot_child_read_budgets(max_nodes);
280 let roots =
281 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
282 let selected_roots = select_roots(
283 &conn,
284 roots,
285 app_name_or_bundle_identifier,
286 target_pid,
287 &mut remaining_filter_reads,
288 )
289 .await;
290 let mut nodes = Vec::new();
291 let mut traversal = BoundedTraversal::new(max_nodes);
292
293 traversal.enqueue(
294 selected_roots
295 .into_iter()
296 .map(|object_ref| (object_ref, 0_u32, None)),
297 );
298
299 while let Some((object_ref, depth, parent_index)) = traversal.pop() {
300 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
301 continue;
302 };
303 let index = nodes.len() as u32;
304 let remaining = traversal.remaining_capacity();
305 let child_refs = if depth < max_depth && remaining > 0 {
306 children_up_to(&proxy, remaining, &mut remaining_traversal_reads)
307 .await
308 .map(|batch| batch.items)
309 .unwrap_or_default()
310 } else {
311 Vec::new()
312 };
313
314 nodes.push(read_node(&proxy, &object_ref, index, parent_index, depth).await);
315
316 traversal.enqueue(
317 child_refs
318 .into_iter()
319 .map(|child| (child, depth + 1, Some(index))),
320 );
321 }
322
323 Ok(nodes)
324}
325
326#[derive(Debug, Clone, Serialize, JsonSchema)]
329pub struct FocusedElementSummary {
330 pub role: String,
331 pub name: Option<String>,
332 pub editable: bool,
333 pub states: Vec<String>,
334}
335
336const FOCUS_PROBE_MAX_NODES: usize = 400;
337const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
338
339pub async fn focused_element_summary(
344 target_pid: Option<u32>,
345) -> Result<Option<FocusedElementSummary>> {
346 let conn = connect().await?;
347 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
348 let roots =
349 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
350 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
351 let selected_roots =
352 select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
353 let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
354 let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
355
356 traversal.enqueue(
357 selected_roots
358 .into_iter()
359 .map(|object_ref| (object_ref, 0_u32)),
360 );
361
362 while let Some((object_ref, depth)) = traversal.pop() {
363 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
364 continue;
365 };
366 let Ok(state) = proxy.get_state().await else {
367 continue;
368 };
369 if state.contains(atspi::State::Focused) {
370 let proxies = proxy.proxies().await.ok();
371 return Ok(Some(FocusedElementSummary {
372 role: role_name(&proxy).await,
373 name: optional_string(proxy.name().await.ok()),
374 editable: supports_editable_text(proxies.as_ref()).await,
375 states: state_labels(state),
376 }));
377 }
378 if depth < FOCUS_PROBE_MAX_DEPTH {
379 let remaining = traversal.remaining_capacity();
380 let children = children_up_to(&proxy, remaining, &mut remaining_traversal_reads)
381 .await
382 .map(|batch| batch.items)
383 .unwrap_or_default();
384 traversal.enqueue(children.into_iter().map(|child| (child, depth + 1)));
385 }
386 }
387
388 Ok(None)
389}
390
391pub async fn perform_action(
392 object_ref_id: &str,
393 requested_action: Option<&str>,
394) -> Result<ActionInvocation> {
395 perform_action_inner(object_ref_id, requested_action, false).await
396}
397
398pub(crate) async fn perform_named_action(
399 object_ref_id: &str,
400 name: &str,
401) -> Result<ActionInvocation> {
402 perform_action_inner(object_ref_id, Some(name), true).await
403}
404
405async fn perform_action_inner(
406 object_ref_id: &str,
407 requested_action: Option<&str>,
408 name_only: bool,
409) -> Result<ActionInvocation> {
410 let conn = connect().await?;
411 let object_ref = object_ref_from_id(object_ref_id)?;
412 let proxy = open_accessible(&conn, &object_ref)
413 .await
414 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
415 let action = proxy
416 .proxies()
417 .await?
418 .action()
419 .await
420 .context("element does not expose the AT-SPI Action interface")?;
421 let actions = action.get_actions().await.unwrap_or_default();
422 let action_index = if name_only {
423 select_named_action_index(&actions, requested_action.unwrap_or_default())?
424 } else {
425 select_action_index(&actions, requested_action)?
426 };
427 let action_name = actions
428 .get(action_index as usize)
429 .map(|action| action.name.clone());
430 let ok = action
431 .do_action(action_index)
432 .await
433 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
434
435 Ok(ActionInvocation {
436 action_index,
437 action_name,
438 ok,
439 })
440}
441
442pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
443 let conn = connect().await?;
444 let object_ref = object_ref_from_id(object_ref_id)?;
445 let proxy = open_accessible(&conn, &object_ref)
446 .await
447 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
448 let proxies = proxy.proxies().await?;
449
450 if let Ok(numeric_value) = value.parse::<f64>() {
451 if let Ok(value_proxy) = proxies.value().await {
452 value_proxy
453 .set_current_value(numeric_value)
454 .await
455 .with_context(|| {
456 format!("failed to set AT-SPI numeric value to {numeric_value}")
457 })?;
458 return Ok(ValueSetInvocation::Numeric {
459 value: numeric_value,
460 });
461 }
462 }
463
464 if let Ok(editable_text) = proxies.editable_text().await {
465 let ok = editable_text
466 .set_text_contents(value)
467 .await
468 .context("failed to set AT-SPI editable text contents")?;
469 if ok {
470 return Ok(ValueSetInvocation::EditableText);
471 }
472 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
473 }
474
475 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
476 return Err(anyhow!(
477 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
478 ));
479 }
480
481 Err(anyhow!(
482 "element does not expose AT-SPI Value or EditableText interfaces"
483 ))
484}
485
486async fn connect() -> Result<AccessibilityConnection> {
487 hydrate_session_bus_env();
488 AccessibilityConnection::new()
489 .await
490 .context("failed to connect to AT-SPI bus")
491}
492
493async fn open_accessible<'r>(
506 conn: &AccessibilityConnection,
507 object_ref: &'r ObjectRefOwned,
508) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
509 object_ref.as_accessible_proxy(conn.connection()).await
510}
511
512async fn registry_children(
513 conn: &AccessibilityConnection,
514 limit: usize,
515 remaining_child_reads: &mut usize,
516) -> Result<Vec<ObjectRefOwned>> {
517 let root = conn
518 .root_accessible_on_registry()
519 .await
520 .context("failed to open AT-SPI registry root")?;
521 let batch = children_up_to(&root, limit, remaining_child_reads)
522 .await
523 .context("failed to read AT-SPI registry children")?;
524 if batch.all_failed() {
525 return Err(anyhow!(
526 "AT-SPI registry reported children, but every indexed child read failed"
527 ));
528 }
529 Ok(batch.items)
530}
531
532async fn select_roots(
533 conn: &AccessibilityConnection,
534 roots: Vec<ObjectRefOwned>,
535 app_name_or_bundle_identifier: Option<&str>,
536 target_pid: Option<u32>,
537 remaining_child_reads: &mut usize,
538) -> Vec<ObjectRefOwned> {
539 let needle = app_name_or_bundle_identifier
540 .map(str::trim)
541 .filter(|value| !value.is_empty())
542 .map(|value| value.to_ascii_lowercase());
543 let dbus = DBusProxy::new(conn.connection()).await.ok();
544 let mut remaining = roots;
545
546 if let Some(target_pid) = target_pid {
547 let mut pid_and_filter_matches = Vec::new();
548 let mut pid_matches = Vec::new();
549 let mut non_pid_matches = Vec::new();
550
551 for object_ref in remaining {
552 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
553 if let Some(needle) = needle.as_deref() {
554 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
555 pid_and_filter_matches.push(object_ref);
556 } else {
557 pid_matches.push(object_ref);
558 }
559 } else {
560 pid_matches.push(object_ref);
561 }
562 } else {
563 non_pid_matches.push(object_ref);
564 }
565 }
566
567 if !pid_and_filter_matches.is_empty() {
568 return pid_and_filter_matches;
569 }
570 if !pid_matches.is_empty() {
571 return pid_matches;
572 }
573
574 remaining = non_pid_matches;
575 }
576
577 let Some(needle) = needle.as_deref() else {
578 return remaining;
579 };
580
581 let mut selected = Vec::new();
582 for object_ref in remaining {
583 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
584 selected.push(object_ref);
585 }
586 }
587
588 selected
589}
590
591async fn root_matches(
592 conn: &AccessibilityConnection,
593 object_ref: &ObjectRefOwned,
594 needle: &str,
595 remaining_child_reads: &mut usize,
596) -> bool {
597 let Ok(proxy) = open_accessible(conn, object_ref).await else {
598 return object_ref_id(object_ref)
599 .to_ascii_lowercase()
600 .contains(needle);
601 };
602
603 if proxy_matches(&proxy, object_ref, needle).await {
604 return true;
605 }
606
607 for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
608 .await
609 .map(|batch| batch.items)
610 .unwrap_or_default()
611 {
612 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
613 continue;
614 };
615 if proxy_matches(&child_proxy, &child_ref, needle).await {
616 return true;
617 }
618 }
619
620 false
621}
622
623async fn proxy_matches(
624 proxy: &AccessibleProxy<'_>,
625 object_ref: &ObjectRefOwned,
626 needle: &str,
627) -> bool {
628 let name = proxy.name().await.unwrap_or_default();
629 let role = proxy.get_role_name().await.unwrap_or_default();
630 format!("{} {} {}", object_ref_id(object_ref), name, role)
631 .to_ascii_lowercase()
632 .contains(needle)
633}
634
635async fn read_app_summary(
636 proxy: &AccessibleProxy<'_>,
637 object_ref: &ObjectRefOwned,
638 dbus: Option<&DBusProxy<'_>>,
639) -> AccessibleAppSummary {
640 AccessibleAppSummary {
641 object_ref: object_ref_id(object_ref),
642 name: optional_string(proxy.name().await.ok()),
643 pid: object_ref_pid(dbus, object_ref).await,
644 role: role_name(proxy).await,
645 child_count: proxy.child_count().await.unwrap_or_default(),
646 bounds: bounds(proxy).await,
647 }
648}
649
650async fn read_node(
651 proxy: &AccessibleProxy<'_>,
652 object_ref: &ObjectRefOwned,
653 index: u32,
654 parent_index: Option<u32>,
655 depth: u32,
656) -> AccessibilityNode {
657 let proxies = proxy.proxies().await.ok();
658
659 AccessibilityNode {
660 index,
661 parent_index,
662 depth,
663 object_ref: object_ref_id(object_ref),
664 role: role_name(proxy).await,
665 name: optional_string(proxy.name().await.ok()),
666 description: optional_string(proxy.description().await.ok()),
667 child_count: proxy.child_count().await.unwrap_or_default(),
668 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
669 states: states_from_proxy(proxy).await,
670 actions: actions_from_proxies(proxies.as_ref()).await,
671 value: value_from_proxies(proxies.as_ref()).await,
672 text: text_from_proxies(proxies.as_ref()).await,
673 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
674 }
675}
676
677async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
678 if let Ok(role) = proxy.get_role_name().await {
679 if !role.trim().is_empty() {
680 return role;
681 }
682 }
683 proxy
684 .get_role()
685 .await
686 .map(|role| format!("{role:?}"))
687 .unwrap_or_else(|_| "unknown".to_string())
688}
689
690async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
691 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
692}
693
694async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
695 let dbus = dbus?;
696 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
697 dbus.get_connection_unix_process_id(bus_name).await.ok()
698}
699
700async fn bounds_from_proxies(
701 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
702 proxy: &AccessibleProxy<'_>,
703) -> Option<Bounds> {
704 let owned_proxies;
705 let proxies = if let Some(proxies) = proxies {
706 proxies
707 } else {
708 owned_proxies = proxy.proxies().await.ok()?;
709 &owned_proxies
710 };
711 let component = proxies.component().await.ok()?;
712 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
713 normalize_bounds(Bounds {
714 x,
715 y,
716 width,
717 height,
718 })
719}
720
721fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
722 if bounds.width <= 0 || bounds.height <= 0 {
723 return None;
724 }
725 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
726 return None;
727 }
728 Some(bounds)
729}
730
731async fn actions_from_proxies(
732 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
733) -> Vec<AccessibilityAction> {
734 let Some(proxies) = proxies else {
735 return Vec::new();
736 };
737 let Ok(action_proxy) = proxies.action().await else {
738 return Vec::new();
739 };
740
741 action_proxy
742 .get_actions()
743 .await
744 .unwrap_or_default()
745 .into_iter()
746 .enumerate()
747 .map(|(index, action)| AccessibilityAction {
748 index: index as i32,
749 name: action.name,
750 description: action.description,
751 keybinding: action.keybinding,
752 })
753 .collect()
754}
755
756async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
757 proxy
758 .get_state()
759 .await
760 .map(state_labels)
761 .unwrap_or_default()
762}
763
764async fn value_from_proxies(
765 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
766) -> Option<AccessibilityValue> {
767 let value = proxies?.value().await.ok()?;
768 Some(AccessibilityValue {
769 current: value.current_value().await.ok()?,
770 minimum: value.minimum_value().await.ok()?,
771 maximum: value.maximum_value().await.ok()?,
772 minimum_increment: value.minimum_increment().await.ok()?,
773 text: optional_string(value.text().await.ok()),
774 })
775}
776
777async fn text_from_proxies(
778 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
779) -> Option<AccessibilityText> {
780 let text = proxies?.text().await.ok()?;
781 let character_count = text.character_count().await.ok()?.max(0);
782 let caret_offset = text.caret_offset().await.ok();
783 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
784 let content = if capped_count > 0 {
785 optional_string(text.get_text(0, capped_count).await.ok())
786 } else {
787 None
788 };
789 let selection_count = text
790 .get_nselections()
791 .await
792 .unwrap_or_default()
793 .clamp(0, MAX_TEXT_SELECTIONS);
794 let mut selections = Vec::new();
795 for index in 0..selection_count {
796 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
797 selections.push(AccessibilityTextSelection {
798 start_offset,
799 end_offset,
800 });
801 }
802 }
803
804 Some(AccessibilityText {
805 character_count,
806 caret_offset,
807 content,
808 truncated: character_count > MAX_TEXT_READBACK_CHARS,
809 selections,
810 })
811}
812
813async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
814 let Some(proxies) = proxies else {
815 return false;
816 };
817 proxies.editable_text().await.is_ok()
818}
819
820fn state_labels(state_set: StateSet) -> Vec<String> {
821 state_set.iter().map(|state| state.to_string()).collect()
822}
823
824fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
825 if actions.is_empty() {
826 return Err(anyhow!("element exposes no AT-SPI actions"));
827 }
828
829 if let Some(requested_action) = requested_action
830 .map(str::trim)
831 .filter(|value| !value.is_empty())
832 {
833 let requested_action = requested_action.to_ascii_lowercase();
834 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
835 action.name.to_ascii_lowercase() == requested_action
836 || action.description.to_ascii_lowercase() == requested_action
837 }) {
838 return Ok(index as i32);
839 }
840
841 if let Ok(index) = requested_action.parse::<usize>() {
842 if index < actions.len() {
843 return Ok(index as i32);
844 }
845 }
846
847 return Err(anyhow!(
848 "requested AT-SPI action was not found; available actions: {}",
849 actions
850 .iter()
851 .map(|action| action.name.as_str())
852 .collect::<Vec<_>>()
853 .join(", ")
854 ));
855 }
856
857 Ok(if actions.len() > 1 { 1 } else { 0 })
858}
859
860fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
861 actions
862 .iter()
863 .position(|action| action.name.eq_ignore_ascii_case(name))
864 .map(|index| index as i32)
865 .ok_or_else(|| {
866 anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
867 })
868}
869
870fn optional_string(value: Option<String>) -> Option<String> {
871 value
872 .map(|value| value.trim().to_string())
873 .filter(|value| !value.is_empty())
874}
875
876fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
877 let (name, path) = split_object_ref_id(object_ref_id)?;
878 let name = UniqueName::try_from(name.to_string())
879 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
880 let path = ObjectPath::try_from(path.to_string())
881 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
882 Ok(ObjectRef::new_owned(name, path))
883}
884
885fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
886 let Some(path_start) = object_ref_id.find('/') else {
887 return Err(anyhow!(
888 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
889 ));
890 };
891 let (name, path) = object_ref_id.split_at(path_start);
892 if name.is_empty() || path.is_empty() {
893 return Err(anyhow!(
894 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
895 ));
896 }
897 Ok((name, path))
898}
899
900fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
901 format!(
902 "{}{}",
903 object_ref.name_as_str().unwrap_or(""),
904 object_ref.path_as_str()
905 )
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911
912 #[test]
913 fn split_object_ref_id_separates_bus_name_and_path() {
914 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
915
916 assert_eq!(name, ":1.42");
917 assert_eq!(path, "/org/a11y/atspi/accessible/7");
918 }
919
920 #[test]
921 fn select_action_index_uses_named_action() {
922 let actions = vec![
923 atspi::Action {
924 name: "click".to_string(),
925 description: "Clicks".to_string(),
926 keybinding: String::new(),
927 },
928 atspi::Action {
929 name: "show-menu".to_string(),
930 description: "Shows menu".to_string(),
931 keybinding: String::new(),
932 },
933 ];
934
935 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
936 }
937
938 #[test]
939 fn cached_action_name_is_revalidated_against_current_actions() {
940 let mut actions = vec![atspi::Action {
941 name: "show-menu".into(),
942 description: "Click".into(),
943 keybinding: String::new(),
944 }];
945 assert!(select_named_action_index(&actions, "Click").is_err());
947 actions.push(atspi::Action {
948 name: "click".into(),
949 description: String::new(),
950 keybinding: String::new(),
951 });
952 assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
953 }
954
955 #[test]
956 fn select_action_index_defaults_to_secondary_when_available() {
957 let actions = vec![
958 atspi::Action {
959 name: "click".to_string(),
960 description: String::new(),
961 keybinding: String::new(),
962 },
963 atspi::Action {
964 name: "show-menu".to_string(),
965 description: String::new(),
966 keybinding: String::new(),
967 },
968 ];
969
970 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
971 }
972
973 #[test]
974 fn state_labels_serialize_in_bit_order() {
975 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
976
977 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
978 }
979
980 #[test]
981 fn default_snapshot_limits_cover_deep_gtk4_trees() {
982 assert_eq!(snapshot_limits(None, None), (1_000, 32));
985 }
986
987 #[test]
988 fn requested_snapshot_limits_remain_bounded() {
989 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
990 assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
991 }
992
993 #[test]
994 fn app_discovery_budget_is_independent_of_requested_tree_size() {
995 assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
996 }
997
998 #[test]
999 fn traversal_attempts_and_queue_share_one_work_budget() {
1000 let mut traversal = BoundedTraversal::new(4);
1001 traversal.enqueue([1]);
1002
1003 assert_eq!(traversal.pop(), Some(1));
1004 traversal.enqueue(2..=10_000);
1005 assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
1006
1007 assert_eq!(traversal.pop(), Some(2));
1008 traversal.enqueue(5..=10_000);
1009 assert_eq!(traversal.queue, VecDeque::from([3, 4]));
1010 assert_eq!(traversal.pop(), Some(3));
1011 assert_eq!(traversal.pop(), Some(4));
1012 assert_eq!(traversal.pop(), None);
1013 assert_eq!(traversal.attempted, 4);
1014 }
1015
1016 #[test]
1017 fn child_count_is_clamped_before_indexed_reads() {
1018 assert_eq!(bounded_child_count(-1, 4), 0);
1019 assert_eq!(bounded_child_count(3, 4), 3);
1020 assert_eq!(bounded_child_count(i32::MAX, 4), 4);
1021 }
1022
1023 #[tokio::test]
1024 async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
1025 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1026 let mut remaining_attempts = 3;
1027 let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
1028 let calls = calls.clone();
1029 move |index| {
1030 let calls = calls.clone();
1031 async move {
1032 calls.lock().unwrap().push(index);
1033 if index == 1 {
1034 Err(())
1035 } else {
1036 Ok(index)
1037 }
1038 }
1039 }
1040 })
1041 .await;
1042
1043 assert_eq!(batch.items, vec![0, 2]);
1044 assert_eq!(batch.attempted, 3);
1045 assert!(!batch.all_failed());
1046 assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
1047 assert_eq!(remaining_attempts, 0);
1048
1049 let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
1050 Ok::<_, ()>(99)
1051 })
1052 .await;
1053 assert!(no_children.items.is_empty());
1054 assert_eq!(no_children.attempted, 0);
1055 assert!(!no_children.all_failed());
1056
1057 let mut failed_attempts = 2;
1058 let all_failed =
1059 fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
1060 assert!(all_failed.all_failed());
1061 }
1062}