1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4 proxy::{
5 accessible::{AccessibleProxy, ObjectRefExt},
6 action::ActionProxy,
7 editable_text::EditableTextProxy,
8 proxy_ext::{Proxies, ProxyExt},
9 text::TextProxy,
10 },
11 CoordType, ObjectRef, ObjectRefOwned, StateSet,
12};
13use atspi_connection::AccessibilityConnection;
16use futures_util::{stream, StreamExt};
17use schemars::JsonSchema;
18use serde::Serialize;
19use std::{collections::VecDeque, future::Future, time::Duration};
20use tokio::time::{sleep_until, timeout, timeout_at, Instant};
21use zbus::{
22 fdo::DBusProxy,
23 names::{BusName, UniqueName},
24 zvariant::{ObjectPath, OwnedObjectPath},
25};
26
27#[derive(Debug, Clone, Serialize, JsonSchema)]
28pub struct AccessibleAppSummary {
29 pub object_ref: String,
30 pub name: Option<String>,
31 pub pid: Option<u32>,
32 pub role: String,
33 pub child_count: i32,
34 pub bounds: Option<Bounds>,
35}
36
37#[derive(Debug, Clone, Serialize, JsonSchema)]
38pub struct AccessibilityNode {
39 pub index: u32,
40 pub parent_index: Option<u32>,
41 pub depth: u32,
42 pub object_ref: String,
43 pub role: String,
44 pub name: Option<String>,
45 pub description: Option<String>,
46 pub child_count: i32,
47 pub bounds: Option<Bounds>,
48 pub states: Vec<String>,
49 pub actions: Vec<AccessibilityAction>,
50 pub value: Option<AccessibilityValue>,
51 pub text: Option<AccessibilityText>,
52 pub supports_editable_text: bool,
53}
54
55#[derive(Debug, Clone, Serialize, JsonSchema)]
56pub struct Bounds {
57 pub x: i32,
58 pub y: i32,
59 pub width: i32,
60 pub height: i32,
61}
62
63#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct AccessibilityAction {
65 pub index: i32,
66 pub name: String,
67 pub description: String,
68 pub keybinding: String,
69}
70
71#[derive(Debug, Clone, Serialize, JsonSchema)]
72pub struct AccessibilityValue {
73 pub current: f64,
74 pub minimum: f64,
75 pub maximum: f64,
76 pub minimum_increment: f64,
77 pub text: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, JsonSchema)]
81pub struct AccessibilityText {
82 pub character_count: i32,
83 pub caret_offset: Option<i32>,
84 pub content: Option<String>,
85 pub truncated: bool,
86 pub selections: Vec<AccessibilityTextSelection>,
87}
88
89#[derive(Debug, Clone, Serialize, JsonSchema)]
90pub struct AccessibilityTextSelection {
91 pub start_offset: i32,
92 pub end_offset: i32,
93}
94
95#[derive(Debug, Clone)]
96pub struct ActionInvocation {
97 pub action_index: i32,
98 pub action_name: Option<String>,
99 pub ok: bool,
100}
101
102#[derive(Debug, Clone)]
103pub enum ValueSetInvocation {
104 Numeric { value: f64 },
105 EditableText { outcome: EditableTextSetOutcome },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum EditableTextSetOutcome {
110 Verified,
111 MismatchAfterDeadline,
112 ReadbackFailed,
113 ReadbackTimedOut,
114 PasswordUnverifiable,
115}
116
117const MAX_TEXT_READBACK_CHARS: i32 = 4096;
118const EDITABLE_TEXT_READ_CHUNK_UNITS: usize = 4096;
119const EDITABLE_TEXT_VERIFY_TIMEOUT: Duration = Duration::from_secs(1);
120const EDITABLE_TEXT_VERIFY_INTERVAL: Duration = Duration::from_millis(50);
121const MAX_TEXT_SELECTIONS: i32 = 8;
122const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
123const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
124const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
125const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
126const CHILD_READ_CONCURRENCY: usize = 16;
127const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
128const MAX_ACTIONS_PER_OBJECT: i32 = 64;
129const ACTION_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
130const MAX_DISCOVERY_ROOTS: usize = 256;
131const ROOT_MATCH_CHILD_LIMIT: usize = 8;
132const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
133
134fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
135 (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
136}
137
138pub(crate) fn snapshot_limits(
139 requested_max_nodes: Option<usize>,
140 requested_max_depth: Option<u32>,
141) -> (usize, u32) {
142 (
143 requested_max_nodes
144 .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
145 .clamp(1, HARD_SNAPSHOT_MAX_NODES),
146 requested_max_depth
147 .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
148 .min(HARD_SNAPSHOT_MAX_DEPTH),
149 )
150}
151
152struct BoundedTraversal<T> {
153 queue: VecDeque<T>,
154 attempted: usize,
155 max_items: usize,
156 dropped: bool,
158}
159
160impl<T> BoundedTraversal<T> {
161 fn new(max_items: usize) -> Self {
162 Self {
163 queue: VecDeque::new(),
164 attempted: 0,
165 max_items,
166 dropped: false,
167 }
168 }
169
170 fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
171 let capacity = self.remaining_capacity();
172 let mut items = items.into_iter();
173 self.queue.extend(items.by_ref().take(capacity));
174 if items.next().is_some() {
175 self.dropped = true;
176 }
177 }
178
179 fn pop(&mut self) -> Option<T> {
180 if self.attempted >= self.max_items {
181 return None;
182 }
183 let item = self.queue.pop_front()?;
184 self.attempted += 1;
185 Some(item)
186 }
187
188 fn remaining_capacity(&self) -> usize {
189 self.max_items
190 .saturating_sub(self.attempted.saturating_add(self.queue.len()))
191 }
192
193 fn truncated(&self) -> bool {
198 self.dropped
199 }
200}
201
202fn bounded_child_count(reported: i32, limit: usize) -> usize {
203 usize::try_from(reported).unwrap_or_default().min(limit)
204}
205
206struct IndexedReadBatch<T> {
207 items: Vec<T>,
208 attempted: usize,
209 incomplete: bool,
213}
214
215impl<T> IndexedReadBatch<T> {
216 fn all_failed(&self) -> bool {
217 self.attempted > 0 && self.items.is_empty()
218 }
219}
220
221async fn fetch_indexed_up_to<T, E, F, Fut>(
222 reported: i32,
223 limit: usize,
224 remaining_attempts: &mut usize,
225 fetch: F,
226) -> IndexedReadBatch<T>
227where
228 F: Fn(i32) -> Fut,
229 Fut: Future<Output = std::result::Result<T, E>>,
230{
231 let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
232 *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
233 let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
234
235 let items = stream::iter(0..end_index)
236 .map(fetch)
237 .buffered(CHILD_READ_CONCURRENCY)
238 .filter_map(|result| async move { result.ok() })
239 .collect()
240 .await;
241
242 IndexedReadBatch {
243 items,
244 attempted: attempt_count,
245 incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
246 }
247}
248
249async fn children_up_to(
250 proxy: &AccessibleProxy<'_>,
251 limit: usize,
252 remaining_attempts: &mut usize,
253) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
254 if limit == 0 || *remaining_attempts == 0 {
255 return Ok(IndexedReadBatch {
259 items: Vec::new(),
260 attempted: 0,
261 incomplete: false,
262 });
263 }
264
265 let child_count = proxy.child_count().await?;
266 Ok(
267 fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
268 child_at_index(proxy, index)
269 })
270 .await,
271 )
272}
273
274async fn child_at_index(proxy: &AccessibleProxy<'_>, index: i32) -> zbus::Result<ObjectRefOwned> {
275 let (name, path): (String, OwnedObjectPath) =
278 proxy.inner().call("GetChildAtIndex", &(index,)).await?;
279 if path.as_str() == "/org/a11y/atspi/null" {
280 return Ok(ObjectRef::Null.into());
281 }
282 match BusName::try_from(name)? {
283 BusName::Unique(name) => Ok(ObjectRef::new_owned(name, path)),
284 name => {
285 let owner = DBusProxy::new(proxy.inner().connection())
286 .await?
287 .get_name_owner(name)
288 .await?;
289 Ok(ObjectRef::new_owned(owner, path))
290 }
291 }
292}
293
294pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
295 let conn = connect().await?;
296 let mut remaining_child_reads = limit;
297 let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
298 let dbus = DBusProxy::new(conn.connection()).await.ok();
299 let mut apps = Vec::new();
300
301 for object_ref in roots.into_iter().take(limit) {
302 if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
303 apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
304 }
305 }
306
307 Ok(apps)
308}
309
310#[derive(Debug, Clone)]
311pub(crate) struct AccessibilitySnapshot {
312 pub nodes: Vec<AccessibilityNode>,
313 pub scoped: bool,
315 pub root_pid: Option<u32>,
319 pub app_filter_matched: bool,
323 pub truncated: bool,
326}
327
328pub async fn snapshot_tree(
329 app_name_or_bundle_identifier: Option<&str>,
330 target_pid: Option<u32>,
331 max_nodes: usize,
332 max_depth: u32,
333) -> Result<Vec<AccessibilityNode>> {
334 Ok(snapshot_accessibility_tree(
335 app_name_or_bundle_identifier,
336 target_pid,
337 max_nodes,
338 max_depth,
339 )
340 .await?
341 .nodes)
342}
343
344pub(crate) async fn snapshot_accessibility_tree(
345 app_name_or_bundle_identifier: Option<&str>,
346 target_pid: Option<u32>,
347 max_nodes: usize,
348 max_depth: u32,
349) -> Result<AccessibilitySnapshot> {
350 let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
351 timeout(
352 SNAPSHOT_TIMEOUT,
353 snapshot_tree_inner(
354 app_name_or_bundle_identifier,
355 target_pid,
356 max_nodes,
357 max_depth,
358 ),
359 )
360 .await
361 .context("AT-SPI snapshot exceeded its 10-second deadline")?
362}
363
364async fn snapshot_tree_inner(
365 app_name_or_bundle_identifier: Option<&str>,
366 target_pid: Option<u32>,
367 max_nodes: usize,
368 max_depth: u32,
369) -> Result<AccessibilitySnapshot> {
370 let conn = connect().await?;
371 let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
374 snapshot_child_read_budgets(max_nodes);
375 let roots =
376 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
377 let selected_roots = select_roots(
378 &conn,
379 roots,
380 app_name_or_bundle_identifier,
381 target_pid,
382 &mut remaining_filter_reads,
383 )
384 .await;
385 let scoped = selected_roots.scoped;
386 let root_pid = selected_roots.pid;
387 let app_filter_matched = selected_roots.app_filter_matched;
388 let mut nodes = Vec::new();
389 let mut truncated = false;
390 let mut traversal = BoundedTraversal::new(max_nodes);
391
392 traversal.enqueue(
393 selected_roots
394 .roots
395 .into_iter()
396 .map(|object_ref| (object_ref, 0_u32, None)),
397 );
398
399 while let Some((object_ref, depth, parent_index)) = traversal.pop() {
400 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
401 continue;
402 };
403 let index = nodes.len() as u32;
404 let remaining = traversal.remaining_capacity();
405 let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
406 let child_refs = if depth < max_depth && remaining > 0 {
407 match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
408 Ok(batch) => {
409 truncated |= batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
413 batch.items
414 }
415 Err(_) => Vec::new(),
416 }
417 } else {
418 truncated |= node.child_count > 0;
420 Vec::new()
421 };
422
423 nodes.push(node);
424
425 traversal.enqueue(
426 child_refs
427 .into_iter()
428 .map(|child| (child, depth + 1, Some(index))),
429 );
430 }
431 truncated |= traversal.truncated();
432
433 Ok(AccessibilitySnapshot {
434 nodes,
435 scoped,
436 root_pid,
437 app_filter_matched,
438 truncated,
439 })
440}
441
442#[derive(Debug, Clone, Serialize, JsonSchema)]
445pub struct FocusedElementSummary {
446 pub role: String,
447 pub name: Option<String>,
448 pub editable: bool,
449 pub states: Vec<String>,
450 #[serde(skip)]
453 #[schemars(skip)]
454 pub is_terminal: bool,
455}
456
457const FOCUS_PROBE_MAX_NODES: usize = 400;
458const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
459
460#[derive(Debug, Clone)]
466pub(crate) enum FocusProbe {
467 Found(FocusedElementSummary),
468 NoneFocused,
470 ShellOnly,
472 Incomplete,
475 NoAccessibleApp,
478}
479
480pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
484 focused_element_probe(target_pid, target_pid.is_some()).await
485}
486
487pub(crate) async fn focused_element_summary_in_app(
491 target_pid: u32,
492) -> Result<Option<FocusedElementSummary>> {
493 Ok(match focused_element_probe(Some(target_pid), true).await? {
494 FocusProbe::Found(summary) => Some(summary),
495 _ => None,
496 })
497}
498
499async fn focused_element_probe(
500 target_pid: Option<u32>,
501 require_scoped: bool,
502) -> Result<FocusProbe> {
503 let conn = connect().await?;
504 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
505 let roots =
506 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
507 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
508 let selected_roots =
509 select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
510 if require_scoped && !selected_roots.scoped {
511 return Ok(FocusProbe::NoAccessibleApp);
512 }
513 let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
514 let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
515 let mut incomplete = false;
516 let mut shell_only = true;
517 let mut observed_nodes = 0;
518
519 traversal.enqueue(
520 selected_roots
521 .roots
522 .into_iter()
523 .map(|object_ref| (object_ref, 0_u32, false)),
524 );
525
526 while let Some((object_ref, depth, in_menu)) = traversal.pop() {
527 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
528 incomplete = true;
529 continue;
530 };
531 let Ok(state) = proxy.get_state().await else {
532 incomplete = true;
533 continue;
534 };
535 observed_nodes += 1;
536 let mut child_menu = false;
537 if shell_only {
538 match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
539 Some(menu) => child_menu = menu,
540 None => shell_only = false,
541 }
542 }
543 if state.contains(atspi::State::Focused) {
544 let proxies = proxy.proxies().await.ok();
545 let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
546 return Ok(FocusProbe::Found(FocusedElementSummary {
547 role: role_name(&proxy).await,
548 name: optional_string(proxy.name().await.ok()),
549 editable: supports_editable_text(proxies.as_ref()).await,
550 states: state_labels(state),
551 is_terminal,
552 }));
553 }
554 let remaining = traversal.remaining_capacity();
555 if depth < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
556 if let Ok(batch) =
557 children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
558 {
559 incomplete |= batch.incomplete;
560 if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
565 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
566 }
567 traversal.enqueue(
568 batch
569 .items
570 .into_iter()
571 .map(|child| (child, depth + 1, child_menu)),
572 );
573 } else {
574 incomplete = true;
575 }
576 } else if !incomplete {
577 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
580 }
581 }
582 if incomplete || traversal.truncated() {
583 return Ok(FocusProbe::Incomplete);
584 }
585
586 Ok(if require_scoped && shell_only && observed_nodes > 0 {
587 FocusProbe::ShellOnly
588 } else {
589 FocusProbe::NoneFocused
590 })
591}
592
593pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
596 match role {
597 "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
598 "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
599 | "tearoff menu item" => Some(true),
600 "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
602 _ => None,
603 }
604}
605
606pub async fn perform_action(
607 object_ref_id: &str,
608 requested_action: Option<&str>,
609) -> Result<ActionInvocation> {
610 perform_action_inner(object_ref_id, requested_action, false).await
611}
612
613pub(crate) async fn perform_named_action(
614 object_ref_id: &str,
615 name: &str,
616) -> Result<ActionInvocation> {
617 perform_action_inner(object_ref_id, Some(name), true).await
618}
619
620async fn perform_action_inner(
621 object_ref_id: &str,
622 requested_action: Option<&str>,
623 name_only: bool,
624) -> Result<ActionInvocation> {
625 let object_ref = object_ref_from_id(object_ref_id)?;
626 let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
627 let conn = connect().await?;
628 let proxy = open_accessible(&conn, &object_ref)
629 .await
630 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
631 let action = proxy
632 .proxies()
633 .await?
634 .action()
635 .await
636 .context("element does not expose the AT-SPI Action interface")?;
637 let actions = read_actions(&action).await?;
638 let action_index = if name_only {
639 select_named_action_index(&actions, requested_action.unwrap_or_default())?
640 } else {
641 select_action_index(&actions, requested_action)?
642 };
643 let action_name = actions
644 .get(action_index as usize)
645 .map(|action| action.name.clone());
646 Ok::<_, anyhow::Error>((action, action_index, action_name))
647 })
648 .await
649 .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
650 let ok = action
652 .do_action(action_index)
653 .await
654 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
655
656 Ok(ActionInvocation {
657 action_index,
658 action_name,
659 ok,
660 })
661}
662
663pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
664 let conn = connect().await?;
665 let object_ref = object_ref_from_id(object_ref_id)?;
666 let proxy = open_accessible(&conn, &object_ref)
667 .await
668 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
669 let proxies = proxy.proxies().await?;
670 set_element_value_with_proxies(&proxy, &proxies, value).await
671}
672
673async fn set_element_value_with_proxies(
674 proxy: &AccessibleProxy<'_>,
675 proxies: &Proxies<'_>,
676 value: &str,
677) -> Result<ValueSetInvocation> {
678 if let Ok(numeric_value) = value.parse::<f64>() {
679 if let Ok(value_proxy) = proxies.value().await {
680 value_proxy
681 .set_current_value(numeric_value)
682 .await
683 .with_context(|| {
684 format!("failed to set AT-SPI numeric value to {numeric_value}")
685 })?;
686 return Ok(ValueSetInvocation::Numeric {
687 value: numeric_value,
688 });
689 }
690 }
691
692 if let Ok(editable_text) = proxies.editable_text().await {
693 return set_editable_text_contents(proxy, proxies, &editable_text, value).await;
694 }
695
696 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
697 return Err(anyhow!(
698 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
699 ));
700 }
701
702 Err(anyhow!(
703 "element does not expose AT-SPI Value or EditableText interfaces"
704 ))
705}
706
707async fn set_editable_text_contents(
708 proxy: &AccessibleProxy<'_>,
709 proxies: &Proxies<'_>,
710 editable_text: &EditableTextProxy<'_>,
711 value: &str,
712) -> Result<ValueSetInvocation> {
713 let ok = editable_text
714 .set_text_contents(value)
715 .await
716 .context("failed to set AT-SPI editable text contents")?;
717 if !ok {
718 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
719 }
720
721 let outcome = verify_editable_text_contents(proxy, proxies, value).await;
722 Ok(ValueSetInvocation::EditableText { outcome })
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726enum EditableTextReadbackError {
727 Failed,
728 TimedOut,
729}
730
731async fn readback_within<T, E>(
732 deadline: Instant,
733 read: impl Future<Output = std::result::Result<T, E>>,
734) -> std::result::Result<T, EditableTextReadbackError> {
735 timeout_at(deadline, read)
736 .await
737 .map_err(|_| EditableTextReadbackError::TimedOut)?
738 .map_err(|_| EditableTextReadbackError::Failed)
739}
740
741async fn text_readback_matches(
742 text: &TextProxy<'_>,
743 requested: &str,
744 deadline: Instant,
745) -> std::result::Result<bool, EditableTextReadbackError> {
746 let character_count = readback_within(deadline, text.character_count()).await?;
747 if character_count < 0 {
748 return Err(EditableTextReadbackError::Failed);
749 }
750 if character_count as usize == requested.chars().count() {
751 return unicode_scalar_text_readback_matches(text, requested, deadline).await;
752 }
753
754 let requested_utf16 = requested.encode_utf16().collect::<Vec<_>>();
755 if character_count as usize != requested_utf16.len() {
756 return Ok(false);
757 }
758 utf16_text_readback_matches(text, &requested_utf16, deadline).await
759}
760
761async fn unicode_scalar_text_readback_matches(
762 text: &TextProxy<'_>,
763 requested: &str,
764 deadline: Instant,
765) -> std::result::Result<bool, EditableTextReadbackError> {
766 let requested_chars = requested.chars().collect::<Vec<_>>();
767 let mut offset = 0;
768 while offset < requested_chars.len() {
769 let end = offset
770 .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
771 .min(requested_chars.len());
772 let chunk = readback_within(deadline, text.get_text(offset as i32, end as i32)).await?;
773 if !chunk
774 .chars()
775 .eq(requested_chars[offset..end].iter().copied())
776 {
777 return Ok(false);
778 }
779 offset = end;
780 }
781
782 Ok(true)
783}
784
785async fn utf16_text_readback_matches(
786 text: &TextProxy<'_>,
787 requested: &[u16],
788 deadline: Instant,
789) -> std::result::Result<bool, EditableTextReadbackError> {
790 let mut offset = 0;
791 while offset < requested.len() {
792 let mut end = offset
793 .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
794 .min(requested.len());
795 if end < requested.len() && (0xD800..=0xDBFF).contains(&requested[end - 1]) {
796 end -= 1;
797 }
798 let chunk = readback_within(deadline, text.get_text(offset as i32, end as i32)).await?;
799 if !chunk
800 .encode_utf16()
801 .eq(requested[offset..end].iter().copied())
802 {
803 return Ok(false);
804 }
805 offset = end;
806 }
807
808 Ok(true)
809}
810
811async fn verify_editable_text_contents(
812 proxy: &AccessibleProxy<'_>,
813 proxies: &Proxies<'_>,
814 requested: &str,
815) -> EditableTextSetOutcome {
816 let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
817 match readback_within(deadline, proxy.get_role()).await {
818 Ok(atspi::Role::PasswordText) => return EditableTextSetOutcome::PasswordUnverifiable,
819 Ok(_) => {}
820 Err(EditableTextReadbackError::Failed) => {
821 return EditableTextSetOutcome::ReadbackFailed;
822 }
823 Err(EditableTextReadbackError::TimedOut) => {
824 return EditableTextSetOutcome::ReadbackTimedOut;
825 }
826 }
827 let text = match readback_within(deadline, proxies.text()).await {
828 Ok(text) => text,
829 Err(EditableTextReadbackError::Failed) => {
830 return EditableTextSetOutcome::ReadbackFailed;
831 }
832 Err(EditableTextReadbackError::TimedOut) => {
833 return EditableTextSetOutcome::ReadbackTimedOut;
834 }
835 };
836
837 loop {
838 match text_readback_matches(&text, requested, deadline).await {
839 Ok(true) => return EditableTextSetOutcome::Verified,
840 Ok(false) => {}
841 Err(EditableTextReadbackError::Failed) => {
842 return EditableTextSetOutcome::ReadbackFailed;
843 }
844 Err(EditableTextReadbackError::TimedOut) => {
845 return EditableTextSetOutcome::ReadbackTimedOut;
846 }
847 }
848
849 let now = Instant::now();
850 if now >= deadline {
851 return EditableTextSetOutcome::MismatchAfterDeadline;
852 }
853 sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
854 if Instant::now() >= deadline {
855 return EditableTextSetOutcome::MismatchAfterDeadline;
856 }
857 }
858}
859
860async fn connect() -> Result<AccessibilityConnection> {
861 hydrate_session_bus_env();
862 AccessibilityConnection::new()
863 .await
864 .context("failed to connect to AT-SPI bus")
865}
866
867async fn open_accessible<'r>(
880 conn: &AccessibilityConnection,
881 object_ref: &'r ObjectRefOwned,
882) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
883 object_ref.as_accessible_proxy(conn.connection()).await
884}
885
886async fn registry_children(
887 conn: &AccessibilityConnection,
888 limit: usize,
889 remaining_child_reads: &mut usize,
890) -> Result<Vec<ObjectRefOwned>> {
891 let root = conn
892 .root_accessible_on_registry()
893 .await
894 .context("failed to open AT-SPI registry root")?;
895 let batch = children_up_to(&root, limit, remaining_child_reads)
896 .await
897 .context("failed to read AT-SPI registry children")?;
898 if batch.all_failed() {
899 return Err(anyhow!(
900 "AT-SPI registry reported children, but every indexed child read failed"
901 ));
902 }
903 Ok(batch.items)
904}
905
906struct SelectedRoots {
907 roots: Vec<ObjectRefOwned>,
908 scoped: bool,
909 pid: Option<u32>,
911 app_filter_matched: bool,
912}
913
914fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
917 app_name_or_bundle_identifier
918 .map(str::trim)
919 .filter(|value| !value.is_empty())
920 .map(|value| value.to_ascii_lowercase())
921}
922
923async fn select_roots(
924 conn: &AccessibilityConnection,
925 roots: Vec<ObjectRefOwned>,
926 app_name_or_bundle_identifier: Option<&str>,
927 target_pid: Option<u32>,
928 remaining_child_reads: &mut usize,
929) -> SelectedRoots {
930 let needle = app_name_needle(app_name_or_bundle_identifier);
931 let dbus = DBusProxy::new(conn.connection()).await.ok();
932 let mut remaining = roots;
933
934 if let Some(target_pid) = target_pid {
935 let mut pid_and_filter_matches = Vec::new();
936 let mut pid_matches = Vec::new();
937 let mut non_pid_matches = Vec::new();
938
939 for object_ref in remaining {
940 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
941 if let Some(needle) = needle.as_deref() {
942 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
943 pid_and_filter_matches.push(object_ref);
944 } else {
945 pid_matches.push(object_ref);
946 }
947 } else {
948 pid_matches.push(object_ref);
949 }
950 } else {
951 non_pid_matches.push(object_ref);
952 }
953 }
954
955 if !pid_and_filter_matches.is_empty() {
956 return SelectedRoots {
957 roots: pid_and_filter_matches,
958 scoped: true,
959 pid: Some(target_pid),
960 app_filter_matched: true,
961 };
962 }
963 if !pid_matches.is_empty() {
964 return SelectedRoots {
965 roots: pid_matches,
966 scoped: true,
967 pid: Some(target_pid),
968 app_filter_matched: needle.is_none(),
969 };
970 }
971
972 remaining = non_pid_matches;
973 }
974
975 let Some(needle) = needle.as_deref() else {
976 return SelectedRoots {
977 roots: remaining,
978 scoped: false,
979 pid: None,
980 app_filter_matched: true,
981 };
982 };
983
984 let mut selected = Vec::new();
985 for object_ref in remaining {
986 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
987 selected.push(object_ref);
988 }
989 }
990
991 let app_filter_matched = !selected.is_empty();
992 SelectedRoots {
993 roots: selected,
994 scoped: true,
995 pid: None,
996 app_filter_matched,
997 }
998}
999
1000async fn root_matches(
1001 conn: &AccessibilityConnection,
1002 object_ref: &ObjectRefOwned,
1003 needle: &str,
1004 remaining_child_reads: &mut usize,
1005) -> bool {
1006 let Ok(proxy) = open_accessible(conn, object_ref).await else {
1007 return object_ref_id(object_ref)
1008 .to_ascii_lowercase()
1009 .contains(needle);
1010 };
1011
1012 if proxy_matches(&proxy, object_ref, needle).await {
1013 return true;
1014 }
1015
1016 for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
1017 .await
1018 .map(|batch| batch.items)
1019 .unwrap_or_default()
1020 {
1021 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
1022 continue;
1023 };
1024 if proxy_matches(&child_proxy, &child_ref, needle).await {
1025 return true;
1026 }
1027 }
1028
1029 false
1030}
1031
1032async fn proxy_matches(
1033 proxy: &AccessibleProxy<'_>,
1034 object_ref: &ObjectRefOwned,
1035 needle: &str,
1036) -> bool {
1037 let name = proxy.name().await.unwrap_or_default();
1038 let role = proxy.get_role_name().await.unwrap_or_default();
1039 format!("{} {} {}", object_ref_id(object_ref), name, role)
1040 .to_ascii_lowercase()
1041 .contains(needle)
1042}
1043
1044async fn read_app_summary(
1045 proxy: &AccessibleProxy<'_>,
1046 object_ref: &ObjectRefOwned,
1047 dbus: Option<&DBusProxy<'_>>,
1048) -> AccessibleAppSummary {
1049 AccessibleAppSummary {
1050 object_ref: object_ref_id(object_ref),
1051 name: optional_string(proxy.name().await.ok()),
1052 pid: object_ref_pid(dbus, object_ref).await,
1053 role: role_name(proxy).await,
1054 child_count: proxy.child_count().await.unwrap_or_default(),
1055 bounds: bounds(proxy).await,
1056 }
1057}
1058
1059async fn read_node(
1060 proxy: &AccessibleProxy<'_>,
1061 object_ref: &ObjectRefOwned,
1062 index: u32,
1063 parent_index: Option<u32>,
1064 depth: u32,
1065) -> AccessibilityNode {
1066 let proxies = proxy.proxies().await.ok();
1067
1068 AccessibilityNode {
1069 index,
1070 parent_index,
1071 depth,
1072 object_ref: object_ref_id(object_ref),
1073 role: role_name(proxy).await,
1074 name: optional_string(proxy.name().await.ok()),
1075 description: optional_string(proxy.description().await.ok()),
1076 child_count: proxy.child_count().await.unwrap_or_default(),
1077 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
1078 states: states_from_proxy(proxy).await,
1079 actions: actions_from_proxies(proxies.as_ref()).await,
1080 value: value_from_proxies(proxies.as_ref()).await,
1081 text: text_from_proxies(proxies.as_ref()).await,
1082 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
1083 }
1084}
1085
1086pub(crate) const UNKNOWN_ROLE: &str = "unknown";
1088
1089async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
1090 if let Ok(role) = proxy.get_role_name().await {
1091 if !role.trim().is_empty() {
1092 return role;
1093 }
1094 }
1095 proxy
1096 .get_role()
1097 .await
1098 .map(|role| format!("{role:?}"))
1099 .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
1100}
1101
1102async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
1103 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
1104}
1105
1106pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
1109 let object_ref = object_ref_from_id(object_ref_id)?;
1110 let conn = connect().await?;
1111 let dbus = DBusProxy::new(conn.connection()).await.ok();
1112 Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
1113}
1114
1115async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
1116 let dbus = dbus?;
1117 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
1118 dbus.get_connection_unix_process_id(bus_name).await.ok()
1119}
1120
1121async fn bounds_from_proxies(
1122 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1123 proxy: &AccessibleProxy<'_>,
1124) -> Option<Bounds> {
1125 let owned_proxies;
1126 let proxies = if let Some(proxies) = proxies {
1127 proxies
1128 } else {
1129 owned_proxies = proxy.proxies().await.ok()?;
1130 &owned_proxies
1131 };
1132 let component = proxies.component().await.ok()?;
1133 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
1134 normalize_bounds(Bounds {
1135 x,
1136 y,
1137 width,
1138 height,
1139 })
1140}
1141
1142fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
1143 if bounds.width <= 0 || bounds.height <= 0 {
1144 return None;
1145 }
1146 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
1147 return None;
1148 }
1149 Some(bounds)
1150}
1151
1152async fn actions_from_proxies(
1153 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1154) -> Vec<AccessibilityAction> {
1155 let Some(proxies) = proxies else {
1156 return Vec::new();
1157 };
1158 let Ok(action_proxy) = proxies.action().await else {
1159 return Vec::new();
1160 };
1161
1162 read_actions(&action_proxy)
1163 .await
1164 .unwrap_or_default()
1165 .into_iter()
1166 .enumerate()
1167 .map(|(index, action)| AccessibilityAction {
1168 index: index as i32,
1169 name: action.name,
1170 description: action.description,
1171 keybinding: action.keybinding,
1172 })
1173 .collect()
1174}
1175
1176async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
1177 timeout(ACTION_QUERY_TIMEOUT, async {
1181 let count = proxy
1182 .inner()
1183 .get_property::<i32>("NActions")
1184 .await
1185 .context("failed to read AT-SPI action count")?;
1186 if count > MAX_ACTIONS_PER_OBJECT {
1187 return Err(anyhow!(
1188 "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
1189 ));
1190 }
1191 if count < 0 {
1192 return Err(anyhow!("invalid negative AT-SPI action count {count}"));
1193 }
1194 Ok(stream::iter(0..count)
1195 .map(|index| async move {
1196 let (name, description, keybinding) = tokio::join!(
1197 proxy.get_name(index),
1198 proxy.get_description(index),
1199 proxy.get_key_binding(index),
1200 );
1201 atspi::Action {
1202 name: name.unwrap_or_default(),
1203 description: description.unwrap_or_default(),
1204 keybinding: keybinding.unwrap_or_default(),
1205 }
1206 })
1207 .buffered(CHILD_READ_CONCURRENCY)
1208 .collect()
1209 .await)
1210 })
1211 .await
1212 .context("AT-SPI action reads exceeded their 5-second deadline")?
1213}
1214
1215async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
1216 proxy
1217 .get_state()
1218 .await
1219 .map(state_labels)
1220 .unwrap_or_default()
1221}
1222
1223async fn value_from_proxies(
1224 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1225) -> Option<AccessibilityValue> {
1226 let value = proxies?.value().await.ok()?;
1227 Some(AccessibilityValue {
1228 current: value.current_value().await.ok()?,
1229 minimum: value.minimum_value().await.ok()?,
1230 maximum: value.maximum_value().await.ok()?,
1231 minimum_increment: value.minimum_increment().await.ok()?,
1232 text: optional_string(value.text().await.ok()),
1233 })
1234}
1235
1236async fn text_from_proxies(
1237 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1238) -> Option<AccessibilityText> {
1239 let text = proxies?.text().await.ok()?;
1240 let character_count = text.character_count().await.ok()?.max(0);
1241 let caret_offset = text.caret_offset().await.ok();
1242 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
1243 let content = if capped_count > 0 {
1244 optional_string(text.get_text(0, capped_count).await.ok())
1245 } else {
1246 None
1247 };
1248 let selection_count = text
1249 .get_nselections()
1250 .await
1251 .unwrap_or_default()
1252 .clamp(0, MAX_TEXT_SELECTIONS);
1253 let mut selections = Vec::new();
1254 for index in 0..selection_count {
1255 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1256 selections.push(AccessibilityTextSelection {
1257 start_offset,
1258 end_offset,
1259 });
1260 }
1261 }
1262
1263 Some(AccessibilityText {
1264 character_count,
1265 caret_offset,
1266 content,
1267 truncated: character_count > MAX_TEXT_READBACK_CHARS,
1268 selections,
1269 })
1270}
1271
1272async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1273 let Some(proxies) = proxies else {
1274 return false;
1275 };
1276 proxies.editable_text().await.is_ok()
1277}
1278
1279fn state_labels(state_set: StateSet) -> Vec<String> {
1280 state_set.iter().map(|state| state.to_string()).collect()
1281}
1282
1283fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1284 if actions.is_empty() {
1285 return Err(anyhow!("element exposes no AT-SPI actions"));
1286 }
1287
1288 if let Some(requested_action) = requested_action
1289 .map(str::trim)
1290 .filter(|value| !value.is_empty())
1291 {
1292 let requested_action = requested_action.to_ascii_lowercase();
1293 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1294 action.name.to_ascii_lowercase() == requested_action
1295 || action.description.to_ascii_lowercase() == requested_action
1296 }) {
1297 return Ok(index as i32);
1298 }
1299
1300 if let Ok(index) = requested_action.parse::<usize>() {
1301 if index < actions.len() {
1302 return Ok(index as i32);
1303 }
1304 }
1305
1306 return Err(anyhow!(
1307 "requested AT-SPI action was not found; available actions: {}",
1308 actions
1309 .iter()
1310 .map(|action| action.name.as_str())
1311 .collect::<Vec<_>>()
1312 .join(", ")
1313 ));
1314 }
1315
1316 Ok(if actions.len() > 1 { 1 } else { 0 })
1317}
1318
1319fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1320 actions
1321 .iter()
1322 .position(|action| action.name.eq_ignore_ascii_case(name))
1323 .map(|index| index as i32)
1324 .ok_or_else(|| {
1325 anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1326 })
1327}
1328
1329fn optional_string(value: Option<String>) -> Option<String> {
1330 value
1331 .map(|value| value.trim().to_string())
1332 .filter(|value| !value.is_empty())
1333}
1334
1335fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1336 let (name, path) = split_object_ref_id(object_ref_id)?;
1337 let name = UniqueName::try_from(name.to_string())
1338 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1339 let path = ObjectPath::try_from(path.to_string())
1340 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1341 Ok(ObjectRef::new_owned(name, path))
1342}
1343
1344fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1345 let Some(path_start) = object_ref_id.find('/') else {
1346 return Err(anyhow!(
1347 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1348 ));
1349 };
1350 let (name, path) = object_ref_id.split_at(path_start);
1351 if name.is_empty() || path.is_empty() {
1352 return Err(anyhow!(
1353 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1354 ));
1355 }
1356 Ok((name, path))
1357}
1358
1359fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1360 format!(
1361 "{}{}",
1362 object_ref.name_as_str().unwrap_or(""),
1363 object_ref.path_as_str()
1364 )
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370 use std::{
1371 io::{BufRead, BufReader},
1372 process::{Child, Command, Stdio},
1373 sync::{
1374 atomic::{AtomicUsize, Ordering},
1375 Arc, Mutex,
1376 },
1377 };
1378
1379 struct PrivateBus(Child);
1380
1381 impl PrivateBus {
1382 fn start() -> (Self, String) {
1383 let mut bus = Self(
1384 Command::new("dbus-daemon")
1385 .args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
1386 .stdout(Stdio::piped())
1387 .spawn()
1388 .unwrap(),
1389 );
1390 let mut address = String::new();
1391 BufReader::new(bus.0.stdout.take().unwrap())
1392 .read_line(&mut address)
1393 .unwrap();
1394 (bus, address.trim().to_string())
1395 }
1396 }
1397
1398 impl Drop for PrivateBus {
1399 fn drop(&mut self) {
1400 let _ = self.0.kill();
1401 let _ = self.0.wait();
1402 }
1403 }
1404
1405 const EDITABLE_FIXTURE_PATH: &str = "/editable";
1406
1407 #[derive(Default)]
1408 struct FakeEditableState {
1409 contents: String,
1410 mutations: usize,
1411 character_count_reads: usize,
1412 get_text_reads: usize,
1413 numeric_value: f64,
1414 numeric_writes: usize,
1415 }
1416
1417 #[derive(Clone, Copy)]
1418 enum FakeMutation {
1419 Apply,
1420 NoOp,
1421 Delayed,
1422 Normalize,
1423 Mask,
1424 Reject,
1425 Fail,
1426 }
1427
1428 #[derive(Clone, Copy, Debug)]
1429 enum FakeTextOffsetModel {
1430 UnicodeScalars,
1431 Utf16CodeUnits,
1432 }
1433
1434 #[derive(Clone, Copy)]
1435 enum FakeTextBehavior {
1436 Normal,
1437 CharacterCountError,
1438 GetTextError,
1439 NegativeCount,
1440 StallCharacterCount,
1441 }
1442
1443 #[derive(Clone, Copy)]
1444 struct FakeEditableConfig {
1445 mutation: FakeMutation,
1446 text: FakeTextBehavior,
1447 text_offset_model: FakeTextOffsetModel,
1448 role: atspi::Role,
1449 has_text: bool,
1450 has_value: bool,
1451 }
1452
1453 impl Default for FakeEditableConfig {
1454 fn default() -> Self {
1455 Self {
1456 mutation: FakeMutation::Apply,
1457 text: FakeTextBehavior::Normal,
1458 text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
1459 role: atspi::Role::Text,
1460 has_text: true,
1461 has_value: false,
1462 }
1463 }
1464 }
1465
1466 struct FakeEditableAccessible(FakeEditableConfig);
1467
1468 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1469 impl FakeEditableAccessible {
1470 #[zbus(property)]
1471 fn child_count(&self) -> i32 {
1472 0
1473 }
1474
1475 fn get_interfaces(&self) -> Vec<&str> {
1476 let mut interfaces = vec!["org.a11y.atspi.Accessible", "org.a11y.atspi.EditableText"];
1477 if self.0.has_text {
1478 interfaces.push("org.a11y.atspi.Text");
1479 }
1480 if self.0.has_value {
1481 interfaces.push("org.a11y.atspi.Value");
1482 }
1483 interfaces
1484 }
1485
1486 fn get_role(&self) -> atspi::Role {
1487 self.0.role
1488 }
1489 }
1490
1491 struct FakeEditableText {
1492 state: Arc<Mutex<FakeEditableState>>,
1493 mutation: FakeMutation,
1494 }
1495
1496 #[zbus::interface(name = "org.a11y.atspi.EditableText")]
1497 impl FakeEditableText {
1498 fn set_text_contents(&self, new_contents: &str) -> zbus::fdo::Result<bool> {
1499 self.state.lock().unwrap().mutations += 1;
1500 match self.mutation {
1501 FakeMutation::Apply => {
1502 self.state.lock().unwrap().contents = new_contents.to_string();
1503 }
1504 FakeMutation::NoOp => {}
1505 FakeMutation::Delayed => {
1506 let state = Arc::clone(&self.state);
1507 let new_contents = new_contents.to_string();
1508 tokio::spawn(async move {
1509 tokio::time::sleep(Duration::from_millis(150)).await;
1510 state.lock().unwrap().contents = new_contents;
1511 });
1512 }
1513 FakeMutation::Normalize => {
1514 self.state.lock().unwrap().contents = new_contents.replace("\r\n", "\n");
1515 }
1516 FakeMutation::Mask => {
1517 self.state.lock().unwrap().contents = "•".repeat(new_contents.chars().count());
1518 }
1519 FakeMutation::Reject => return Ok(false),
1520 FakeMutation::Fail => {
1521 return Err(zbus::fdo::Error::Failed(
1522 "injected SetTextContents failure".into(),
1523 ));
1524 }
1525 }
1526 Ok(true)
1527 }
1528 }
1529
1530 fn unicode_scalar_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
1531 contents
1532 .char_indices()
1533 .map(|(byte_index, _)| byte_index)
1534 .chain(std::iter::once(contents.len()))
1535 .nth(offset)
1536 }
1537
1538 fn utf16_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
1539 let mut utf16_offset = 0;
1540 for (byte_index, character) in contents.char_indices() {
1541 if utf16_offset == offset {
1542 return Some(byte_index);
1543 }
1544 utf16_offset += character.len_utf16();
1545 }
1546 (utf16_offset == offset).then_some(contents.len())
1547 }
1548
1549 struct FakeText {
1550 state: Arc<Mutex<FakeEditableState>>,
1551 behavior: FakeTextBehavior,
1552 text_offset_model: FakeTextOffsetModel,
1553 }
1554
1555 #[zbus::interface(name = "org.a11y.atspi.Text")]
1556 impl FakeText {
1557 #[zbus(property)]
1558 async fn character_count(&self) -> zbus::fdo::Result<i32> {
1559 self.state.lock().unwrap().character_count_reads += 1;
1560 match self.behavior {
1561 FakeTextBehavior::CharacterCountError => Err(zbus::fdo::Error::Failed(
1562 "injected CharacterCount failure".into(),
1563 )),
1564 FakeTextBehavior::NegativeCount => Ok(-1),
1565 FakeTextBehavior::StallCharacterCount => std::future::pending().await,
1566 _ => {
1567 let state = self.state.lock().unwrap();
1568 let count = match self.text_offset_model {
1569 FakeTextOffsetModel::UnicodeScalars => state.contents.chars().count(),
1570 FakeTextOffsetModel::Utf16CodeUnits => {
1571 state.contents.encode_utf16().count()
1572 }
1573 };
1574 Ok(count as i32)
1575 }
1576 }
1577 }
1578
1579 fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
1580 let mut state = self.state.lock().unwrap();
1581 state.get_text_reads += 1;
1582 if matches!(self.behavior, FakeTextBehavior::GetTextError) {
1583 return Err(zbus::fdo::Error::Failed("injected GetText failure".into()));
1584 }
1585 if start_offset < 0 || end_offset < start_offset {
1586 return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
1587 }
1588 let offset_to_byte_index = match self.text_offset_model {
1589 FakeTextOffsetModel::UnicodeScalars => unicode_scalar_offset_to_byte_index,
1590 FakeTextOffsetModel::Utf16CodeUnits => utf16_offset_to_byte_index,
1591 };
1592 let start = offset_to_byte_index(&state.contents, start_offset as usize)
1593 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
1594 let end = offset_to_byte_index(&state.contents, end_offset as usize)
1595 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
1596 Ok(state.contents[start..end].to_string())
1597 }
1598 }
1599
1600 struct FakeValue(Arc<Mutex<FakeEditableState>>);
1601
1602 #[zbus::interface(name = "org.a11y.atspi.Value")]
1603 impl FakeValue {
1604 #[zbus(property)]
1605 fn current_value(&self) -> f64 {
1606 self.0.lock().unwrap().numeric_value
1607 }
1608
1609 #[zbus(property)]
1610 fn set_current_value(&self, value: f64) {
1611 let mut state = self.0.lock().unwrap();
1612 state.numeric_value = value;
1613 state.numeric_writes += 1;
1614 }
1615 }
1616
1617 async fn start_editable_fixture(
1618 initial_contents: &str,
1619 config: FakeEditableConfig,
1620 ) -> (
1621 PrivateBus,
1622 zbus::Connection,
1623 zbus::Connection,
1624 Arc<Mutex<FakeEditableState>>,
1625 ) {
1626 let (bus, address) = PrivateBus::start();
1627 let state = Arc::new(Mutex::new(FakeEditableState {
1628 contents: initial_contents.to_string(),
1629 ..Default::default()
1630 }));
1631 let mut builder = zbus::connection::Builder::address(address.as_str())
1632 .unwrap()
1633 .serve_at(EDITABLE_FIXTURE_PATH, FakeEditableAccessible(config))
1634 .unwrap()
1635 .serve_at(
1636 EDITABLE_FIXTURE_PATH,
1637 FakeEditableText {
1638 state: Arc::clone(&state),
1639 mutation: config.mutation,
1640 },
1641 )
1642 .unwrap();
1643 if config.has_text {
1644 builder = builder
1645 .serve_at(
1646 EDITABLE_FIXTURE_PATH,
1647 FakeText {
1648 state: Arc::clone(&state),
1649 behavior: config.text,
1650 text_offset_model: config.text_offset_model,
1651 },
1652 )
1653 .unwrap();
1654 }
1655 if config.has_value {
1656 builder = builder
1657 .serve_at(EDITABLE_FIXTURE_PATH, FakeValue(Arc::clone(&state)))
1658 .unwrap();
1659 }
1660 let peer = builder.build().await.unwrap();
1661 let client = zbus::connection::Builder::address(address.as_str())
1662 .unwrap()
1663 .build()
1664 .await
1665 .unwrap();
1666 (bus, peer, client, state)
1667 }
1668
1669 async fn dispatch_editable_fixture(
1670 client: &zbus::Connection,
1671 peer: &zbus::Connection,
1672 value: &str,
1673 ) -> Result<ValueSetInvocation> {
1674 let proxy = AccessibleProxy::builder(client)
1675 .destination(peer.unique_name().unwrap().clone())
1676 .unwrap()
1677 .path(EDITABLE_FIXTURE_PATH)
1678 .unwrap()
1679 .cache_properties(zbus::proxy::CacheProperties::No)
1680 .build()
1681 .await
1682 .unwrap();
1683 let proxies = proxy.proxies().await.unwrap();
1684 set_element_value_with_proxies(&proxy, &proxies, value).await
1685 }
1686
1687 fn editable_outcome(invocation: ValueSetInvocation) -> EditableTextSetOutcome {
1688 match invocation {
1689 ValueSetInvocation::EditableText { outcome } => outcome,
1690 ValueSetInvocation::Numeric { .. } => panic!("expected editable-text dispatch"),
1691 }
1692 }
1693
1694 struct FakeAccessible {
1695 name: String,
1696 children: Vec<(String, OwnedObjectPath)>,
1697 has_action: bool,
1698 }
1699
1700 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1701 impl FakeAccessible {
1702 #[zbus(property)]
1703 fn child_count(&self) -> i32 {
1704 self.children.len() as i32
1705 }
1706
1707 #[zbus(property)]
1708 fn name(&self) -> &str {
1709 &self.name
1710 }
1711
1712 fn get_interfaces(&self) -> Vec<&str> {
1713 let mut interfaces = vec!["org.a11y.atspi.Accessible"];
1714 if self.has_action {
1715 interfaces.push("org.a11y.atspi.Action");
1716 }
1717 interfaces
1718 }
1719
1720 fn get_child_at_index(
1721 &self,
1722 index: i32,
1723 ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
1724 self.children
1725 .get(index as usize)
1726 .cloned()
1727 .map(|child| (child,))
1728 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
1729 }
1730 }
1731
1732 struct FakeWebKitAction {
1733 count: i32,
1734 stall_indexed_reads: bool,
1735 name_reads: Arc<AtomicUsize>,
1736 }
1737
1738 impl Default for FakeWebKitAction {
1739 fn default() -> Self {
1740 Self {
1741 count: 1,
1742 stall_indexed_reads: false,
1743 name_reads: Arc::default(),
1744 }
1745 }
1746 }
1747
1748 #[zbus::interface(name = "org.a11y.atspi.Action")]
1749 impl FakeWebKitAction {
1750 async fn get_actions(&self) -> Vec<atspi::Action> {
1751 std::future::pending().await
1752 }
1753
1754 #[zbus(property, name = "NActions")]
1755 fn nactions(&self) -> i32 {
1756 self.count
1757 }
1758
1759 async fn get_name(&self, index: i32) -> &str {
1760 assert!((0..self.count).contains(&index));
1761 self.name_reads.fetch_add(1, Ordering::SeqCst);
1762 if self.stall_indexed_reads {
1763 std::future::pending::<()>().await;
1764 }
1765 "click"
1766 }
1767
1768 async fn get_localized_name(&self, index: i32) -> &str {
1769 assert!((0..self.count).contains(&index));
1770 "Activate"
1771 }
1772
1773 fn get_description(&self, index: i32) -> &str {
1774 assert!((0..self.count).contains(&index));
1775 "Activate the control"
1776 }
1777
1778 fn get_key_binding(&self, index: i32) -> &str {
1779 assert!((0..self.count).contains(&index));
1780 "Return"
1781 }
1782
1783 fn do_action(&self, index: i32) -> bool {
1784 index == 0
1785 }
1786 }
1787
1788 struct FakeQtAction;
1789
1790 #[zbus::interface(name = "org.a11y.atspi.Action")]
1791 impl FakeQtAction {
1792 #[zbus(property, name = "NActions")]
1793 fn nactions(&self) -> i32 {
1794 2
1795 }
1796
1797 fn get_name(&self, index: i32) -> &str {
1798 match index {
1799 0 => "Press",
1800 1 => "Set Focus",
1801 _ => panic!("invalid action index"),
1802 }
1803 }
1804
1805 fn get_description(&self, index: i32) -> &str {
1806 match index {
1807 0 => "Eylemi tetikler",
1808 1 => "Odağı ayarlar",
1809 _ => panic!("invalid action index"),
1810 }
1811 }
1812
1813 fn get_key_binding(&self, _index: i32) -> &str {
1814 ""
1815 }
1816 }
1817
1818 #[tokio::test]
1819 async fn editable_text_true_without_publication_is_mismatch_and_mutates_once() {
1820 let (_bus, peer, client, state) = start_editable_fixture(
1821 "original",
1822 FakeEditableConfig {
1823 mutation: FakeMutation::NoOp,
1824 ..Default::default()
1825 },
1826 )
1827 .await;
1828
1829 let outcome = editable_outcome(
1830 dispatch_editable_fixture(&client, &peer, "modified")
1831 .await
1832 .unwrap(),
1833 );
1834
1835 assert!(
1838 matches!(
1839 outcome,
1840 EditableTextSetOutcome::MismatchAfterDeadline
1841 | EditableTextSetOutcome::ReadbackTimedOut
1842 ),
1843 "{outcome:?}"
1844 );
1845 let state = state.lock().unwrap();
1846 assert_eq!(state.mutations, 1);
1847 assert!(state.character_count_reads > 1);
1848 assert!(state.get_text_reads > 1);
1849 assert_eq!(state.contents, "original");
1850 }
1851
1852 #[tokio::test]
1853 async fn editable_text_exact_readback_accepts_empty_whitespace_and_trailing_newline() {
1854 let (_bus, peer, client, state) =
1855 start_editable_fixture("initial", FakeEditableConfig::default()).await;
1856 let values = [
1857 "",
1858 " \t\n",
1859 " leading and trailing\t \n",
1860 "trailing newline\n",
1861 "Case and unicode € 内容 😀🦀𐐷",
1862 ];
1863
1864 for value in values {
1865 let outcome = editable_outcome(
1866 dispatch_editable_fixture(&client, &peer, value)
1867 .await
1868 .unwrap(),
1869 );
1870 assert_eq!(outcome, EditableTextSetOutcome::Verified);
1871 assert_eq!(state.lock().unwrap().contents, value);
1872 }
1873 assert_eq!(state.lock().unwrap().mutations, values.len());
1874 }
1875
1876 #[tokio::test]
1877 async fn editable_text_non_bmp_readback_supports_scalar_and_utf16_peers() {
1878 let requested = "🦀 GTK verified ";
1879 for text_offset_model in [
1880 FakeTextOffsetModel::UnicodeScalars,
1881 FakeTextOffsetModel::Utf16CodeUnits,
1882 ] {
1883 let (_bus, peer, client, state) = start_editable_fixture(
1884 "",
1885 FakeEditableConfig {
1886 text_offset_model,
1887 ..Default::default()
1888 },
1889 )
1890 .await;
1891
1892 let outcome = editable_outcome(
1893 dispatch_editable_fixture(&client, &peer, requested)
1894 .await
1895 .unwrap(),
1896 );
1897
1898 assert_eq!(
1899 outcome,
1900 EditableTextSetOutcome::Verified,
1901 "{text_offset_model:?}"
1902 );
1903 let state = state.lock().unwrap();
1904 assert_eq!(state.mutations, 1);
1905 assert_eq!(state.contents, requested);
1906 }
1907 }
1908
1909 #[tokio::test]
1910 async fn editable_text_non_bmp_matching_count_does_not_accept_different_content() {
1911 let requested = "🦀 GTK verified ";
1912 let published = "🦀 GTK verified!";
1913 assert_eq!(requested.chars().count(), published.chars().count());
1914 assert_eq!(
1915 requested.encode_utf16().count(),
1916 published.encode_utf16().count()
1917 );
1918 for text_offset_model in [
1919 FakeTextOffsetModel::UnicodeScalars,
1920 FakeTextOffsetModel::Utf16CodeUnits,
1921 ] {
1922 let (_bus, peer, client, state) = start_editable_fixture(
1923 published,
1924 FakeEditableConfig {
1925 mutation: FakeMutation::NoOp,
1926 text_offset_model,
1927 ..Default::default()
1928 },
1929 )
1930 .await;
1931
1932 let outcome = editable_outcome(
1933 dispatch_editable_fixture(&client, &peer, requested)
1934 .await
1935 .unwrap(),
1936 );
1937
1938 assert!(
1941 matches!(
1942 outcome,
1943 EditableTextSetOutcome::MismatchAfterDeadline
1944 | EditableTextSetOutcome::ReadbackTimedOut
1945 ),
1946 "{text_offset_model:?}: {outcome:?}"
1947 );
1948 let state = state.lock().unwrap();
1949 assert_eq!(state.mutations, 1);
1950 assert!(state.get_text_reads > 0, "{text_offset_model:?}");
1951 assert_eq!(state.contents, published);
1952 }
1953 }
1954
1955 #[tokio::test]
1956 async fn editable_text_readback_chunks_utf16_without_splitting_non_bmp() {
1957 let (_bus, peer, client, state) = start_editable_fixture(
1958 "",
1959 FakeEditableConfig {
1960 text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
1961 ..Default::default()
1962 },
1963 )
1964 .await;
1965 let mut expected = "a".repeat(EDITABLE_TEXT_READ_CHUNK_UNITS - 1);
1966 expected.push_str("🦀é");
1968 expected.push_str(&"内容\n".repeat(1_200));
1969 assert!(expected.encode_utf16().count() > MAX_TEXT_READBACK_CHARS as usize);
1970
1971 let outcome = editable_outcome(
1972 dispatch_editable_fixture(&client, &peer, &expected)
1973 .await
1974 .unwrap(),
1975 );
1976
1977 assert_eq!(outcome, EditableTextSetOutcome::Verified);
1978 let state = state.lock().unwrap();
1979 assert_eq!(state.mutations, 1);
1980 assert!(state.get_text_reads >= 2);
1981 assert_eq!(state.contents, expected);
1982 }
1983
1984 #[tokio::test]
1985 async fn editable_text_readback_waits_for_async_publication() {
1986 let (_bus, peer, client, state) = start_editable_fixture(
1987 "pending",
1988 FakeEditableConfig {
1989 mutation: FakeMutation::Delayed,
1990 ..Default::default()
1991 },
1992 )
1993 .await;
1994
1995 let outcome = editable_outcome(
1996 dispatch_editable_fixture(&client, &peer, "published asynchronously")
1997 .await
1998 .unwrap(),
1999 );
2000
2001 assert_eq!(outcome, EditableTextSetOutcome::Verified);
2002 let state = state.lock().unwrap();
2003 assert_eq!(state.mutations, 1);
2004 assert!(state.character_count_reads > 1);
2005 assert_eq!(state.contents, "published asynchronously");
2006 }
2007
2008 #[tokio::test]
2009 async fn editable_text_normalization_mismatch_is_not_verified() {
2010 let (_bus, peer, client, state) = start_editable_fixture(
2011 "",
2012 FakeEditableConfig {
2013 mutation: FakeMutation::Normalize,
2014 ..Default::default()
2015 },
2016 )
2017 .await;
2018
2019 let outcome = editable_outcome(
2020 dispatch_editable_fixture(&client, &peer, "first\r\nsecond\n")
2021 .await
2022 .unwrap(),
2023 );
2024
2025 assert_eq!(outcome, EditableTextSetOutcome::MismatchAfterDeadline);
2026 let state = state.lock().unwrap();
2027 assert_eq!(state.mutations, 1);
2028 assert!(state.character_count_reads > 1);
2029 assert_eq!(state.contents, "first\nsecond\n");
2030 }
2031
2032 #[tokio::test]
2033 async fn password_text_is_unverifiable_even_when_requested_text_is_bullets() {
2034 let (_bus, peer, client, state) = start_editable_fixture(
2035 "",
2036 FakeEditableConfig {
2037 mutation: FakeMutation::Mask,
2038 role: atspi::Role::PasswordText,
2039 ..Default::default()
2040 },
2041 )
2042 .await;
2043
2044 let outcome = editable_outcome(
2045 dispatch_editable_fixture(&client, &peer, "••••••••")
2046 .await
2047 .unwrap(),
2048 );
2049
2050 assert_eq!(outcome, EditableTextSetOutcome::PasswordUnverifiable);
2051 let state = state.lock().unwrap();
2052 assert_eq!(state.mutations, 1);
2053 assert_eq!(state.character_count_reads, 0);
2054 assert_eq!(state.contents, "••••••••");
2055 }
2056
2057 #[tokio::test]
2058 async fn editable_text_missing_text_and_read_errors_are_structured_outcomes() {
2059 let configs = [
2060 FakeEditableConfig {
2061 has_text: false,
2062 ..Default::default()
2063 },
2064 FakeEditableConfig {
2065 text: FakeTextBehavior::CharacterCountError,
2066 ..Default::default()
2067 },
2068 FakeEditableConfig {
2069 text: FakeTextBehavior::GetTextError,
2070 ..Default::default()
2071 },
2072 ];
2073
2074 for config in configs {
2075 let (_bus, peer, client, state) = start_editable_fixture("before", config).await;
2076 let outcome = editable_outcome(
2077 dispatch_editable_fixture(&client, &peer, "readback may have changed")
2078 .await
2079 .unwrap(),
2080 );
2081 assert_eq!(outcome, EditableTextSetOutcome::ReadbackFailed);
2082 assert_eq!(state.lock().unwrap().mutations, 1);
2083 }
2084 }
2085
2086 #[tokio::test]
2087 async fn editable_text_negative_counts_and_stalled_readback_are_structured_outcomes() {
2088 let cases = [
2089 (
2090 FakeTextBehavior::NegativeCount,
2091 EditableTextSetOutcome::ReadbackFailed,
2092 ),
2093 (
2094 FakeTextBehavior::StallCharacterCount,
2095 EditableTextSetOutcome::ReadbackTimedOut,
2096 ),
2097 ];
2098
2099 for (text, expected) in cases {
2100 let (_bus, peer, client, state) = start_editable_fixture(
2101 "before",
2102 FakeEditableConfig {
2103 text,
2104 ..Default::default()
2105 },
2106 )
2107 .await;
2108 let outcome = editable_outcome(
2109 dispatch_editable_fixture(&client, &peer, "after")
2110 .await
2111 .unwrap(),
2112 );
2113 assert_eq!(outcome, expected);
2114 let state = state.lock().unwrap();
2115 assert_eq!(state.mutations, 1);
2116 assert_eq!(state.get_text_reads, 0);
2117 }
2118 }
2119
2120 #[tokio::test]
2121 async fn editable_text_rejection_and_mutation_errors_remain_errors() {
2122 let cases = [
2123 (FakeMutation::Reject, "rejected"),
2124 (FakeMutation::Fail, "failed to set"),
2125 ];
2126
2127 for (mutation, expected_error) in cases {
2128 let (_bus, peer, client, state) = start_editable_fixture(
2129 "before",
2130 FakeEditableConfig {
2131 mutation,
2132 ..Default::default()
2133 },
2134 )
2135 .await;
2136 let error = dispatch_editable_fixture(&client, &peer, "after")
2137 .await
2138 .unwrap_err();
2139 assert!(error.to_string().contains(expected_error));
2140 let state = state.lock().unwrap();
2141 assert_eq!(state.mutations, 1);
2142 assert_eq!(state.character_count_reads, 0);
2143 }
2144 }
2145
2146 #[tokio::test]
2147 async fn numeric_value_dispatch_remains_isolated_from_editable_text() {
2148 let (_bus, peer, client, state) = start_editable_fixture(
2149 "unchanged",
2150 FakeEditableConfig {
2151 has_value: true,
2152 ..Default::default()
2153 },
2154 )
2155 .await;
2156
2157 let invocation = dispatch_editable_fixture(&client, &peer, "42.5")
2158 .await
2159 .unwrap();
2160
2161 match invocation {
2162 ValueSetInvocation::Numeric { value } => assert_eq!(value, 42.5),
2163 ValueSetInvocation::EditableText { .. } => panic!("expected numeric dispatch"),
2164 }
2165 let state = state.lock().unwrap();
2166 assert_eq!(state.numeric_value, 42.5);
2167 assert_eq!(state.numeric_writes, 1);
2168 assert_eq!(state.mutations, 0);
2169 assert_eq!(state.character_count_reads, 0);
2170 assert_eq!(state.contents, "unchanged");
2171 }
2172
2173 #[tokio::test]
2174 async fn qt_action_names_do_not_depend_on_localized_names() {
2175 let (_bus, address) = PrivateBus::start();
2176 let peer = zbus::connection::Builder::address(address.as_str())
2177 .unwrap()
2178 .serve_at("/actions/qt", FakeQtAction)
2179 .unwrap()
2180 .build()
2181 .await
2182 .unwrap();
2183 let client = zbus::connection::Builder::address(address.as_str())
2184 .unwrap()
2185 .build()
2186 .await
2187 .unwrap();
2188 let action = ActionProxy::builder(&client)
2189 .destination(peer.unique_name().unwrap().clone())
2190 .unwrap()
2191 .path("/actions/qt")
2192 .unwrap()
2193 .cache_properties(zbus::proxy::CacheProperties::No)
2194 .build()
2195 .await
2196 .unwrap();
2197
2198 let actions = timeout(Duration::from_secs(1), read_actions(&action))
2199 .await
2200 .expect("Qt action reads must not require GetLocalizedName")
2201 .unwrap();
2202
2203 assert_eq!(actions.len(), 2);
2204 assert_eq!(actions[0].name, "Press");
2205 assert_eq!(actions[0].description, "Eylemi tetikler");
2206 assert_eq!(actions[1].name, "Set Focus");
2207 assert_eq!(actions[1].description, "Odağı ayarlar");
2208 assert_eq!(select_named_action_index(&actions, "press").unwrap(), 0);
2209 }
2210
2211 #[tokio::test]
2212 async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
2213 let (_bus, address) = PrivateBus::start();
2214
2215 let webkit_name = "org.webkit.MockWebProcess";
2216 let child_path = "/org/a11y/webkit/accessible/child";
2217 let webkit = zbus::connection::Builder::address(address.trim())
2218 .unwrap()
2219 .name(webkit_name)
2220 .unwrap()
2221 .serve_at(
2222 child_path,
2223 FakeAccessible {
2224 name: "WebKit page".into(),
2225 children: Vec::new(),
2226 has_action: true,
2227 },
2228 )
2229 .unwrap()
2230 .serve_at(child_path, FakeWebKitAction::default())
2231 .unwrap()
2232 .build()
2233 .await
2234 .unwrap();
2235 let owner = webkit.unique_name().unwrap().to_string();
2236 let gtk = zbus::connection::Builder::address(address.trim())
2237 .unwrap()
2238 .serve_at(
2239 "/org/a11y/atspi/accessible/root",
2240 FakeAccessible {
2241 name: "GTK socket".into(),
2242 has_action: false,
2243 children: vec![
2244 (webkit_name.into(), child_path.try_into().unwrap()),
2245 (owner.clone(), child_path.try_into().unwrap()),
2246 (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
2247 (
2248 "org.webkit.UnownedWebProcess".into(),
2249 child_path.try_into().unwrap(),
2250 ),
2251 ],
2252 },
2253 )
2254 .unwrap()
2255 .build()
2256 .await
2257 .unwrap();
2258 let client = zbus::connection::Builder::address(address.trim())
2259 .unwrap()
2260 .build()
2261 .await
2262 .unwrap();
2263 let proxy = AccessibleProxy::builder(&client)
2264 .destination(gtk.unique_name().unwrap().clone())
2265 .unwrap()
2266 .path("/org/a11y/atspi/accessible/root")
2267 .unwrap()
2268 .build()
2269 .await
2270 .unwrap();
2271 let mut remaining = 4;
2272 let reply = proxy
2273 .inner()
2274 .call_method("GetChildAtIndex", &(0_i32,))
2275 .await
2276 .unwrap();
2277 assert_eq!(reply.body().signature().to_string(), "(so)");
2278 let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
2279
2280 assert_eq!(batch.attempted, 4);
2281 assert_eq!(remaining, 0);
2282 assert!(!batch.incomplete);
2283 assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
2284 assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
2285 assert_eq!(batch.items[0].path_as_str(), child_path);
2286 assert_eq!(batch.items[0], batch.items[1]);
2287 assert!(batch.items[2].is_null());
2288
2289 let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
2291 assert_eq!(child.name().await.unwrap(), "WebKit page");
2292 assert_eq!(
2293 object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
2294 batch.items[0]
2295 );
2296 let action = child.proxies().await.unwrap().action().await.unwrap();
2297 let actions = timeout(Duration::from_secs(1), read_actions(&action))
2298 .await
2299 .expect("indexed action reads must not wait for GetActions")
2300 .unwrap();
2301 assert_eq!(actions.len(), 1);
2302 assert_eq!(actions[0].name, "click");
2303 assert_eq!(actions[0].description, "Activate the control");
2304 assert_eq!(actions[0].keybinding, "Return");
2305 assert_eq!(select_named_action_index(&actions, "click").unwrap(), 0);
2306 assert!(action.do_action(0).await.unwrap());
2307 }
2308
2309 #[tokio::test]
2310 async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
2311 let (_bus, address) = PrivateBus::start();
2312 let name_reads = Arc::new(AtomicUsize::new(0));
2313 let counts = [
2314 -1,
2315 MAX_ACTIONS_PER_OBJECT + 1,
2316 i32::MAX,
2317 0,
2318 MAX_ACTIONS_PER_OBJECT,
2319 ];
2320 let mut builder = zbus::connection::Builder::address(address.as_str()).unwrap();
2321 for (index, count) in counts.iter().enumerate() {
2322 builder = builder
2323 .serve_at(
2324 format!("/actions/{index}"),
2325 FakeWebKitAction {
2326 count: *count,
2327 name_reads: Arc::clone(&name_reads),
2328 ..Default::default()
2329 },
2330 )
2331 .unwrap();
2332 }
2333 let peer = builder.build().await.unwrap();
2334 let client = zbus::connection::Builder::address(address.as_str())
2335 .unwrap()
2336 .build()
2337 .await
2338 .unwrap();
2339
2340 for (index, count) in counts.iter().enumerate() {
2341 let action = ActionProxy::builder(&client)
2342 .destination(peer.unique_name().unwrap().clone())
2343 .unwrap()
2344 .path(format!("/actions/{index}"))
2345 .unwrap()
2346 .cache_properties(zbus::proxy::CacheProperties::No)
2347 .build()
2348 .await
2349 .unwrap();
2350 let result = timeout(Duration::from_secs(1), read_actions(&action))
2351 .await
2352 .expect("untrusted counts must not cause unbounded work");
2353 if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
2354 assert_eq!(result.unwrap().len(), *count as usize);
2355 } else {
2356 let error = result.unwrap_err().to_string();
2357 assert!(error.contains("action count"), "{error}");
2358 assert_eq!(name_reads.load(Ordering::SeqCst), 0);
2359 }
2360 }
2361 assert_eq!(
2362 name_reads.load(Ordering::SeqCst),
2363 MAX_ACTIONS_PER_OBJECT as usize
2364 );
2365 }
2366
2367 #[tokio::test]
2368 async fn indexed_actions_time_out_the_entire_read() {
2369 let (_bus, address) = PrivateBus::start();
2370 let name_reads = Arc::new(AtomicUsize::new(0));
2371 let peer = zbus::connection::Builder::address(address.as_str())
2372 .unwrap()
2373 .serve_at(
2374 "/actions/stalled",
2375 FakeWebKitAction {
2376 stall_indexed_reads: true,
2377 name_reads: Arc::clone(&name_reads),
2378 ..Default::default()
2379 },
2380 )
2381 .unwrap()
2382 .build()
2383 .await
2384 .unwrap();
2385 let client = zbus::connection::Builder::address(address.as_str())
2386 .unwrap()
2387 .build()
2388 .await
2389 .unwrap();
2390 let action = ActionProxy::builder(&client)
2391 .destination(peer.unique_name().unwrap().clone())
2392 .unwrap()
2393 .path("/actions/stalled")
2394 .unwrap()
2395 .cache_properties(zbus::proxy::CacheProperties::No)
2396 .build()
2397 .await
2398 .unwrap();
2399
2400 let error = timeout(
2401 ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
2402 read_actions(&action),
2403 )
2404 .await
2405 .expect("indexed reads must have an overall deadline")
2406 .unwrap_err();
2407 assert!(error.to_string().contains("5-second deadline"));
2408 assert_eq!(name_reads.load(Ordering::SeqCst), 1);
2409 }
2410
2411 #[test]
2412 fn split_object_ref_id_separates_bus_name_and_path() {
2413 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
2414
2415 assert_eq!(name, ":1.42");
2416 assert_eq!(path, "/org/a11y/atspi/accessible/7");
2417 }
2418
2419 #[test]
2420 fn select_action_index_uses_named_action() {
2421 let actions = vec![
2422 atspi::Action {
2423 name: "click".to_string(),
2424 description: "Clicks".to_string(),
2425 keybinding: String::new(),
2426 },
2427 atspi::Action {
2428 name: "show-menu".to_string(),
2429 description: "Shows menu".to_string(),
2430 keybinding: String::new(),
2431 },
2432 ];
2433
2434 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
2435 }
2436
2437 #[test]
2438 fn cached_action_name_is_revalidated_against_current_actions() {
2439 let mut actions = vec![atspi::Action {
2440 name: "show-menu".into(),
2441 description: "Click".into(),
2442 keybinding: String::new(),
2443 }];
2444 assert!(select_named_action_index(&actions, "Click").is_err());
2446 actions.push(atspi::Action {
2447 name: "click".into(),
2448 description: String::new(),
2449 keybinding: String::new(),
2450 });
2451 assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
2452 }
2453
2454 #[test]
2455 fn select_action_index_defaults_to_secondary_when_available() {
2456 let actions = vec![
2457 atspi::Action {
2458 name: "click".to_string(),
2459 description: String::new(),
2460 keybinding: String::new(),
2461 },
2462 atspi::Action {
2463 name: "show-menu".to_string(),
2464 description: String::new(),
2465 keybinding: String::new(),
2466 },
2467 ];
2468
2469 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
2470 }
2471
2472 #[test]
2473 fn state_labels_serialize_in_bit_order() {
2474 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
2475
2476 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
2477 }
2478
2479 #[test]
2480 fn default_snapshot_limits_cover_deep_gtk4_trees() {
2481 assert_eq!(snapshot_limits(None, None), (1_000, 32));
2484 }
2485
2486 #[test]
2487 fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
2488 assert_eq!(app_name_needle(None), None);
2489 assert_eq!(app_name_needle(Some("")), None);
2490 assert_eq!(app_name_needle(Some(" ")), None);
2491 assert_eq!(
2492 app_name_needle(Some("Calculator")).as_deref(),
2493 Some("calculator")
2494 );
2495 assert_eq!(
2496 app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
2497 Some(":1.64/org/a11y/atspi/accessible/root")
2498 );
2499 }
2500
2501 #[test]
2502 fn requested_snapshot_limits_remain_bounded() {
2503 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
2504 assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
2505 }
2506
2507 #[test]
2508 fn app_discovery_budget_is_independent_of_requested_tree_size() {
2509 assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
2510 }
2511
2512 #[test]
2513 fn traversal_attempts_and_queue_share_one_work_budget() {
2514 let mut traversal = BoundedTraversal::new(4);
2515 traversal.enqueue([1]);
2516
2517 assert_eq!(traversal.pop(), Some(1));
2518 traversal.enqueue(2..=10_000);
2519 assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
2520
2521 assert_eq!(traversal.pop(), Some(2));
2522 traversal.enqueue(5..=10_000);
2523 assert_eq!(traversal.queue, VecDeque::from([3, 4]));
2524 assert_eq!(traversal.pop(), Some(3));
2525 assert_eq!(traversal.pop(), Some(4));
2526 assert_eq!(traversal.pop(), None);
2527 assert_eq!(traversal.attempted, 4);
2528 assert!(
2529 traversal.truncated(),
2530 "dropped enqueue items must be reported"
2531 );
2532 }
2533
2534 #[test]
2535 fn traversal_that_drains_within_budget_is_not_truncated() {
2536 let mut traversal = BoundedTraversal::new(3);
2537 traversal.enqueue([1, 2, 3]);
2538 assert_eq!(traversal.pop(), Some(1));
2539 assert_eq!(traversal.pop(), Some(2));
2540 assert_eq!(traversal.pop(), Some(3));
2541 assert_eq!(traversal.pop(), None);
2542 assert!(
2543 !traversal.truncated(),
2544 "exactly max_items real nodes is complete"
2545 );
2546 }
2547
2548 #[test]
2549 fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
2550 let mut traversal = BoundedTraversal::new(2);
2551 traversal.enqueue([1, 2]);
2552 assert_eq!(traversal.pop(), Some(1));
2553 assert_eq!(traversal.pop(), Some(2));
2554 assert!(!traversal.truncated(), "nothing dropped yet");
2555 traversal.enqueue([3]);
2558 assert!(traversal.queue.is_empty());
2559 assert_eq!(traversal.pop(), None);
2560 assert!(traversal.truncated());
2561 }
2562
2563 #[test]
2564 fn child_count_is_clamped_before_indexed_reads() {
2565 assert_eq!(bounded_child_count(-1, 4), 0);
2566 assert_eq!(bounded_child_count(3, 4), 3);
2567 assert_eq!(bounded_child_count(i32::MAX, 4), 4);
2568 }
2569
2570 #[tokio::test]
2571 async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
2572 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2573 let mut remaining_attempts = 3;
2574 let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
2575 let calls = calls.clone();
2576 move |index| {
2577 let calls = calls.clone();
2578 async move {
2579 calls.lock().unwrap().push(index);
2580 if index == 1 {
2581 Err(())
2582 } else {
2583 Ok(index)
2584 }
2585 }
2586 }
2587 })
2588 .await;
2589
2590 assert_eq!(batch.items, vec![0, 2]);
2591 assert_eq!(batch.attempted, 3);
2592 assert!(
2593 batch.incomplete,
2594 "100 reported children, 3 attempted: budget cut must be reported"
2595 );
2596 assert!(!batch.all_failed());
2597 assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
2598 assert_eq!(remaining_attempts, 0);
2599
2600 let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
2601 Ok::<_, ()>(99)
2602 })
2603 .await;
2604 assert!(no_children.items.is_empty());
2605 assert_eq!(no_children.attempted, 0);
2606 assert!(!no_children.all_failed());
2607
2608 let mut failed_attempts = 2;
2609 let all_failed =
2610 fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
2611 assert!(all_failed.all_failed());
2612 }
2613}