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