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 pub window_scope: Option<String>,
340}
341
342pub async fn snapshot_tree(
343 app_name_or_bundle_identifier: Option<&str>,
344 target_pid: Option<u32>,
345 max_nodes: usize,
346 max_depth: u32,
347) -> Result<Vec<AccessibilityNode>> {
348 Ok(snapshot_accessibility_tree(
349 app_name_or_bundle_identifier,
350 target_pid,
351 None,
352 None,
353 max_nodes,
354 max_depth,
355 )
356 .await?
357 .nodes)
358}
359
360pub(crate) async fn snapshot_accessibility_tree(
367 app_name_or_bundle_identifier: Option<&str>,
368 target_pid: Option<u32>,
369 window_title: Option<&str>,
370 pid_fallback_name: Option<&str>,
371 max_nodes: usize,
372 max_depth: u32,
373) -> Result<AccessibilitySnapshot> {
374 let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
375 let stop_reading = Instant::now() + SNAPSHOT_TIMEOUT;
379 timeout(
380 SNAPSHOT_TIMEOUT + SNAPSHOT_HARD_GRACE + SNAPSHOT_HARD_GRACE,
381 snapshot_tree_inner(
382 app_name_or_bundle_identifier,
383 target_pid,
384 window_title
385 .map(str::trim)
386 .filter(|title| !title.is_empty()),
387 pid_fallback_name,
388 max_nodes,
389 max_depth,
390 stop_reading,
391 ),
392 )
393 .await
394 .context("AT-SPI snapshot could not find its target before the deadline")?
395}
396
397async fn snapshot_tree_inner(
398 app_name_or_bundle_identifier: Option<&str>,
399 target_pid: Option<u32>,
400 window_title: Option<&str>,
401 pid_fallback_name: Option<&str>,
402 max_nodes: usize,
403 max_depth: u32,
404 stop_reading: Instant,
405) -> Result<AccessibilitySnapshot> {
406 let conn = connect().await?;
407 let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
410 snapshot_child_read_budgets(max_nodes);
411 let roots =
412 registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
413 let selected_roots = select_roots(
414 &conn,
415 roots,
416 app_name_or_bundle_identifier,
417 target_pid,
418 pid_fallback_name,
419 &mut remaining_filter_reads,
420 )
421 .await;
422 let scoped = selected_roots.scoped;
423 let root_pid = selected_roots.pid;
424 let app_filter_matched = selected_roots.app_filter_matched;
425 let mut nodes = Vec::new();
426 let mut truncated = false;
427 let mut window_scope = None;
428 let mut traversal = BoundedTraversal::new(max_nodes);
429
430 traversal.enqueue(
431 selected_roots
432 .roots
433 .into_iter()
434 .map(|object_ref| (object_ref, 0_u32, None)),
435 );
436
437 let node_cutoff = stop_reading + SNAPSHOT_HARD_GRACE;
441 while let Some((object_ref, depth, parent_index)) = traversal.pop() {
442 if Instant::now() >= stop_reading {
443 truncated = true;
444 break;
445 }
446 let index = nodes.len() as u32;
447 let remaining = traversal.remaining_capacity();
448 let read = timeout_at(node_cutoff, async {
449 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
450 return None;
451 };
452 let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
453 let mut cut = false;
454 let mut scope = None;
455 let child_refs = if depth < max_depth && remaining > 0 {
456 match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
457 Ok(batch) => {
458 cut = batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
462 match window_title.filter(|_| depth == 0) {
463 Some(title) => {
464 let (children, matched) =
465 scope_to_window(&conn, batch.items, title).await;
466 scope = matched;
467 children
468 }
469 None => batch.items,
470 }
471 }
472 Err(_) => Vec::new(),
473 }
474 } else {
475 cut = node.child_count > 0;
477 Vec::new()
478 };
479 Some((node, child_refs, cut, scope))
480 })
481 .await;
482 let Ok(read) = read else {
483 truncated = true;
484 break;
485 };
486 let Some((node, child_refs, cut, scope)) = read else {
487 continue;
488 };
489 truncated |= cut;
490 if scope.is_some() {
491 window_scope = scope;
492 }
493 nodes.push(node);
494
495 traversal.enqueue(
496 child_refs
497 .into_iter()
498 .map(|child| (child, depth + 1, Some(index))),
499 );
500 }
501 truncated |= traversal.truncated();
502
503 Ok(AccessibilitySnapshot {
504 nodes,
505 scoped,
506 root_pid,
507 app_filter_matched,
508 truncated,
509 window_scope,
510 })
511}
512
513async fn scope_to_window(
519 conn: &AccessibilityConnection,
520 children: Vec<ObjectRefOwned>,
521 title: &str,
522) -> (Vec<ObjectRefOwned>, Option<String>) {
523 let mut labels = Vec::with_capacity(children.len());
525 for child in &children {
526 labels.push(match open_accessible(conn, child).await {
527 Ok(proxy) => (
528 role_name(&proxy).await,
529 optional_string(proxy.name().await.ok()),
530 ),
531 Err(_) => (String::new(), None),
532 });
533 }
534 let Some(chosen) = matching_window(&labels, title) else {
535 return (children, None);
536 };
537 let name = labels[chosen].1.clone();
538 let keep = kept_beside_window(&labels, chosen);
539 let kept = children
540 .into_iter()
541 .zip(keep)
542 .filter_map(|(child, keep)| keep.then_some(child))
543 .collect();
544 (kept, name)
545}
546
547fn kept_beside_window(children: &[(String, Option<String>)], chosen: usize) -> Vec<bool> {
550 children
551 .iter()
552 .enumerate()
553 .map(|(index, (role, name))| {
554 index == chosen
555 || role != "frame"
556 || name.as_deref().is_none_or(|name| name.trim().is_empty())
557 })
558 .collect()
559}
560
561pub(crate) fn is_window_role(role: &str) -> bool {
562 matches!(role, "frame" | "window" | "dialog" | "file chooser")
563}
564
565fn matching_window(children: &[(String, Option<String>)], title: &str) -> Option<usize> {
575 let windows: Vec<(usize, &str)> = children
576 .iter()
577 .enumerate()
578 .filter(|(_, (role, _))| is_window_role(role))
579 .map(|(index, (_, name))| (index, name.as_deref().unwrap_or_default().trim()))
580 .collect();
581 if windows.len() < 2 {
582 return None;
583 }
584 let title = title.trim();
585 let lowered = title.to_lowercase();
586 let rules: [&dyn Fn(&str) -> bool; 3] = [
587 &|name| name == title,
588 &|name| name.to_lowercase() == lowered,
589 &|name| name.to_lowercase().contains(&lowered),
590 ];
591 for rule in rules {
592 let mut matches = windows.iter().filter(|(_, name)| rule(name));
593 if let Some((index, _)) = matches.next() {
594 return matches.next().is_none().then_some(*index);
595 }
596 }
597 let decorated = |name: &str| {
598 let name = name.to_lowercase();
599 !name.is_empty()
600 && lowered.strip_prefix(&name).is_some_and(|rest| {
601 rest.starts_with(" (") || rest.starts_with(" - ") || rest.starts_with(" \u{2014} ")
602 })
603 };
604 let decorated_matches = windows.iter().filter(|(_, name)| decorated(name));
605 let longest = decorated_matches
606 .clone()
607 .map(|(_, name)| name.len())
608 .max()?;
609 let mut longest_matches = decorated_matches.filter(|(_, name)| name.len() == longest);
610 let (index, _) = longest_matches.next()?;
611 longest_matches.next().is_none().then_some(*index)
612}
613
614#[derive(Debug, Clone, Serialize, JsonSchema)]
617pub struct FocusedElementSummary {
618 pub role: String,
619 pub name: Option<String>,
620 pub editable: bool,
621 pub states: Vec<String>,
622 #[serde(skip)]
625 #[schemars(skip)]
626 pub is_terminal: bool,
627}
628
629impl FocusedElementSummary {
630 pub(crate) fn accepts_text(&self) -> bool {
632 self.editable
633 || self.states.iter().any(|state| state == "editable")
634 || matches!(
635 self.role.as_str(),
636 "text" | "entry" | "password text" | "combo box"
637 )
638 }
639}
640
641const FOCUS_PROBE_MAX_NODES: usize = 400;
642const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
643
644#[derive(Debug, Clone)]
650pub(crate) enum FocusProbe {
651 Found(FocusedElementSummary),
652 NoneFocused,
654 ShellOnly,
656 Incomplete,
659 NoAccessibleApp,
662}
663
664pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
668 focused_element_probe(target_pid, target_pid.is_some()).await
669}
670
671pub(crate) async fn focused_element_summary_in_app(
675 target_pid: u32,
676) -> Result<Option<FocusedElementSummary>> {
677 Ok(match focused_element_probe(Some(target_pid), true).await? {
678 FocusProbe::Found(summary) => Some(summary),
679 _ => None,
680 })
681}
682
683async fn focused_element_probe(
684 target_pid: Option<u32>,
685 require_scoped: bool,
686) -> Result<FocusProbe> {
687 let conn = connect().await?;
688 Ok(focused_element_search(&conn, target_pid, require_scoped)
689 .await?
690 .0)
691}
692
693async fn focused_element_search(
696 conn: &AccessibilityConnection,
697 target_pid: Option<u32>,
698 require_scoped: bool,
699) -> Result<(FocusProbe, Option<ObjectRefOwned>)> {
700 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
701 let roots = registry_children(conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
702 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
703 let selected_roots = select_roots(
704 conn,
705 roots,
706 None,
707 target_pid,
708 None,
709 &mut remaining_filter_reads,
710 )
711 .await;
712 if require_scoped && selected_roots.pid != target_pid {
713 return Ok((FocusProbe::NoAccessibleApp, None));
714 }
715 focused_element_search_from(conn, selected_roots.roots, require_scoped).await
716}
717
718async fn focused_element_search_from(
720 conn: &AccessibilityConnection,
721 roots: Vec<ObjectRefOwned>,
722 require_scoped: bool,
723) -> Result<(FocusProbe, Option<ObjectRefOwned>)> {
724 let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
725 let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
726 let mut incomplete = false;
727 let mut shell_only = true;
728 let mut observed_nodes = 0;
729 let mut focused_container = None;
730 let mut depth_base = 0_u32;
731
732 traversal.enqueue(
733 roots
734 .into_iter()
735 .map(|object_ref| (object_ref, 0_u32, false)),
736 );
737
738 while let Some((object_ref, depth, in_menu)) = traversal.pop() {
739 let Ok(proxy) = open_accessible(conn, &object_ref).await else {
740 incomplete = true;
741 continue;
742 };
743 let Ok(state) = proxy.get_state().await else {
744 incomplete = true;
745 continue;
746 };
747 observed_nodes += 1;
748 let mut child_menu = false;
749 if shell_only {
750 match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
751 Some(menu) => child_menu = menu,
752 None => shell_only = false,
753 }
754 }
755 if state.contains(atspi::State::Focused) {
756 let proxies = proxy.proxies().await.ok();
757 let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
758 let summary = FocusedElementSummary {
759 role: role_name(&proxy).await,
760 name: optional_string(proxy.name().await.ok()),
761 editable: supports_editable_text(proxies.as_ref()).await,
762 states: state_labels(state),
763 is_terminal,
764 };
765 if focus_is_container(&summary) {
770 focused_container = Some(summary);
771 if require_scoped {
774 traversal.narrow();
775 remaining_traversal_reads = traversal.remaining_capacity();
779 depth_base = depth;
780 incomplete = false;
781 }
782 } else {
783 return Ok((FocusProbe::Found(summary), Some(object_ref.clone())));
784 }
785 }
786 let remaining = traversal.remaining_capacity();
787 if depth - depth_base < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
788 if let Ok(batch) =
789 children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
790 {
791 incomplete |= batch.incomplete;
792 if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
797 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
798 }
799 traversal.enqueue(
800 batch
801 .items
802 .into_iter()
803 .map(|child| (child, depth + 1, child_menu)),
804 );
805 } else {
806 incomplete = true;
807 }
808 } else if !incomplete {
809 incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
812 }
813 }
814 Ok((
815 finish_focus_probe(
816 incomplete || traversal.truncated(),
817 require_scoped && shell_only && observed_nodes > 0,
818 focused_container,
819 ),
820 None,
821 ))
822}
823
824pub(crate) fn focus_is_container(element: &FocusedElementSummary) -> bool {
825 !element.editable
826 && !element.states.iter().any(|state| state == "editable")
827 && !element.is_terminal
828 && matches!(
829 element.role.as_str(),
830 "application"
831 | "frame"
832 | "window"
833 | "panel"
834 | "filler"
835 | "section"
836 | "document web"
837 | "document frame"
838 | "document text"
839 )
840}
841
842fn finish_focus_probe(
843 incomplete: bool,
844 shell_only: bool,
845 focused_container: Option<FocusedElementSummary>,
846) -> FocusProbe {
847 if incomplete {
850 FocusProbe::Incomplete
851 } else if let Some(container) = focused_container {
852 FocusProbe::Found(container)
853 } else if shell_only {
854 FocusProbe::ShellOnly
855 } else {
856 FocusProbe::NoneFocused
857 }
858}
859
860pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
863 match role {
864 "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
865 "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
866 | "tearoff menu item" => Some(true),
867 "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
869 _ => None,
870 }
871}
872
873pub async fn perform_action(
874 object_ref_id: &str,
875 requested_action: Option<&str>,
876) -> Result<ActionInvocation> {
877 perform_action_inner(object_ref_id, requested_action, false).await
878}
879
880pub(crate) async fn perform_named_action(
881 object_ref_id: &str,
882 name: &str,
883) -> Result<ActionInvocation> {
884 perform_action_inner(object_ref_id, Some(name), true).await
885}
886
887async fn perform_action_inner(
888 object_ref_id: &str,
889 requested_action: Option<&str>,
890 name_only: bool,
891) -> Result<ActionInvocation> {
892 let object_ref = object_ref_from_id(object_ref_id)?;
893 let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
894 let conn = connect().await?;
895 let proxy = open_accessible(&conn, &object_ref)
896 .await
897 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
898 let action = proxy
899 .proxies()
900 .await?
901 .action()
902 .await
903 .context("element does not expose the AT-SPI Action interface")?;
904 let actions = read_actions(&action).await?;
905 let action_index = if name_only {
906 select_named_action_index(&actions, requested_action.unwrap_or_default())?
907 } else {
908 select_action_index(&actions, requested_action)?
909 };
910 let action_name = actions
911 .get(action_index as usize)
912 .map(|action| action.name.clone());
913 Ok::<_, anyhow::Error>((action, action_index, action_name))
914 })
915 .await
916 .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
917 let ok = action
919 .do_action(action_index)
920 .await
921 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
922
923 Ok(ActionInvocation {
924 action_index,
925 action_name,
926 ok,
927 })
928}
929
930pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
931 let conn = connect().await?;
932 let object_ref = object_ref_from_id(object_ref_id)?;
933 let proxy = open_accessible(&conn, &object_ref)
934 .await
935 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
936 let proxies = proxy.proxies().await?;
937 set_element_value_with_proxies(&proxy, &proxies, value).await
938}
939
940async fn set_element_value_with_proxies(
941 proxy: &AccessibleProxy<'_>,
942 proxies: &Proxies<'_>,
943 value: &str,
944) -> Result<ValueSetInvocation> {
945 if let Ok(numeric_value) = value.parse::<f64>() {
946 if let Ok(value_proxy) = proxies.value().await {
947 value_proxy
948 .set_current_value(numeric_value)
949 .await
950 .with_context(|| {
951 format!("failed to set AT-SPI numeric value to {numeric_value}")
952 })?;
953 return Ok(ValueSetInvocation::Numeric {
954 value: numeric_value,
955 });
956 }
957 }
958
959 if let Ok(editable_text) = proxies.editable_text().await {
960 return set_editable_text_contents(proxy, proxies, &editable_text, value).await;
961 }
962
963 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
964 return Err(anyhow!(
965 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
966 ));
967 }
968
969 Err(anyhow!(
970 "element does not expose AT-SPI Value or EditableText interfaces"
971 ))
972}
973
974async fn set_editable_text_contents(
975 proxy: &AccessibleProxy<'_>,
976 proxies: &Proxies<'_>,
977 editable_text: &EditableTextProxy<'_>,
978 value: &str,
979) -> Result<ValueSetInvocation> {
980 let ok = editable_text
981 .set_text_contents(value)
982 .await
983 .context("failed to set AT-SPI editable text contents")?;
984 if !ok {
985 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
986 }
987
988 let outcome = verify_editable_text_contents(proxy, proxies, value).await;
989 Ok(ValueSetInvocation::EditableText { outcome })
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
993enum EditableTextReadbackError {
994 Failed,
995 TimedOut,
996}
997
998async fn readback_within<T, E>(
999 deadline: Instant,
1000 read: impl Future<Output = std::result::Result<T, E>>,
1001) -> std::result::Result<T, EditableTextReadbackError> {
1002 timeout_at(deadline, read)
1003 .await
1004 .map_err(|_| EditableTextReadbackError::TimedOut)?
1005 .map_err(|_| EditableTextReadbackError::Failed)
1006}
1007
1008async fn text_readback_matches(
1009 text: &TextProxy<'_>,
1010 requested: &str,
1011 deadline: Instant,
1012) -> std::result::Result<bool, EditableTextReadbackError> {
1013 let character_count = readback_within(deadline, text.character_count()).await?;
1014 if character_count < 0 {
1015 return Err(EditableTextReadbackError::Failed);
1016 }
1017 if character_count as usize == requested.chars().count() {
1018 return unicode_scalar_text_readback_matches(text, 0, requested, deadline).await;
1019 }
1020
1021 let requested_utf16 = requested.encode_utf16().collect::<Vec<_>>();
1022 if character_count as usize != requested_utf16.len() {
1023 return Ok(false);
1024 }
1025 utf16_text_readback_matches(text, 0, &requested_utf16, deadline).await
1026}
1027
1028async fn unicode_scalar_text_readback_matches(
1031 text: &TextProxy<'_>,
1032 base: usize,
1033 requested: &str,
1034 deadline: Instant,
1035) -> std::result::Result<bool, EditableTextReadbackError> {
1036 let requested_chars = requested.chars().collect::<Vec<_>>();
1037 let mut offset = 0;
1038 while offset < requested_chars.len() {
1039 let end = offset
1040 .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1041 .min(requested_chars.len());
1042 let chunk = readback_within(
1043 deadline,
1044 text.get_text((base + offset) as i32, (base + end) as i32),
1045 )
1046 .await?;
1047 if !chunk
1048 .chars()
1049 .eq(requested_chars[offset..end].iter().copied())
1050 {
1051 return Ok(false);
1052 }
1053 offset = end;
1054 }
1055
1056 Ok(true)
1057}
1058
1059async fn utf16_text_readback_matches(
1061 text: &TextProxy<'_>,
1062 base: usize,
1063 requested: &[u16],
1064 deadline: Instant,
1065) -> std::result::Result<bool, EditableTextReadbackError> {
1066 let mut offset = 0;
1067 while offset < requested.len() {
1068 let mut end = offset
1069 .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1070 .min(requested.len());
1071 if end < requested.len() && (0xD800..=0xDBFF).contains(&requested[end - 1]) {
1072 end -= 1;
1073 }
1074 let chunk = readback_within(
1075 deadline,
1076 text.get_text((base + offset) as i32, (base + end) as i32),
1077 )
1078 .await?;
1079 if !chunk
1080 .encode_utf16()
1081 .eq(requested[offset..end].iter().copied())
1082 {
1083 return Ok(false);
1084 }
1085 offset = end;
1086 }
1087
1088 Ok(true)
1089}
1090
1091async fn verify_editable_text_contents(
1092 proxy: &AccessibleProxy<'_>,
1093 proxies: &Proxies<'_>,
1094 requested: &str,
1095) -> EditableTextSetOutcome {
1096 let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1097 match readback_within(deadline, proxy.get_role()).await {
1098 Ok(atspi::Role::PasswordText) => return EditableTextSetOutcome::PasswordUnverifiable,
1099 Ok(_) => {}
1100 Err(EditableTextReadbackError::Failed) => {
1101 return EditableTextSetOutcome::ReadbackFailed;
1102 }
1103 Err(EditableTextReadbackError::TimedOut) => {
1104 return EditableTextSetOutcome::ReadbackTimedOut;
1105 }
1106 }
1107 let text = match readback_within(deadline, proxies.text()).await {
1108 Ok(text) => text,
1109 Err(EditableTextReadbackError::Failed) => {
1110 return EditableTextSetOutcome::ReadbackFailed;
1111 }
1112 Err(EditableTextReadbackError::TimedOut) => {
1113 return EditableTextSetOutcome::ReadbackTimedOut;
1114 }
1115 };
1116
1117 loop {
1118 match text_readback_matches(&text, requested, deadline).await {
1119 Ok(true) => return EditableTextSetOutcome::Verified,
1120 Ok(false) => {}
1121 Err(EditableTextReadbackError::Failed) => {
1122 return EditableTextSetOutcome::ReadbackFailed;
1123 }
1124 Err(EditableTextReadbackError::TimedOut) => {
1125 return EditableTextSetOutcome::ReadbackTimedOut;
1126 }
1127 }
1128
1129 let now = Instant::now();
1130 if now >= deadline {
1131 return EditableTextSetOutcome::MismatchAfterDeadline;
1132 }
1133 sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
1134 if Instant::now() >= deadline {
1135 return EditableTextSetOutcome::MismatchAfterDeadline;
1136 }
1137 }
1138}
1139
1140const MAX_REPORTED_READBACK_CHARS: usize = 256;
1142
1143#[derive(Debug, Clone, PartialEq, Eq)]
1145pub(crate) enum FocusedTextInsert {
1146 Verified {
1149 role: String,
1150 replaced_selection: bool,
1151 caret_after_text: bool,
1153 },
1154 NotEditable(String),
1157 PasswordField,
1159 Mismatch {
1162 expected_count: i32,
1163 read_count: i32,
1164 read_text: String,
1166 read_truncated: bool,
1167 },
1168 ReadbackFailed,
1169 ReadbackTimedOut,
1170 EditFailed {
1172 reason: String,
1173 may_have_changed: bool,
1174 },
1175}
1176
1177pub(crate) async fn insert_text_at_focus(
1184 target_pid: u32,
1185 window_title: Option<&str>,
1186 text: &str,
1187) -> Result<FocusedTextInsert> {
1188 let conn = connect().await?;
1189 insert_text_at_focus_on(&conn, target_pid, window_title, text).await
1190}
1191
1192pub(crate) async fn insert_text_at_focus_on(
1193 conn: &AccessibilityConnection,
1194 target_pid: u32,
1195 window_title: Option<&str>,
1196 text: &str,
1197) -> Result<FocusedTextInsert> {
1198 let window = timeout(
1199 ACTION_QUERY_TIMEOUT,
1200 target_window_root(conn, target_pid, window_title),
1201 )
1202 .await
1203 .context("finding the target window in AT-SPI exceeded its 5-second deadline")??;
1204 let window = match window {
1205 Ok(window) => window,
1206 Err(reason) => return Ok(FocusedTextInsert::NotEditable(reason)),
1207 };
1208 let (probe, object_ref) = timeout(
1209 ACTION_QUERY_TIMEOUT,
1210 focused_element_search_from(conn, vec![window], true),
1211 )
1212 .await
1213 .context("the AT-SPI focus search exceeded its 5-second deadline")??;
1214 let (summary, object_ref) = match (probe, object_ref) {
1215 (FocusProbe::Found(summary), Some(object_ref)) => (summary, object_ref),
1216 (FocusProbe::Found(summary), None) => {
1217 return Ok(FocusedTextInsert::NotEditable(format!(
1218 "keyboard focus is on a {} container, not on a text field",
1219 summary.role
1220 )))
1221 }
1222 (FocusProbe::NoneFocused | FocusProbe::ShellOnly, _) => {
1223 return Ok(FocusedTextInsert::NotEditable(
1224 "no element in the target window has keyboard focus".to_string(),
1225 ))
1226 }
1227 (FocusProbe::Incomplete, _) => {
1228 return Ok(FocusedTextInsert::NotEditable(
1229 "the focused element was not found within the AT-SPI search limits".to_string(),
1230 ))
1231 }
1232 (FocusProbe::NoAccessibleApp, _) => {
1233 return Ok(FocusedTextInsert::NotEditable(
1234 "the target app exposes no AT-SPI tree".to_string(),
1235 ))
1236 }
1237 };
1238 let proxy = open_accessible(conn, &object_ref)
1239 .await
1240 .context("failed to open the focused AT-SPI element")?;
1241 let proxies = proxy
1242 .proxies()
1243 .await
1244 .context("failed to read the focused element's interfaces")?;
1245 insert_text_with_proxies(&proxy, &proxies, &summary, text).await
1246}
1247
1248async fn target_window_root(
1254 conn: &AccessibilityConnection,
1255 target_pid: u32,
1256 window_title: Option<&str>,
1257) -> Result<std::result::Result<ObjectRefOwned, String>> {
1258 let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
1259 let roots = registry_children(conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
1260 let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
1261 let selected = select_roots(
1262 conn,
1263 roots,
1264 None,
1265 Some(target_pid),
1266 None,
1267 &mut remaining_filter_reads,
1268 )
1269 .await;
1270 if selected.pid != Some(target_pid) {
1271 return Ok(Err("the target app exposes no AT-SPI tree".to_string()));
1272 }
1273 let mut windows = Vec::new();
1274 let mut labels = Vec::new();
1275 let mut remaining_child_reads = MAX_DISCOVERY_CHILD_READS;
1276 for root in &selected.roots {
1277 let Ok(root) = open_accessible(conn, root).await else {
1278 continue;
1279 };
1280 let Ok(batch) =
1281 children_up_to(&root, MAX_DISCOVERY_ROOTS, &mut remaining_child_reads).await
1282 else {
1283 continue;
1284 };
1285 for child in batch.items {
1286 let Ok(proxy) = open_accessible(conn, &child).await else {
1287 continue;
1288 };
1289 let role = role_name(&proxy).await;
1290 if !is_window_role(&role) {
1291 continue;
1292 }
1293 let name = optional_string(proxy.name().await.ok());
1294 let active = proxy
1295 .get_state()
1296 .await
1297 .is_ok_and(|state| state.contains(atspi::State::Active));
1298 labels.push((role, name));
1299 windows.push((child, active));
1300 }
1301 }
1302 let chosen = match windows.len() {
1303 0 => return Ok(Err("the target app exposes no AT-SPI window".to_string())),
1304 1 => 0,
1305 count => {
1306 let Some(chosen) = window_title.and_then(|title| matching_window(&labels, title))
1307 else {
1308 return Ok(Err(format!(
1309 "the target window could not be matched to one of the app's {count} AT-SPI windows"
1310 )));
1311 };
1312 chosen
1313 }
1314 };
1315 let (window, active) = windows.swap_remove(chosen);
1316 if !active {
1317 return Ok(Err(format!(
1318 "the target window's AT-SPI {} is not active, so its focus may be stale",
1319 labels[chosen].0
1320 )));
1321 }
1322 Ok(Ok(window))
1323}
1324
1325async fn read_before_edit<T, E: std::fmt::Display>(
1326 deadline: Instant,
1327 read: impl Future<Output = std::result::Result<T, E>>,
1328 what: &str,
1329) -> Result<T> {
1330 match timeout_at(deadline, read).await {
1331 Ok(Ok(value)) => Ok(value),
1332 Ok(Err(error)) => Err(anyhow!("failed to read the field's {what}: {error}")),
1333 Err(_) => Err(anyhow!("reading the field's {what} timed out")),
1334 }
1335}
1336
1337async fn insert_text_length(
1343 proxy: &AccessibleProxy<'_>,
1344 requested: &str,
1345 deadline: Instant,
1346) -> std::result::Result<i32, String> {
1347 let too_long = |_| "the text is too long for AT-SPI InsertText".to_string();
1348 if requested.is_ascii() {
1349 return i32::try_from(requested.len()).map_err(too_long);
1350 }
1351 let Some(toolkit) = toolkit_name(proxy, deadline).await else {
1352 return Err("the app's toolkit could not be read, and InsertText counts the length of non-ASCII text differently per toolkit".to_string());
1353 };
1354 if toolkit.trim().to_ascii_lowercase().starts_with("qt") {
1355 i32::try_from(requested.encode_utf16().count()).map_err(too_long)
1356 } else {
1357 i32::try_from(requested.len()).map_err(too_long)
1358 }
1359}
1360
1361async fn toolkit_name(proxy: &AccessibleProxy<'_>, deadline: Instant) -> Option<String> {
1364 let connection = proxy.inner().connection();
1365 let application = timeout_at(deadline, proxy.get_application())
1366 .await
1367 .ok()
1368 .and_then(|reply| reply.ok())
1369 .filter(|application| !application.is_null());
1370 let (name, path) = match &application {
1371 Some(application) => (
1372 application.name_as_str()?.to_string(),
1373 application.path_as_str().to_string(),
1374 ),
1375 None => (
1376 proxy.inner().destination().to_string(),
1377 "/org/a11y/atspi/accessible/root".to_string(),
1378 ),
1379 };
1380 let app = atspi::proxy::application::ApplicationProxy::builder(connection)
1381 .destination(name)
1382 .ok()?
1383 .path(path)
1384 .ok()?
1385 .cache_properties(zbus::proxy::CacheProperties::No)
1386 .build()
1387 .await
1388 .ok()?;
1389 timeout_at(deadline, app.toolkit_name()).await.ok()?.ok()
1390}
1391
1392async fn insert_text_with_proxies(
1393 proxy: &AccessibleProxy<'_>,
1394 proxies: &Proxies<'_>,
1395 summary: &FocusedElementSummary,
1396 requested: &str,
1397) -> Result<FocusedTextInsert> {
1398 let deadline = Instant::now() + ACTION_QUERY_TIMEOUT;
1399 let role = read_before_edit(deadline, proxy.get_role(), "role").await?;
1400 if role == atspi::Role::PasswordText || summary.role.to_ascii_lowercase().contains("password") {
1401 return Ok(FocusedTextInsert::PasswordField);
1402 }
1403 if summary.is_terminal || role == atspi::Role::Terminal {
1404 return Ok(FocusedTextInsert::NotEditable(
1405 "the focused element is a terminal".to_string(),
1406 ));
1407 }
1408 if matches!(role, atspi::Role::Invalid | atspi::Role::Unknown) {
1409 return Ok(FocusedTextInsert::NotEditable(
1410 "the focused element's role does not show whether it hides input".to_string(),
1411 ));
1412 }
1413 if !summary.states.iter().any(|state| state == "editable") {
1414 return Ok(FocusedTextInsert::NotEditable(format!(
1415 "the focused {} is not editable",
1416 summary.role
1417 )));
1418 }
1419 let Ok(editable) = proxies.editable_text().await else {
1420 return Ok(FocusedTextInsert::NotEditable(format!(
1421 "the focused {} does not expose AT-SPI EditableText",
1422 summary.role
1423 )));
1424 };
1425 let Ok(text) = proxies.text().await else {
1426 return Ok(FocusedTextInsert::NotEditable(format!(
1427 "the focused {} does not expose AT-SPI Text, so the result could not be read back",
1428 summary.role
1429 )));
1430 };
1431 let count = read_before_edit(deadline, text.character_count(), "character count").await?;
1432 let caret = read_before_edit(deadline, text.caret_offset(), "caret offset").await?;
1433 let selections =
1434 read_before_edit(deadline, text_selection_count(&text), "selection count").await?;
1435 let (start, end) = match selections {
1436 ..=0 => (caret, caret),
1437 1 => {
1438 let (a, b) = read_before_edit(deadline, text.get_selection(0), "selection").await?;
1439 (a.min(b), a.max(b))
1440 }
1441 n => {
1442 return Ok(FocusedTextInsert::NotEditable(format!(
1443 "the field has {n} selections, so the insertion point is ambiguous"
1444 )))
1445 }
1446 };
1447 if count < 0 || start < 0 || end > count {
1448 return Err(anyhow!(
1449 "the field reported an insertion point ({start}..{end}) outside its {count} characters"
1450 ));
1451 }
1452
1453 let replaced_selection = start < end;
1454 let whole_field = start == 0 && end == count;
1455 let insert_length = if whole_field {
1458 None
1459 } else {
1460 match insert_text_length(proxy, requested, deadline).await {
1461 Ok(length) => Some(length),
1462 Err(reason) => return Ok(FocusedTextInsert::NotEditable(reason)),
1463 }
1464 };
1465 let untouched = read_untouched_text(&text, start, end, count, deadline).await?;
1466
1467 if let Some(length) = insert_length {
1468 if replaced_selection {
1469 match edit_call(editable.delete_text(start, end), "DeleteText").await {
1470 Ok(()) => {}
1471 Err(EditCallError::Refused) => {
1472 return Ok(FocusedTextInsert::EditFailed {
1473 reason: "the app refused to delete the selection".to_string(),
1474 may_have_changed: false,
1475 })
1476 }
1477 Err(EditCallError::Failed(reason)) => {
1478 return Ok(FocusedTextInsert::EditFailed {
1479 reason,
1480 may_have_changed: true,
1481 })
1482 }
1483 }
1484 }
1485 match edit_call(editable.insert_text(start, requested, length), "InsertText").await {
1486 Ok(()) => {}
1487 Err(EditCallError::Refused) => {
1488 return Ok(FocusedTextInsert::EditFailed {
1489 reason: if replaced_selection {
1490 "the app deleted the selection but refused InsertText".to_string()
1491 } else {
1492 "the app refused InsertText".to_string()
1493 },
1494 may_have_changed: replaced_selection,
1495 })
1496 }
1497 Err(EditCallError::Failed(reason)) => {
1498 return Ok(FocusedTextInsert::EditFailed {
1499 reason,
1500 may_have_changed: true,
1501 })
1502 }
1503 }
1504 } else {
1505 match edit_call(editable.set_text_contents(requested), "SetTextContents").await {
1506 Ok(()) => {}
1507 Err(EditCallError::Refused) => {
1508 return Ok(FocusedTextInsert::EditFailed {
1509 reason: "the app refused SetTextContents".to_string(),
1510 may_have_changed: false,
1511 })
1512 }
1513 Err(EditCallError::Failed(reason)) => {
1514 return Ok(FocusedTextInsert::EditFailed {
1515 reason,
1516 may_have_changed: true,
1517 })
1518 }
1519 }
1520 }
1521
1522 verify_inserted_text(&text, start, count - (end - start), requested, &untouched)
1523 .await
1524 .map(|outcome| match outcome {
1525 InsertReadback::Verified { caret_after_text } => FocusedTextInsert::Verified {
1526 role: summary.role.clone(),
1527 replaced_selection,
1528 caret_after_text,
1529 },
1530 InsertReadback::Other(outcome) => outcome,
1531 })
1532}
1533
1534enum EditCallError {
1535 Refused,
1537 Failed(String),
1539}
1540
1541async fn edit_call(
1543 call: impl Future<Output = zbus::Result<bool>>,
1544 name: &str,
1545) -> std::result::Result<(), EditCallError> {
1546 match timeout(ACTION_QUERY_TIMEOUT, call).await {
1547 Ok(Ok(true)) => Ok(()),
1548 Ok(Ok(false)) => Err(EditCallError::Refused),
1549 Ok(Err(error)) => Err(EditCallError::Failed(format!("{name} failed: {error}"))),
1550 Err(_) => Err(EditCallError::Failed(format!(
1551 "{name} did not answer within 5 seconds"
1552 ))),
1553 }
1554}
1555
1556const UNTOUCHED_SPAN_UNITS: i32 = 32;
1558
1559enum UntouchedText {
1561 Whole { before: String, after: String },
1564 Spans {
1566 before: String,
1567 before_units: i32,
1568 after: String,
1569 after_units: i32,
1570 },
1571}
1572
1573async fn read_untouched_text(
1574 text: &TextProxy<'_>,
1575 start: i32,
1576 end: i32,
1577 count: i32,
1578 deadline: Instant,
1579) -> Result<UntouchedText> {
1580 if count <= MAX_TEXT_READBACK_CHARS {
1581 return Ok(UntouchedText::Whole {
1582 before: read_before_edit(deadline, text.get_text(0, start), "text").await?,
1583 after: read_before_edit(deadline, text.get_text(end, count), "text").await?,
1584 });
1585 }
1586 let before_units = start.min(UNTOUCHED_SPAN_UNITS);
1587 let after_units = (count - end).min(UNTOUCHED_SPAN_UNITS);
1588 Ok(UntouchedText::Spans {
1589 before: read_before_edit(deadline, text.get_text(start - before_units, start), "text")
1590 .await?,
1591 before_units,
1592 after: read_before_edit(deadline, text.get_text(end, end + after_units), "text").await?,
1593 after_units,
1594 })
1595}
1596
1597enum InsertReadback {
1598 Verified { caret_after_text: bool },
1599 Other(FocusedTextInsert),
1600}
1601
1602async fn edit_reads_back(
1605 text: &TextProxy<'_>,
1606 start: i32,
1607 units: i32,
1608 read_count: i32,
1609 requested: &str,
1610 untouched: &UntouchedText,
1611 deadline: Instant,
1612) -> std::result::Result<bool, EditableTextReadbackError> {
1613 match untouched {
1614 UntouchedText::Whole { before, after } => {
1615 let contents = readback_within(deadline, text.get_text(0, read_count)).await?;
1616 Ok(
1617 contents.len() == before.len() + requested.len() + after.len()
1618 && contents.starts_with(before.as_str())
1619 && contents[before.len()..].starts_with(requested)
1620 && contents.ends_with(after.as_str()),
1621 )
1622 }
1623 UntouchedText::Spans {
1624 before,
1625 before_units,
1626 after,
1627 after_units,
1628 } => {
1629 let inserted = readback_within(deadline, text.get_text(start, start + units)).await?;
1630 if inserted != requested {
1631 return Ok(false);
1632 }
1633 let read_before =
1634 readback_within(deadline, text.get_text(start - before_units, start)).await?;
1635 let after_start = start + units;
1636 let read_after = readback_within(
1637 deadline,
1638 text.get_text(after_start, after_start + after_units),
1639 )
1640 .await?;
1641 Ok(&read_before == before && &read_after == after)
1642 }
1643 }
1644}
1645
1646async fn verify_inserted_text(
1651 text: &TextProxy<'_>,
1652 start: i32,
1653 kept: i32,
1654 requested: &str,
1655 untouched: &UntouchedText,
1656) -> Result<InsertReadback> {
1657 let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1658 let scalars = requested.chars().count() as i64;
1659 let utf16 = requested.encode_utf16().count() as i64;
1660 let mut read_count = 0;
1661 let mut mismatched = false;
1664 loop {
1665 let count = match readback_within(deadline, text.character_count()).await {
1666 Ok(count) => count,
1667 Err(EditableTextReadbackError::Failed) => {
1668 return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackFailed))
1669 }
1670 Err(EditableTextReadbackError::TimedOut) => {
1671 if mismatched {
1672 break;
1673 }
1674 return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackTimedOut));
1675 }
1676 };
1677 read_count = count;
1678 let units = i64::from(read_count) - i64::from(kept);
1679 let matched = if units == scalars || units == utf16 {
1680 edit_reads_back(
1681 text,
1682 start,
1683 units as i32,
1684 read_count,
1685 requested,
1686 untouched,
1687 deadline,
1688 )
1689 .await
1690 .map(|matched| matched.then_some(units))
1691 } else {
1692 Ok(None)
1693 };
1694 match matched {
1695 Ok(Some(units)) => {
1696 let after = (i64::from(start) + units) as i32;
1697 let caret_deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1698 let caret_after_text =
1699 match readback_within(caret_deadline, text.caret_offset()).await {
1700 Ok(caret) if caret == after => true,
1701 _ => matches!(
1702 readback_within(caret_deadline, text.set_caret_offset(after)).await,
1703 Ok(true)
1704 ),
1705 };
1706 return Ok(InsertReadback::Verified { caret_after_text });
1707 }
1708 Ok(None) => mismatched = true,
1709 Err(EditableTextReadbackError::Failed) => {
1710 return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackFailed))
1711 }
1712 Err(EditableTextReadbackError::TimedOut) => {
1713 if mismatched {
1714 break;
1715 }
1716 return Ok(InsertReadback::Other(FocusedTextInsert::ReadbackTimedOut));
1717 }
1718 }
1719 let now = Instant::now();
1720 if now >= deadline {
1721 break;
1722 }
1723 sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
1724 if Instant::now() >= deadline {
1725 break;
1726 }
1727 }
1728
1729 let wanted = scalars.max(utf16);
1731 let shown = wanted.min(MAX_REPORTED_READBACK_CHARS as i64);
1732 let read_end = (i64::from(start) + shown).min(i64::from(read_count.max(0)));
1733 let read_text = if read_end > i64::from(start) {
1734 let quote_deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1735 readback_within(quote_deadline, text.get_text(start, read_end as i32))
1736 .await
1737 .unwrap_or_default()
1738 } else {
1739 String::new()
1740 };
1741 let read_truncated = wanted > shown;
1742 Ok(InsertReadback::Other(FocusedTextInsert::Mismatch {
1743 expected_count: (i64::from(kept) + scalars) as i32,
1744 read_count,
1745 read_text,
1746 read_truncated,
1747 }))
1748}
1749
1750async fn connect() -> Result<AccessibilityConnection> {
1751 hydrate_session_bus_env();
1752 AccessibilityConnection::new()
1753 .await
1754 .context("failed to connect to AT-SPI bus")
1755}
1756
1757async fn open_accessible<'r>(
1770 conn: &AccessibilityConnection,
1771 object_ref: &'r ObjectRefOwned,
1772) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
1773 object_ref.as_accessible_proxy(conn.connection()).await
1774}
1775
1776async fn registry_children(
1777 conn: &AccessibilityConnection,
1778 limit: usize,
1779 remaining_child_reads: &mut usize,
1780) -> Result<Vec<ObjectRefOwned>> {
1781 let root = conn
1782 .root_accessible_on_registry()
1783 .await
1784 .context("failed to open AT-SPI registry root")?;
1785 let batch = children_up_to(&root, limit, remaining_child_reads)
1786 .await
1787 .context("failed to read AT-SPI registry children")?;
1788 if batch.all_failed() {
1789 return Err(anyhow!(
1790 "AT-SPI registry reported children, but every indexed child read failed"
1791 ));
1792 }
1793 Ok(batch.items)
1794}
1795
1796struct SelectedRoots {
1797 roots: Vec<ObjectRefOwned>,
1798 scoped: bool,
1799 pid: Option<u32>,
1801 app_filter_matched: bool,
1802}
1803
1804fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
1807 app_name_or_bundle_identifier
1808 .map(str::trim)
1809 .filter(|value| !value.is_empty())
1810 .map(|value| value.to_ascii_lowercase())
1811}
1812
1813async fn select_roots(
1814 conn: &AccessibilityConnection,
1815 roots: Vec<ObjectRefOwned>,
1816 app_name_or_bundle_identifier: Option<&str>,
1817 target_pid: Option<u32>,
1818 pid_fallback_name: Option<&str>,
1819 remaining_child_reads: &mut usize,
1820) -> SelectedRoots {
1821 let needle = app_name_needle(app_name_or_bundle_identifier);
1822 let dbus = DBusProxy::new(conn.connection()).await.ok();
1823 let mut remaining = roots;
1824
1825 if let Some(target_pid) = target_pid {
1826 let mut pid_and_filter_matches = Vec::new();
1827 let mut pid_matches = Vec::new();
1828 let mut non_pid_matches = Vec::new();
1829
1830 for object_ref in remaining {
1831 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
1832 if let Some(needle) = needle.as_deref() {
1833 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1834 pid_and_filter_matches.push(object_ref);
1835 } else {
1836 pid_matches.push(object_ref);
1837 }
1838 } else {
1839 pid_matches.push(object_ref);
1840 }
1841 } else {
1842 non_pid_matches.push(object_ref);
1843 }
1844 }
1845
1846 if !pid_and_filter_matches.is_empty() {
1847 return SelectedRoots {
1848 roots: pid_and_filter_matches,
1849 scoped: true,
1850 pid: Some(target_pid),
1851 app_filter_matched: true,
1852 };
1853 }
1854 if !pid_matches.is_empty() {
1855 return SelectedRoots {
1856 roots: pid_matches,
1857 scoped: true,
1858 pid: Some(target_pid),
1859 app_filter_matched: needle.is_none(),
1860 };
1861 }
1862
1863 if needle.is_none() {
1864 if let Some(fallback) = app_name_needle(pid_fallback_name) {
1868 let mut selected = Vec::new();
1869 for object_ref in non_pid_matches {
1870 if root_matches(conn, &object_ref, &fallback, remaining_child_reads).await {
1871 selected.push(object_ref);
1872 }
1873 }
1874 if !selected.is_empty() {
1875 return SelectedRoots {
1876 roots: selected,
1877 scoped: true,
1878 pid: None,
1879 app_filter_matched: true,
1880 };
1881 }
1882 }
1883 return SelectedRoots {
1885 roots: Vec::new(),
1886 scoped: true,
1887 pid: None,
1888 app_filter_matched: false,
1889 };
1890 }
1891
1892 remaining = non_pid_matches;
1893 }
1894
1895 let Some(needle) = needle.as_deref() else {
1896 return SelectedRoots {
1897 roots: remaining,
1898 scoped: false,
1899 pid: None,
1900 app_filter_matched: true,
1901 };
1902 };
1903
1904 let mut selected = Vec::new();
1905 for object_ref in remaining {
1906 if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1907 selected.push(object_ref);
1908 }
1909 }
1910
1911 let app_filter_matched = !selected.is_empty();
1912 SelectedRoots {
1913 roots: selected,
1914 scoped: true,
1915 pid: None,
1916 app_filter_matched,
1917 }
1918}
1919
1920async fn root_matches(
1921 conn: &AccessibilityConnection,
1922 object_ref: &ObjectRefOwned,
1923 needle: &str,
1924 remaining_child_reads: &mut usize,
1925) -> bool {
1926 let Ok(proxy) = open_accessible(conn, object_ref).await else {
1927 return object_ref_id(object_ref)
1928 .to_ascii_lowercase()
1929 .contains(needle);
1930 };
1931
1932 if proxy_matches(&proxy, object_ref, needle).await {
1933 return true;
1934 }
1935
1936 for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
1937 .await
1938 .map(|batch| batch.items)
1939 .unwrap_or_default()
1940 {
1941 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
1942 continue;
1943 };
1944 if proxy_matches(&child_proxy, &child_ref, needle).await {
1945 return true;
1946 }
1947 }
1948
1949 false
1950}
1951
1952async fn proxy_matches(
1953 proxy: &AccessibleProxy<'_>,
1954 object_ref: &ObjectRefOwned,
1955 needle: &str,
1956) -> bool {
1957 let name = proxy.name().await.unwrap_or_default();
1958 let role = proxy.get_role_name().await.unwrap_or_default();
1959 format!("{} {} {}", object_ref_id(object_ref), name, role)
1960 .to_ascii_lowercase()
1961 .contains(needle)
1962}
1963
1964async fn read_app_summary(
1965 proxy: &AccessibleProxy<'_>,
1966 object_ref: &ObjectRefOwned,
1967 dbus: Option<&DBusProxy<'_>>,
1968) -> AccessibleAppSummary {
1969 AccessibleAppSummary {
1970 object_ref: object_ref_id(object_ref),
1971 name: optional_string(proxy.name().await.ok()),
1972 pid: object_ref_pid(dbus, object_ref).await,
1973 role: role_name(proxy).await,
1974 child_count: proxy.child_count().await.unwrap_or_default(),
1975 bounds: bounds(proxy).await,
1976 }
1977}
1978
1979async fn read_node(
1980 proxy: &AccessibleProxy<'_>,
1981 object_ref: &ObjectRefOwned,
1982 index: u32,
1983 parent_index: Option<u32>,
1984 depth: u32,
1985) -> AccessibilityNode {
1986 let proxies = proxy.proxies().await.ok();
1987
1988 AccessibilityNode {
1989 index,
1990 parent_index,
1991 depth,
1992 object_ref: object_ref_id(object_ref),
1993 role: role_name(proxy).await,
1994 name: optional_string(proxy.name().await.ok()),
1995 description: optional_string(proxy.description().await.ok()),
1996 child_count: proxy.child_count().await.unwrap_or_default(),
1997 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
1998 states: states_from_proxy(proxy).await,
1999 actions: actions_from_proxies(proxies.as_ref()).await,
2000 value: value_from_proxies(proxies.as_ref()).await,
2001 text: text_from_proxies(proxies.as_ref()).await,
2002 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
2003 }
2004}
2005
2006pub(crate) const UNKNOWN_ROLE: &str = "unknown";
2008
2009async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
2010 if let Ok(role) = proxy.get_role_name().await {
2011 if !role.trim().is_empty() {
2012 return role;
2013 }
2014 }
2015 proxy
2016 .get_role()
2017 .await
2018 .map(|role| format!("{role:?}"))
2019 .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
2020}
2021
2022async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
2023 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
2024}
2025
2026pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
2029 let object_ref = object_ref_from_id(object_ref_id)?;
2030 let conn = connect().await?;
2031 let dbus = DBusProxy::new(conn.connection()).await.ok();
2032 Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
2033}
2034
2035pub(crate) async fn object_ref_application_pid(object_ref_id: &str) -> Result<Option<u32>> {
2037 let object_ref = object_ref_from_id(object_ref_id)?;
2038 let conn = connect().await?;
2039 let application = timeout(ACTION_QUERY_TIMEOUT, async {
2040 let proxy = open_accessible(&conn, &object_ref).await.ok()?;
2041 proxy.get_application().await.ok()
2042 })
2043 .await
2044 .ok()
2045 .flatten()
2046 .filter(|application| !application.is_null());
2047 let dbus = DBusProxy::new(conn.connection()).await.ok();
2048 Ok(object_ref_pid(dbus.as_ref(), application.as_ref().unwrap_or(&object_ref)).await)
2049}
2050
2051async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
2052 let dbus = dbus?;
2053 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
2054 dbus.get_connection_unix_process_id(bus_name).await.ok()
2055}
2056
2057async fn bounds_from_proxies(
2058 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2059 proxy: &AccessibleProxy<'_>,
2060) -> Option<Bounds> {
2061 let owned_proxies;
2062 let proxies = if let Some(proxies) = proxies {
2063 proxies
2064 } else {
2065 owned_proxies = proxy.proxies().await.ok()?;
2066 &owned_proxies
2067 };
2068 let component = proxies.component().await.ok()?;
2069 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
2070 normalize_bounds(Bounds {
2071 x,
2072 y,
2073 width,
2074 height,
2075 })
2076}
2077
2078fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
2079 if bounds.width <= 0 || bounds.height <= 0 {
2080 return None;
2081 }
2082 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
2083 return None;
2084 }
2085 Some(bounds)
2086}
2087
2088async fn actions_from_proxies(
2089 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2090) -> Vec<AccessibilityAction> {
2091 let Some(proxies) = proxies else {
2092 return Vec::new();
2093 };
2094 let Ok(action_proxy) = proxies.action().await else {
2095 return Vec::new();
2096 };
2097
2098 read_actions(&action_proxy)
2099 .await
2100 .unwrap_or_default()
2101 .into_iter()
2102 .enumerate()
2103 .map(|(index, action)| AccessibilityAction {
2104 index: index as i32,
2105 name: action.name,
2106 description: action.description,
2107 keybinding: action.keybinding,
2108 })
2109 .collect()
2110}
2111
2112async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
2113 timeout(ACTION_QUERY_TIMEOUT, async {
2117 let count = proxy
2118 .inner()
2119 .get_property::<i32>("NActions")
2120 .await
2121 .context("failed to read AT-SPI action count")?;
2122 if count > MAX_ACTIONS_PER_OBJECT {
2123 return Err(anyhow!(
2124 "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
2125 ));
2126 }
2127 if count < 0 {
2128 return Err(anyhow!("invalid negative AT-SPI action count {count}"));
2129 }
2130 Ok(stream::iter(0..count)
2131 .map(|index| async move {
2132 let (name, description, keybinding) = tokio::join!(
2133 proxy.get_name(index),
2134 proxy.get_description(index),
2135 proxy.get_key_binding(index),
2136 );
2137 atspi::Action {
2138 name: name.unwrap_or_default(),
2139 description: description.unwrap_or_default(),
2140 keybinding: keybinding.unwrap_or_default(),
2141 }
2142 })
2143 .buffered(CHILD_READ_CONCURRENCY)
2144 .collect()
2145 .await)
2146 })
2147 .await
2148 .context("AT-SPI action reads exceeded their 5-second deadline")?
2149}
2150
2151async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
2152 proxy
2153 .get_state()
2154 .await
2155 .map(state_labels)
2156 .unwrap_or_default()
2157}
2158
2159async fn value_from_proxies(
2160 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2161) -> Option<AccessibilityValue> {
2162 let value = proxies?.value().await.ok()?;
2163 Some(AccessibilityValue {
2164 current: value.current_value().await.ok()?,
2165 minimum: value.minimum_value().await.ok()?,
2166 maximum: value.maximum_value().await.ok()?,
2167 minimum_increment: value.minimum_increment().await.ok()?,
2168 text: optional_string(value.text().await.ok()),
2169 })
2170}
2171
2172async fn text_selection_count(text: &TextProxy<'_>) -> zbus::Result<i32> {
2175 text.inner()
2176 .call_method("GetNSelections", &())
2177 .await?
2178 .body()
2179 .deserialize()
2180}
2181
2182async fn text_from_proxies(
2183 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
2184) -> Option<AccessibilityText> {
2185 let text = proxies?.text().await.ok()?;
2186 let character_count = text.character_count().await.ok()?.max(0);
2187 let caret_offset = text.caret_offset().await.ok();
2188 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
2189 let content = if capped_count > 0 {
2190 optional_string(text.get_text(0, capped_count).await.ok())
2191 } else {
2192 None
2193 };
2194 let selection_count = text_selection_count(&text)
2195 .await
2196 .unwrap_or_default()
2197 .clamp(0, MAX_TEXT_SELECTIONS);
2198 let mut selections = Vec::new();
2199 for index in 0..selection_count {
2200 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
2201 selections.push(AccessibilityTextSelection {
2202 start_offset,
2203 end_offset,
2204 });
2205 }
2206 }
2207
2208 Some(AccessibilityText {
2209 character_count,
2210 caret_offset,
2211 content,
2212 truncated: character_count > MAX_TEXT_READBACK_CHARS,
2213 selections,
2214 })
2215}
2216
2217async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
2218 let Some(proxies) = proxies else {
2219 return false;
2220 };
2221 proxies.editable_text().await.is_ok()
2222}
2223
2224fn state_labels(state_set: StateSet) -> Vec<String> {
2225 state_set.iter().map(|state| state.to_string()).collect()
2226}
2227
2228fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
2229 if actions.is_empty() {
2230 return Err(anyhow!("element exposes no AT-SPI actions"));
2231 }
2232
2233 if let Some(requested_action) = requested_action
2234 .map(str::trim)
2235 .filter(|value| !value.is_empty())
2236 {
2237 let requested_action = requested_action.to_ascii_lowercase();
2238 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
2239 action.name.to_ascii_lowercase() == requested_action
2240 || action.description.to_ascii_lowercase() == requested_action
2241 }) {
2242 return Ok(index as i32);
2243 }
2244
2245 if let Ok(index) = requested_action.parse::<usize>() {
2246 if index < actions.len() {
2247 return Ok(index as i32);
2248 }
2249 }
2250
2251 return Err(anyhow!(
2252 "requested AT-SPI action was not found; available actions: {}",
2253 actions
2254 .iter()
2255 .map(|action| action.name.as_str())
2256 .collect::<Vec<_>>()
2257 .join(", ")
2258 ));
2259 }
2260
2261 Ok(if actions.len() > 1 { 1 } else { 0 })
2262}
2263
2264fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
2265 actions
2266 .iter()
2267 .position(|action| action.name.eq_ignore_ascii_case(name))
2268 .map(|index| index as i32)
2269 .ok_or_else(|| {
2270 anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
2271 })
2272}
2273
2274fn optional_string(value: Option<String>) -> Option<String> {
2275 value
2276 .map(|value| value.trim().to_string())
2277 .filter(|value| !value.is_empty())
2278}
2279
2280fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
2281 let (name, path) = split_object_ref_id(object_ref_id)?;
2282 let name = UniqueName::try_from(name.to_string())
2283 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
2284 let path = ObjectPath::try_from(path.to_string())
2285 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
2286 Ok(ObjectRef::new_owned(name, path))
2287}
2288
2289fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
2290 let Some(path_start) = object_ref_id.find('/') else {
2291 return Err(anyhow!(
2292 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
2293 ));
2294 };
2295 let (name, path) = object_ref_id.split_at(path_start);
2296 if name.is_empty() || path.is_empty() {
2297 return Err(anyhow!(
2298 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
2299 ));
2300 }
2301 Ok((name, path))
2302}
2303
2304fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
2305 format!(
2306 "{}{}",
2307 object_ref.name_as_str().unwrap_or(""),
2308 object_ref.path_as_str()
2309 )
2310}
2311
2312#[cfg(test)]
2313mod tests {
2314 use super::insert_fixture::*;
2315 use super::*;
2316 use crate::test_sockets::PrivateBus;
2317 use std::sync::{
2318 atomic::{AtomicUsize, Ordering},
2319 Arc, Mutex,
2320 };
2321
2322 const EDITABLE_FIXTURE_PATH: &str = "/editable";
2323
2324 #[derive(Default)]
2325 struct FakeEditableState {
2326 contents: String,
2327 mutations: usize,
2328 character_count_reads: usize,
2329 get_text_reads: usize,
2330 numeric_value: f64,
2331 numeric_writes: usize,
2332 }
2333
2334 #[derive(Clone, Copy)]
2335 enum FakeMutation {
2336 Apply,
2337 NoOp,
2338 Delayed,
2339 Normalize,
2340 Mask,
2341 Reject,
2342 Fail,
2343 }
2344
2345 #[derive(Clone, Copy, Debug)]
2346 enum FakeTextOffsetModel {
2347 UnicodeScalars,
2348 Utf16CodeUnits,
2349 }
2350
2351 #[derive(Clone, Copy)]
2352 enum FakeTextBehavior {
2353 Normal,
2354 CharacterCountError,
2355 GetTextError,
2356 NegativeCount,
2357 StallCharacterCount,
2358 }
2359
2360 #[derive(Clone, Copy)]
2361 struct FakeEditableConfig {
2362 mutation: FakeMutation,
2363 text: FakeTextBehavior,
2364 text_offset_model: FakeTextOffsetModel,
2365 role: atspi::Role,
2366 has_text: bool,
2367 has_value: bool,
2368 }
2369
2370 impl Default for FakeEditableConfig {
2371 fn default() -> Self {
2372 Self {
2373 mutation: FakeMutation::Apply,
2374 text: FakeTextBehavior::Normal,
2375 text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
2376 role: atspi::Role::Text,
2377 has_text: true,
2378 has_value: false,
2379 }
2380 }
2381 }
2382
2383 struct FakeEditableAccessible(FakeEditableConfig);
2384
2385 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
2386 impl FakeEditableAccessible {
2387 #[zbus(property)]
2388 fn child_count(&self) -> i32 {
2389 0
2390 }
2391
2392 fn get_interfaces(&self) -> Vec<&str> {
2393 let mut interfaces = vec!["org.a11y.atspi.Accessible", "org.a11y.atspi.EditableText"];
2394 if self.0.has_text {
2395 interfaces.push("org.a11y.atspi.Text");
2396 }
2397 if self.0.has_value {
2398 interfaces.push("org.a11y.atspi.Value");
2399 }
2400 interfaces
2401 }
2402
2403 fn get_role(&self) -> atspi::Role {
2404 self.0.role
2405 }
2406 }
2407
2408 struct FakeEditableText {
2409 state: Arc<Mutex<FakeEditableState>>,
2410 mutation: FakeMutation,
2411 }
2412
2413 #[zbus::interface(name = "org.a11y.atspi.EditableText")]
2414 impl FakeEditableText {
2415 fn set_text_contents(&self, new_contents: &str) -> zbus::fdo::Result<bool> {
2416 self.state.lock().unwrap().mutations += 1;
2417 match self.mutation {
2418 FakeMutation::Apply => {
2419 self.state.lock().unwrap().contents = new_contents.to_string();
2420 }
2421 FakeMutation::NoOp => {}
2422 FakeMutation::Delayed => {
2423 let state = Arc::clone(&self.state);
2424 let new_contents = new_contents.to_string();
2425 tokio::spawn(async move {
2426 tokio::time::sleep(Duration::from_millis(150)).await;
2427 state.lock().unwrap().contents = new_contents;
2428 });
2429 }
2430 FakeMutation::Normalize => {
2431 self.state.lock().unwrap().contents = new_contents.replace("\r\n", "\n");
2432 }
2433 FakeMutation::Mask => {
2434 self.state.lock().unwrap().contents = "•".repeat(new_contents.chars().count());
2435 }
2436 FakeMutation::Reject => return Ok(false),
2437 FakeMutation::Fail => {
2438 return Err(zbus::fdo::Error::Failed(
2439 "injected SetTextContents failure".into(),
2440 ));
2441 }
2442 }
2443 Ok(true)
2444 }
2445 }
2446
2447 fn unicode_scalar_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
2448 contents
2449 .char_indices()
2450 .map(|(byte_index, _)| byte_index)
2451 .chain(std::iter::once(contents.len()))
2452 .nth(offset)
2453 }
2454
2455 fn utf16_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
2456 let mut utf16_offset = 0;
2457 for (byte_index, character) in contents.char_indices() {
2458 if utf16_offset == offset {
2459 return Some(byte_index);
2460 }
2461 utf16_offset += character.len_utf16();
2462 }
2463 (utf16_offset == offset).then_some(contents.len())
2464 }
2465
2466 struct FakeText {
2467 state: Arc<Mutex<FakeEditableState>>,
2468 behavior: FakeTextBehavior,
2469 text_offset_model: FakeTextOffsetModel,
2470 }
2471
2472 #[zbus::interface(name = "org.a11y.atspi.Text")]
2473 impl FakeText {
2474 #[zbus(property)]
2475 async fn character_count(&self) -> zbus::fdo::Result<i32> {
2476 self.state.lock().unwrap().character_count_reads += 1;
2477 match self.behavior {
2478 FakeTextBehavior::CharacterCountError => Err(zbus::fdo::Error::Failed(
2479 "injected CharacterCount failure".into(),
2480 )),
2481 FakeTextBehavior::NegativeCount => Ok(-1),
2482 FakeTextBehavior::StallCharacterCount => std::future::pending().await,
2483 _ => {
2484 let state = self.state.lock().unwrap();
2485 let count = match self.text_offset_model {
2486 FakeTextOffsetModel::UnicodeScalars => state.contents.chars().count(),
2487 FakeTextOffsetModel::Utf16CodeUnits => {
2488 state.contents.encode_utf16().count()
2489 }
2490 };
2491 Ok(count as i32)
2492 }
2493 }
2494 }
2495
2496 fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
2497 let mut state = self.state.lock().unwrap();
2498 state.get_text_reads += 1;
2499 if matches!(self.behavior, FakeTextBehavior::GetTextError) {
2500 return Err(zbus::fdo::Error::Failed("injected GetText failure".into()));
2501 }
2502 if start_offset < 0 || end_offset < start_offset {
2503 return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
2504 }
2505 let offset_to_byte_index = match self.text_offset_model {
2506 FakeTextOffsetModel::UnicodeScalars => unicode_scalar_offset_to_byte_index,
2507 FakeTextOffsetModel::Utf16CodeUnits => utf16_offset_to_byte_index,
2508 };
2509 let start = offset_to_byte_index(&state.contents, start_offset as usize)
2510 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
2511 let end = offset_to_byte_index(&state.contents, end_offset as usize)
2512 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
2513 Ok(state.contents[start..end].to_string())
2514 }
2515 }
2516
2517 struct FakeValue(Arc<Mutex<FakeEditableState>>);
2518
2519 #[zbus::interface(name = "org.a11y.atspi.Value")]
2520 impl FakeValue {
2521 #[zbus(property)]
2522 fn current_value(&self) -> f64 {
2523 self.0.lock().unwrap().numeric_value
2524 }
2525
2526 #[zbus(property)]
2527 fn set_current_value(&self, value: f64) {
2528 let mut state = self.0.lock().unwrap();
2529 state.numeric_value = value;
2530 state.numeric_writes += 1;
2531 }
2532 }
2533
2534 async fn start_editable_fixture(
2535 initial_contents: &str,
2536 config: FakeEditableConfig,
2537 ) -> (
2538 PrivateBus,
2539 zbus::Connection,
2540 zbus::Connection,
2541 Arc<Mutex<FakeEditableState>>,
2542 ) {
2543 let bus = PrivateBus::start("cul-atspi-bus");
2544 let address = bus.address();
2545 let state = Arc::new(Mutex::new(FakeEditableState {
2546 contents: initial_contents.to_string(),
2547 ..Default::default()
2548 }));
2549 let mut builder = zbus::connection::Builder::address(address)
2550 .unwrap()
2551 .serve_at(EDITABLE_FIXTURE_PATH, FakeEditableAccessible(config))
2552 .unwrap()
2553 .serve_at(
2554 EDITABLE_FIXTURE_PATH,
2555 FakeEditableText {
2556 state: Arc::clone(&state),
2557 mutation: config.mutation,
2558 },
2559 )
2560 .unwrap();
2561 if config.has_text {
2562 builder = builder
2563 .serve_at(
2564 EDITABLE_FIXTURE_PATH,
2565 FakeText {
2566 state: Arc::clone(&state),
2567 behavior: config.text,
2568 text_offset_model: config.text_offset_model,
2569 },
2570 )
2571 .unwrap();
2572 }
2573 if config.has_value {
2574 builder = builder
2575 .serve_at(EDITABLE_FIXTURE_PATH, FakeValue(Arc::clone(&state)))
2576 .unwrap();
2577 }
2578 let peer = builder.build().await.unwrap();
2579 let client = zbus::connection::Builder::address(address)
2580 .unwrap()
2581 .build()
2582 .await
2583 .unwrap();
2584 (bus, peer, client, state)
2585 }
2586
2587 async fn dispatch_editable_fixture(
2588 client: &zbus::Connection,
2589 peer: &zbus::Connection,
2590 value: &str,
2591 ) -> Result<ValueSetInvocation> {
2592 let proxy = AccessibleProxy::builder(client)
2593 .destination(peer.unique_name().unwrap().clone())
2594 .unwrap()
2595 .path(EDITABLE_FIXTURE_PATH)
2596 .unwrap()
2597 .cache_properties(zbus::proxy::CacheProperties::No)
2598 .build()
2599 .await
2600 .unwrap();
2601 let proxies = proxy.proxies().await.unwrap();
2602 set_element_value_with_proxies(&proxy, &proxies, value).await
2603 }
2604
2605 fn editable_outcome(invocation: ValueSetInvocation) -> EditableTextSetOutcome {
2606 match invocation {
2607 ValueSetInvocation::EditableText { outcome } => outcome,
2608 ValueSetInvocation::Numeric { .. } => panic!("expected editable-text dispatch"),
2609 }
2610 }
2611
2612 struct FakeAccessible {
2613 name: String,
2614 children: Vec<(String, OwnedObjectPath)>,
2615 has_action: bool,
2616 }
2617
2618 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
2619 impl FakeAccessible {
2620 #[zbus(property)]
2621 fn child_count(&self) -> i32 {
2622 self.children.len() as i32
2623 }
2624
2625 #[zbus(property)]
2626 fn name(&self) -> &str {
2627 &self.name
2628 }
2629
2630 fn get_interfaces(&self) -> Vec<&str> {
2631 let mut interfaces = vec!["org.a11y.atspi.Accessible"];
2632 if self.has_action {
2633 interfaces.push("org.a11y.atspi.Action");
2634 }
2635 interfaces
2636 }
2637
2638 fn get_child_at_index(
2639 &self,
2640 index: i32,
2641 ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
2642 self.children
2643 .get(index as usize)
2644 .cloned()
2645 .map(|child| (child,))
2646 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
2647 }
2648 }
2649
2650 struct FakeWebKitAction {
2651 count: i32,
2652 stall_indexed_reads: bool,
2653 name_reads: Arc<AtomicUsize>,
2654 }
2655
2656 impl Default for FakeWebKitAction {
2657 fn default() -> Self {
2658 Self {
2659 count: 1,
2660 stall_indexed_reads: false,
2661 name_reads: Arc::default(),
2662 }
2663 }
2664 }
2665
2666 #[zbus::interface(name = "org.a11y.atspi.Action")]
2667 impl FakeWebKitAction {
2668 async fn get_actions(&self) -> Vec<atspi::Action> {
2669 std::future::pending().await
2670 }
2671
2672 #[zbus(property, name = "NActions")]
2673 fn nactions(&self) -> i32 {
2674 self.count
2675 }
2676
2677 async fn get_name(&self, index: i32) -> &str {
2678 assert!((0..self.count).contains(&index));
2679 self.name_reads.fetch_add(1, Ordering::SeqCst);
2680 if self.stall_indexed_reads {
2681 std::future::pending::<()>().await;
2682 }
2683 "click"
2684 }
2685
2686 async fn get_localized_name(&self, index: i32) -> &str {
2687 assert!((0..self.count).contains(&index));
2688 "Activate"
2689 }
2690
2691 fn get_description(&self, index: i32) -> &str {
2692 assert!((0..self.count).contains(&index));
2693 "Activate the control"
2694 }
2695
2696 fn get_key_binding(&self, index: i32) -> &str {
2697 assert!((0..self.count).contains(&index));
2698 "Return"
2699 }
2700
2701 fn do_action(&self, index: i32) -> bool {
2702 index == 0
2703 }
2704 }
2705
2706 struct FakeQtAction;
2707
2708 #[zbus::interface(name = "org.a11y.atspi.Action")]
2709 impl FakeQtAction {
2710 #[zbus(property, name = "NActions")]
2711 fn nactions(&self) -> i32 {
2712 2
2713 }
2714
2715 fn get_name(&self, index: i32) -> &str {
2716 match index {
2717 0 => "Press",
2718 1 => "Set Focus",
2719 _ => panic!("invalid action index"),
2720 }
2721 }
2722
2723 fn get_description(&self, index: i32) -> &str {
2724 match index {
2725 0 => "Eylemi tetikler",
2726 1 => "Odağı ayarlar",
2727 _ => panic!("invalid action index"),
2728 }
2729 }
2730
2731 fn get_key_binding(&self, _index: i32) -> &str {
2732 ""
2733 }
2734 }
2735
2736 #[tokio::test]
2737 async fn editable_text_true_without_publication_is_mismatch_and_mutates_once() {
2738 let (_bus, peer, client, state) = start_editable_fixture(
2739 "original",
2740 FakeEditableConfig {
2741 mutation: FakeMutation::NoOp,
2742 ..Default::default()
2743 },
2744 )
2745 .await;
2746
2747 let outcome = editable_outcome(
2748 dispatch_editable_fixture(&client, &peer, "modified")
2749 .await
2750 .unwrap(),
2751 );
2752
2753 assert!(
2756 matches!(
2757 outcome,
2758 EditableTextSetOutcome::MismatchAfterDeadline
2759 | EditableTextSetOutcome::ReadbackTimedOut
2760 ),
2761 "{outcome:?}"
2762 );
2763 let state = state.lock().unwrap();
2764 assert_eq!(state.mutations, 1);
2765 assert!(state.character_count_reads > 1);
2766 assert!(state.get_text_reads > 1);
2767 assert_eq!(state.contents, "original");
2768 }
2769
2770 #[tokio::test]
2771 async fn editable_text_exact_readback_accepts_empty_whitespace_and_trailing_newline() {
2772 let (_bus, peer, client, state) =
2773 start_editable_fixture("initial", FakeEditableConfig::default()).await;
2774 let values = [
2775 "",
2776 " \t\n",
2777 " leading and trailing\t \n",
2778 "trailing newline\n",
2779 "Case and unicode € 内容 😀🦀𐐷",
2780 ];
2781
2782 for value in values {
2783 let outcome = editable_outcome(
2784 dispatch_editable_fixture(&client, &peer, value)
2785 .await
2786 .unwrap(),
2787 );
2788 assert_eq!(outcome, EditableTextSetOutcome::Verified);
2789 assert_eq!(state.lock().unwrap().contents, value);
2790 }
2791 assert_eq!(state.lock().unwrap().mutations, values.len());
2792 }
2793
2794 #[tokio::test]
2795 async fn editable_text_non_bmp_readback_supports_scalar_and_utf16_peers() {
2796 let requested = "🦀 GTK verified ";
2797 for text_offset_model in [
2798 FakeTextOffsetModel::UnicodeScalars,
2799 FakeTextOffsetModel::Utf16CodeUnits,
2800 ] {
2801 let (_bus, peer, client, state) = start_editable_fixture(
2802 "",
2803 FakeEditableConfig {
2804 text_offset_model,
2805 ..Default::default()
2806 },
2807 )
2808 .await;
2809
2810 let outcome = editable_outcome(
2811 dispatch_editable_fixture(&client, &peer, requested)
2812 .await
2813 .unwrap(),
2814 );
2815
2816 assert_eq!(
2817 outcome,
2818 EditableTextSetOutcome::Verified,
2819 "{text_offset_model:?}"
2820 );
2821 let state = state.lock().unwrap();
2822 assert_eq!(state.mutations, 1);
2823 assert_eq!(state.contents, requested);
2824 }
2825 }
2826
2827 #[tokio::test]
2828 async fn editable_text_non_bmp_matching_count_does_not_accept_different_content() {
2829 let requested = "🦀 GTK verified ";
2830 let published = "🦀 GTK verified!";
2831 assert_eq!(requested.chars().count(), published.chars().count());
2832 assert_eq!(
2833 requested.encode_utf16().count(),
2834 published.encode_utf16().count()
2835 );
2836 for text_offset_model in [
2837 FakeTextOffsetModel::UnicodeScalars,
2838 FakeTextOffsetModel::Utf16CodeUnits,
2839 ] {
2840 let (_bus, peer, client, state) = start_editable_fixture(
2841 published,
2842 FakeEditableConfig {
2843 mutation: FakeMutation::NoOp,
2844 text_offset_model,
2845 ..Default::default()
2846 },
2847 )
2848 .await;
2849
2850 let outcome = editable_outcome(
2851 dispatch_editable_fixture(&client, &peer, requested)
2852 .await
2853 .unwrap(),
2854 );
2855
2856 assert!(
2859 matches!(
2860 outcome,
2861 EditableTextSetOutcome::MismatchAfterDeadline
2862 | EditableTextSetOutcome::ReadbackTimedOut
2863 ),
2864 "{text_offset_model:?}: {outcome:?}"
2865 );
2866 let state = state.lock().unwrap();
2867 assert_eq!(state.mutations, 1);
2868 assert!(state.get_text_reads > 0, "{text_offset_model:?}");
2869 assert_eq!(state.contents, published);
2870 }
2871 }
2872
2873 #[tokio::test]
2874 async fn editable_text_readback_chunks_utf16_without_splitting_non_bmp() {
2875 let (_bus, peer, client, state) = start_editable_fixture(
2876 "",
2877 FakeEditableConfig {
2878 text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
2879 ..Default::default()
2880 },
2881 )
2882 .await;
2883 let mut expected = "a".repeat(EDITABLE_TEXT_READ_CHUNK_UNITS - 1);
2884 expected.push_str("🦀é");
2886 expected.push_str(&"内容\n".repeat(1_200));
2887 assert!(expected.encode_utf16().count() > MAX_TEXT_READBACK_CHARS as usize);
2888
2889 let outcome = editable_outcome(
2890 dispatch_editable_fixture(&client, &peer, &expected)
2891 .await
2892 .unwrap(),
2893 );
2894
2895 assert_eq!(outcome, EditableTextSetOutcome::Verified);
2896 let state = state.lock().unwrap();
2897 assert_eq!(state.mutations, 1);
2898 assert!(state.get_text_reads >= 2);
2899 assert_eq!(state.contents, expected);
2900 }
2901
2902 #[tokio::test]
2903 async fn editable_text_readback_waits_for_async_publication() {
2904 let (_bus, peer, client, state) = start_editable_fixture(
2905 "pending",
2906 FakeEditableConfig {
2907 mutation: FakeMutation::Delayed,
2908 ..Default::default()
2909 },
2910 )
2911 .await;
2912
2913 let outcome = editable_outcome(
2914 dispatch_editable_fixture(&client, &peer, "published asynchronously")
2915 .await
2916 .unwrap(),
2917 );
2918
2919 assert_eq!(outcome, EditableTextSetOutcome::Verified);
2920 let state = state.lock().unwrap();
2921 assert_eq!(state.mutations, 1);
2922 assert!(state.character_count_reads > 1);
2923 assert_eq!(state.contents, "published asynchronously");
2924 }
2925
2926 #[tokio::test]
2927 async fn editable_text_normalization_mismatch_is_not_verified() {
2928 let (_bus, peer, client, state) = start_editable_fixture(
2929 "",
2930 FakeEditableConfig {
2931 mutation: FakeMutation::Normalize,
2932 ..Default::default()
2933 },
2934 )
2935 .await;
2936
2937 let outcome = editable_outcome(
2938 dispatch_editable_fixture(&client, &peer, "first\r\nsecond\n")
2939 .await
2940 .unwrap(),
2941 );
2942
2943 assert!(
2946 matches!(
2947 outcome,
2948 EditableTextSetOutcome::MismatchAfterDeadline
2949 | EditableTextSetOutcome::ReadbackTimedOut
2950 ),
2951 "{outcome:?}"
2952 );
2953 let state = state.lock().unwrap();
2954 assert_eq!(state.mutations, 1);
2955 assert!(state.character_count_reads > 1);
2956 assert_eq!(state.contents, "first\nsecond\n");
2957 }
2958
2959 #[tokio::test]
2960 async fn password_text_is_unverifiable_even_when_requested_text_is_bullets() {
2961 let (_bus, peer, client, state) = start_editable_fixture(
2962 "",
2963 FakeEditableConfig {
2964 mutation: FakeMutation::Mask,
2965 role: atspi::Role::PasswordText,
2966 ..Default::default()
2967 },
2968 )
2969 .await;
2970
2971 let outcome = editable_outcome(
2972 dispatch_editable_fixture(&client, &peer, "••••••••")
2973 .await
2974 .unwrap(),
2975 );
2976
2977 assert_eq!(outcome, EditableTextSetOutcome::PasswordUnverifiable);
2978 let state = state.lock().unwrap();
2979 assert_eq!(state.mutations, 1);
2980 assert_eq!(state.character_count_reads, 0);
2981 assert_eq!(state.contents, "••••••••");
2982 }
2983
2984 #[tokio::test]
2985 async fn editable_text_missing_text_and_read_errors_are_structured_outcomes() {
2986 let configs = [
2987 FakeEditableConfig {
2988 has_text: false,
2989 ..Default::default()
2990 },
2991 FakeEditableConfig {
2992 text: FakeTextBehavior::CharacterCountError,
2993 ..Default::default()
2994 },
2995 FakeEditableConfig {
2996 text: FakeTextBehavior::GetTextError,
2997 ..Default::default()
2998 },
2999 ];
3000
3001 for config in configs {
3002 let (_bus, peer, client, state) = start_editable_fixture("before", config).await;
3003 let outcome = editable_outcome(
3004 dispatch_editable_fixture(&client, &peer, "readback may have changed")
3005 .await
3006 .unwrap(),
3007 );
3008 assert_eq!(outcome, EditableTextSetOutcome::ReadbackFailed);
3009 assert_eq!(state.lock().unwrap().mutations, 1);
3010 }
3011 }
3012
3013 #[tokio::test]
3014 async fn editable_text_negative_counts_and_stalled_readback_are_structured_outcomes() {
3015 let cases = [
3016 (
3017 FakeTextBehavior::NegativeCount,
3018 EditableTextSetOutcome::ReadbackFailed,
3019 ),
3020 (
3021 FakeTextBehavior::StallCharacterCount,
3022 EditableTextSetOutcome::ReadbackTimedOut,
3023 ),
3024 ];
3025
3026 for (text, expected) in cases {
3027 let (_bus, peer, client, state) = start_editable_fixture(
3028 "before",
3029 FakeEditableConfig {
3030 text,
3031 ..Default::default()
3032 },
3033 )
3034 .await;
3035 let outcome = editable_outcome(
3036 dispatch_editable_fixture(&client, &peer, "after")
3037 .await
3038 .unwrap(),
3039 );
3040 assert_eq!(outcome, expected);
3041 let state = state.lock().unwrap();
3042 assert_eq!(state.mutations, 1);
3043 assert_eq!(state.get_text_reads, 0);
3044 }
3045 }
3046
3047 #[tokio::test]
3048 async fn editable_text_rejection_and_mutation_errors_remain_errors() {
3049 let cases = [
3050 (FakeMutation::Reject, "rejected"),
3051 (FakeMutation::Fail, "failed to set"),
3052 ];
3053
3054 for (mutation, expected_error) in cases {
3055 let (_bus, peer, client, state) = start_editable_fixture(
3056 "before",
3057 FakeEditableConfig {
3058 mutation,
3059 ..Default::default()
3060 },
3061 )
3062 .await;
3063 let error = dispatch_editable_fixture(&client, &peer, "after")
3064 .await
3065 .unwrap_err();
3066 assert!(error.to_string().contains(expected_error));
3067 let state = state.lock().unwrap();
3068 assert_eq!(state.mutations, 1);
3069 assert_eq!(state.character_count_reads, 0);
3070 }
3071 }
3072
3073 #[tokio::test]
3074 async fn numeric_value_dispatch_remains_isolated_from_editable_text() {
3075 let (_bus, peer, client, state) = start_editable_fixture(
3076 "unchanged",
3077 FakeEditableConfig {
3078 has_value: true,
3079 ..Default::default()
3080 },
3081 )
3082 .await;
3083
3084 let invocation = dispatch_editable_fixture(&client, &peer, "42.5")
3085 .await
3086 .unwrap();
3087
3088 match invocation {
3089 ValueSetInvocation::Numeric { value } => assert_eq!(value, 42.5),
3090 ValueSetInvocation::EditableText { .. } => panic!("expected numeric dispatch"),
3091 }
3092 let state = state.lock().unwrap();
3093 assert_eq!(state.numeric_value, 42.5);
3094 assert_eq!(state.numeric_writes, 1);
3095 assert_eq!(state.mutations, 0);
3096 assert_eq!(state.character_count_reads, 0);
3097 assert_eq!(state.contents, "unchanged");
3098 }
3099
3100 #[tokio::test]
3101 async fn qt_action_names_do_not_depend_on_localized_names() {
3102 let bus = PrivateBus::start("cul-atspi-bus");
3103 let address = bus.address();
3104 let peer = zbus::connection::Builder::address(address)
3105 .unwrap()
3106 .serve_at("/actions/qt", FakeQtAction)
3107 .unwrap()
3108 .build()
3109 .await
3110 .unwrap();
3111 let client = zbus::connection::Builder::address(address)
3112 .unwrap()
3113 .build()
3114 .await
3115 .unwrap();
3116 let action = ActionProxy::builder(&client)
3117 .destination(peer.unique_name().unwrap().clone())
3118 .unwrap()
3119 .path("/actions/qt")
3120 .unwrap()
3121 .cache_properties(zbus::proxy::CacheProperties::No)
3122 .build()
3123 .await
3124 .unwrap();
3125
3126 let actions = timeout(Duration::from_secs(1), read_actions(&action))
3127 .await
3128 .expect("Qt action reads must not require GetLocalizedName")
3129 .unwrap();
3130
3131 assert_eq!(actions.len(), 2);
3132 assert_eq!(actions[0].name, "Press");
3133 assert_eq!(actions[0].description, "Eylemi tetikler");
3134 assert_eq!(actions[1].name, "Set Focus");
3135 assert_eq!(actions[1].description, "Odağı ayarlar");
3136 assert_eq!(select_named_action_index(&actions, "press").unwrap(), 0);
3137 }
3138
3139 #[tokio::test]
3140 async fn pid_roots_keep_all_toolkits_and_a_missing_pid_stays_empty() {
3141 let bus = PrivateBus::start("cul-atspi-roots");
3142 let root_path = "/org/a11y/atspi/accessible/root";
3143 let mut peers = Vec::new();
3144 for child_count in [0, 1] {
3145 let children = (0..child_count)
3146 .map(|_| (String::new(), "/child".try_into().unwrap()))
3147 .collect();
3148 peers.push(
3149 zbus::connection::Builder::address(bus.address())
3150 .unwrap()
3151 .serve_at(
3152 root_path,
3153 FakeAccessible {
3154 name: "qt-app".into(),
3155 children,
3156 has_action: false,
3157 },
3158 )
3159 .unwrap()
3160 .build()
3161 .await
3162 .unwrap(),
3163 );
3164 }
3165 let conn = AccessibilityConnection::from_address(bus.address().parse().unwrap())
3166 .await
3167 .unwrap();
3168 let roots = peers
3169 .iter()
3170 .map(|peer| {
3171 object_ref_from_id(&format!("{}{root_path}", peer.unique_name().unwrap())).unwrap()
3172 })
3173 .collect::<Vec<_>>();
3174 let pid = std::process::id();
3175 let mut budget = MAX_DISCOVERY_CHILD_READS;
3176 let selected = select_roots(&conn, roots.clone(), None, Some(pid), None, &mut budget).await;
3177 assert_eq!(selected.roots, roots);
3178 assert_eq!(selected.pid, Some(pid));
3179 assert!(selected.scoped);
3180 assert!(selected.app_filter_matched);
3181
3182 let explicit = object_ref_id(&roots[1]);
3183 let selected = select_roots(
3184 &conn,
3185 roots.clone(),
3186 Some(&explicit),
3187 Some(pid),
3188 None,
3189 &mut budget,
3190 )
3191 .await;
3192 assert_eq!(selected.roots, vec![roots[1].clone()]);
3193 assert_eq!(selected.pid, Some(pid));
3194 assert!(selected.app_filter_matched);
3195
3196 let selected = select_roots(
3197 &conn,
3198 roots.clone(),
3199 None,
3200 Some(u32::MAX),
3201 None,
3202 &mut budget,
3203 )
3204 .await;
3205 assert!(selected.roots.is_empty());
3206 assert_eq!(selected.pid, None);
3207 assert!(selected.scoped);
3208 assert!(!selected.app_filter_matched);
3209
3210 let selected = select_roots(
3213 &conn,
3214 roots.clone(),
3215 None,
3216 Some(u32::MAX),
3217 Some("qt-app"),
3218 &mut budget,
3219 )
3220 .await;
3221 assert_eq!(selected.roots, roots);
3222 assert_eq!(selected.pid, None);
3223 assert!(selected.scoped);
3224 assert!(selected.app_filter_matched);
3225
3226 let selected = select_roots(
3228 &conn,
3229 roots.clone(),
3230 None,
3231 Some(pid),
3232 Some("no such window"),
3233 &mut budget,
3234 )
3235 .await;
3236 assert_eq!(selected.roots, roots);
3237 assert_eq!(selected.pid, Some(pid));
3238
3239 let selected = select_roots(
3240 &conn,
3241 roots,
3242 None,
3243 Some(u32::MAX),
3244 Some("no such window"),
3245 &mut budget,
3246 )
3247 .await;
3248 assert!(selected.roots.is_empty());
3249 assert!(!selected.app_filter_matched);
3250 }
3251
3252 #[tokio::test]
3253 async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
3254 let bus = PrivateBus::start("cul-atspi-bus");
3255 let address = bus.address();
3256
3257 let webkit_name = "org.webkit.MockWebProcess";
3258 let child_path = "/org/a11y/webkit/accessible/child";
3259 let webkit = zbus::connection::Builder::address(address)
3260 .unwrap()
3261 .name(webkit_name)
3262 .unwrap()
3263 .serve_at(
3264 child_path,
3265 FakeAccessible {
3266 name: "WebKit page".into(),
3267 children: Vec::new(),
3268 has_action: true,
3269 },
3270 )
3271 .unwrap()
3272 .serve_at(child_path, FakeWebKitAction::default())
3273 .unwrap()
3274 .build()
3275 .await
3276 .unwrap();
3277 let owner = webkit.unique_name().unwrap().to_string();
3278 let gtk = zbus::connection::Builder::address(address)
3279 .unwrap()
3280 .serve_at(
3281 "/org/a11y/atspi/accessible/root",
3282 FakeAccessible {
3283 name: "GTK socket".into(),
3284 has_action: false,
3285 children: vec![
3286 (webkit_name.into(), child_path.try_into().unwrap()),
3287 (owner.clone(), child_path.try_into().unwrap()),
3288 (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
3289 (
3290 "org.webkit.UnownedWebProcess".into(),
3291 child_path.try_into().unwrap(),
3292 ),
3293 ],
3294 },
3295 )
3296 .unwrap()
3297 .build()
3298 .await
3299 .unwrap();
3300 let client = zbus::connection::Builder::address(address)
3301 .unwrap()
3302 .build()
3303 .await
3304 .unwrap();
3305 let proxy = AccessibleProxy::builder(&client)
3306 .destination(gtk.unique_name().unwrap().clone())
3307 .unwrap()
3308 .path("/org/a11y/atspi/accessible/root")
3309 .unwrap()
3310 .build()
3311 .await
3312 .unwrap();
3313 let mut remaining = 4;
3314 let reply = proxy
3315 .inner()
3316 .call_method("GetChildAtIndex", &(0_i32,))
3317 .await
3318 .unwrap();
3319 assert_eq!(reply.body().signature().to_string(), "(so)");
3320 let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
3321
3322 assert_eq!(batch.attempted, 4);
3323 assert_eq!(remaining, 0);
3324 assert!(!batch.incomplete);
3325 assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
3326 assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
3327 assert_eq!(batch.items[0].path_as_str(), child_path);
3328 assert_eq!(batch.items[0], batch.items[1]);
3329 assert!(batch.items[2].is_null());
3330
3331 let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
3333 assert_eq!(child.name().await.unwrap(), "WebKit page");
3334 assert_eq!(
3335 object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
3336 batch.items[0]
3337 );
3338 let action = child.proxies().await.unwrap().action().await.unwrap();
3339 let actions = timeout(Duration::from_secs(1), read_actions(&action))
3340 .await
3341 .expect("indexed action reads must not wait for GetActions")
3342 .unwrap();
3343 assert_eq!(actions.len(), 1);
3344 assert_eq!(actions[0].name, "click");
3345 assert_eq!(actions[0].description, "Activate the control");
3346 assert_eq!(actions[0].keybinding, "Return");
3347 assert_eq!(select_named_action_index(&actions, "click").unwrap(), 0);
3348 assert!(action.do_action(0).await.unwrap());
3349 }
3350
3351 #[tokio::test]
3352 async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
3353 let bus = PrivateBus::start("cul-atspi-bus");
3354 let address = bus.address();
3355 let name_reads = Arc::new(AtomicUsize::new(0));
3356 let counts = [
3357 -1,
3358 MAX_ACTIONS_PER_OBJECT + 1,
3359 i32::MAX,
3360 0,
3361 MAX_ACTIONS_PER_OBJECT,
3362 ];
3363 let mut builder = zbus::connection::Builder::address(address).unwrap();
3364 for (index, count) in counts.iter().enumerate() {
3365 builder = builder
3366 .serve_at(
3367 format!("/actions/{index}"),
3368 FakeWebKitAction {
3369 count: *count,
3370 name_reads: Arc::clone(&name_reads),
3371 ..Default::default()
3372 },
3373 )
3374 .unwrap();
3375 }
3376 let peer = builder.build().await.unwrap();
3377 let client = zbus::connection::Builder::address(address)
3378 .unwrap()
3379 .build()
3380 .await
3381 .unwrap();
3382
3383 for (index, count) in counts.iter().enumerate() {
3384 let action = ActionProxy::builder(&client)
3385 .destination(peer.unique_name().unwrap().clone())
3386 .unwrap()
3387 .path(format!("/actions/{index}"))
3388 .unwrap()
3389 .cache_properties(zbus::proxy::CacheProperties::No)
3390 .build()
3391 .await
3392 .unwrap();
3393 let result = timeout(Duration::from_secs(1), read_actions(&action))
3394 .await
3395 .expect("untrusted counts must not cause unbounded work");
3396 if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
3397 assert_eq!(result.unwrap().len(), *count as usize);
3398 } else {
3399 let error = result.unwrap_err().to_string();
3400 assert!(error.contains("action count"), "{error}");
3401 assert_eq!(name_reads.load(Ordering::SeqCst), 0);
3402 }
3403 }
3404 assert_eq!(
3405 name_reads.load(Ordering::SeqCst),
3406 MAX_ACTIONS_PER_OBJECT as usize
3407 );
3408 }
3409
3410 #[tokio::test]
3411 async fn indexed_actions_time_out_the_entire_read() {
3412 let bus = PrivateBus::start("cul-atspi-bus");
3413 let address = bus.address();
3414 let name_reads = Arc::new(AtomicUsize::new(0));
3415 let peer = zbus::connection::Builder::address(address)
3416 .unwrap()
3417 .serve_at(
3418 "/actions/stalled",
3419 FakeWebKitAction {
3420 stall_indexed_reads: true,
3421 name_reads: Arc::clone(&name_reads),
3422 ..Default::default()
3423 },
3424 )
3425 .unwrap()
3426 .build()
3427 .await
3428 .unwrap();
3429 let client = zbus::connection::Builder::address(address)
3430 .unwrap()
3431 .build()
3432 .await
3433 .unwrap();
3434 let action = ActionProxy::builder(&client)
3435 .destination(peer.unique_name().unwrap().clone())
3436 .unwrap()
3437 .path("/actions/stalled")
3438 .unwrap()
3439 .cache_properties(zbus::proxy::CacheProperties::No)
3440 .build()
3441 .await
3442 .unwrap();
3443
3444 let error = timeout(
3445 ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
3446 read_actions(&action),
3447 )
3448 .await
3449 .expect("indexed reads must have an overall deadline")
3450 .unwrap_err();
3451 assert!(error.to_string().contains("5-second deadline"));
3452 assert_eq!(name_reads.load(Ordering::SeqCst), 1);
3453 }
3454
3455 fn app_children(children: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
3456 children
3457 .iter()
3458 .map(|(role, name)| (role.to_string(), Some(name.to_string())))
3459 .collect()
3460 }
3461
3462 #[test]
3463 fn a_window_title_picks_one_of_several_app_windows() {
3464 let chromium = app_children(&[
3465 ("frame", "GitHub - Chromium"),
3466 ("frame", "PlanetScale - Chromium"),
3467 ("menu", ""),
3468 ]);
3469 assert_eq!(
3470 matching_window(&chromium, "PlanetScale - Chromium"),
3471 Some(1)
3472 );
3473 assert_eq!(
3474 matching_window(&chromium, "planetscale - chromium"),
3475 Some(1)
3476 );
3477 assert_eq!(matching_window(&chromium, "PlanetScale"), Some(1));
3479 assert_eq!(matching_window(&chromium, "Chromium"), None);
3481 assert_eq!(matching_window(&chromium, "Gmail"), None);
3482 }
3483
3484 #[test]
3485 fn a_decorated_compositor_title_picks_the_frame_it_decorates() {
3486 let chromium = app_children(&[
3487 ("frame", "GitHub - Chromium"),
3488 ("frame", "PlanetScale - Chromium"),
3489 ]);
3490 assert_eq!(
3491 matching_window(&chromium, "PlanetScale - Chromium (Profile 1)"),
3492 Some(1)
3493 );
3494 let nested = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
3496 assert_eq!(
3497 matching_window(&nested, "Docs - Settings (Profile 1)"),
3498 Some(1)
3499 );
3500 let generic = app_children(&[("frame", "Settings"), ("frame", "Mail")]);
3502 assert_eq!(
3503 matching_window(&generic, "Docs - Settings (Profile 1)"),
3504 None
3505 );
3506 assert_eq!(matching_window(&generic, "Mailbox"), None);
3507 }
3508
3509 #[test]
3510 fn an_exact_window_name_wins_over_a_substring_match() {
3511 let windows = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
3512 assert_eq!(matching_window(&windows, "Docs"), Some(0));
3513 }
3514
3515 #[test]
3516 fn narrowing_keeps_popups_and_dialogs_beside_the_window() {
3517 let chromium = app_children(&[
3518 ("frame", "GitHub - Chromium"),
3519 ("frame", "PlanetScale - Chromium"),
3520 ("window", ""),
3521 ("dialog", "Open File"),
3522 ("menu", ""),
3523 ]);
3524 let chosen = matching_window(&chromium, "PlanetScale").unwrap();
3525 assert_eq!(
3526 kept_beside_window(&chromium, chosen),
3527 [false, true, true, true, true]
3528 );
3529 }
3530
3531 #[test]
3532 fn one_window_is_never_narrowed() {
3533 let single = app_children(&[("frame", "PlanetScale - Chromium"), ("menu", "")]);
3534 assert_eq!(matching_window(&single, "PlanetScale"), None);
3535 }
3536
3537 #[test]
3538 fn split_object_ref_id_separates_bus_name_and_path() {
3539 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
3540
3541 assert_eq!(name, ":1.42");
3542 assert_eq!(path, "/org/a11y/atspi/accessible/7");
3543 }
3544
3545 #[test]
3546 fn select_action_index_uses_named_action() {
3547 let actions = vec![
3548 atspi::Action {
3549 name: "click".to_string(),
3550 description: "Clicks".to_string(),
3551 keybinding: String::new(),
3552 },
3553 atspi::Action {
3554 name: "show-menu".to_string(),
3555 description: "Shows menu".to_string(),
3556 keybinding: String::new(),
3557 },
3558 ];
3559
3560 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
3561 }
3562
3563 #[test]
3564 fn cached_action_name_is_revalidated_against_current_actions() {
3565 let mut actions = vec![atspi::Action {
3566 name: "show-menu".into(),
3567 description: "Click".into(),
3568 keybinding: String::new(),
3569 }];
3570 assert!(select_named_action_index(&actions, "Click").is_err());
3572 actions.push(atspi::Action {
3573 name: "click".into(),
3574 description: String::new(),
3575 keybinding: String::new(),
3576 });
3577 assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
3578 }
3579
3580 #[test]
3581 fn select_action_index_defaults_to_secondary_when_available() {
3582 let actions = vec![
3583 atspi::Action {
3584 name: "click".to_string(),
3585 description: String::new(),
3586 keybinding: String::new(),
3587 },
3588 atspi::Action {
3589 name: "show-menu".to_string(),
3590 description: String::new(),
3591 keybinding: String::new(),
3592 },
3593 ];
3594
3595 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
3596 }
3597
3598 #[test]
3599 fn state_labels_serialize_in_bit_order() {
3600 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
3601
3602 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
3603 }
3604
3605 #[test]
3606 fn default_snapshot_limits_cover_deep_gtk4_trees() {
3607 assert_eq!(snapshot_limits(None, None), (1_000, 32));
3610 }
3611
3612 #[test]
3613 fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
3614 assert_eq!(app_name_needle(None), None);
3615 assert_eq!(app_name_needle(Some("")), None);
3616 assert_eq!(app_name_needle(Some(" ")), None);
3617 assert_eq!(
3618 app_name_needle(Some("Calculator")).as_deref(),
3619 Some("calculator")
3620 );
3621 assert_eq!(
3622 app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
3623 Some(":1.64/org/a11y/atspi/accessible/root")
3624 );
3625 }
3626
3627 #[test]
3628 fn requested_snapshot_limits_remain_bounded() {
3629 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
3630 assert_eq!(snapshot_limits(Some(100_000), Some(128)), (10_000, 64));
3631 }
3632
3633 #[test]
3634 fn app_discovery_budget_is_independent_of_requested_tree_size() {
3635 assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
3636 }
3637
3638 #[test]
3639 fn traversal_attempts_and_queue_share_one_work_budget() {
3640 let mut traversal = BoundedTraversal::new(4);
3641 traversal.enqueue([1]);
3642
3643 assert_eq!(traversal.pop(), Some(1));
3644 traversal.enqueue(2..=10_000);
3645 assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
3646
3647 assert_eq!(traversal.pop(), Some(2));
3648 traversal.enqueue(5..=10_000);
3649 assert_eq!(traversal.queue, VecDeque::from([3, 4]));
3650 assert_eq!(traversal.pop(), Some(3));
3651 assert_eq!(traversal.pop(), Some(4));
3652 assert_eq!(traversal.pop(), None);
3653 assert_eq!(traversal.attempted, 4);
3654 assert!(
3655 traversal.truncated(),
3656 "dropped enqueue items must be reported"
3657 );
3658 }
3659
3660 #[test]
3661 fn traversal_that_drains_within_budget_is_not_truncated() {
3662 let mut traversal = BoundedTraversal::new(3);
3663 traversal.enqueue([1, 2, 3]);
3664 assert_eq!(traversal.pop(), Some(1));
3665 assert_eq!(traversal.pop(), Some(2));
3666 assert_eq!(traversal.pop(), Some(3));
3667 assert_eq!(traversal.pop(), None);
3668 assert!(
3669 !traversal.truncated(),
3670 "exactly max_items real nodes is complete"
3671 );
3672 }
3673
3674 #[test]
3675 fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
3676 let mut traversal = BoundedTraversal::new(2);
3677 traversal.enqueue([1, 2]);
3678 assert_eq!(traversal.pop(), Some(1));
3679 assert_eq!(traversal.pop(), Some(2));
3680 assert!(!traversal.truncated(), "nothing dropped yet");
3681 traversal.enqueue([3]);
3684 assert!(traversal.queue.is_empty());
3685 assert_eq!(traversal.pop(), None);
3686 assert!(traversal.truncated());
3687 }
3688
3689 #[test]
3690 fn child_count_is_clamped_before_indexed_reads() {
3691 assert_eq!(bounded_child_count(-1, 4), 0);
3692 assert_eq!(bounded_child_count(3, 4), 3);
3693 assert_eq!(bounded_child_count(i32::MAX, 4), 4);
3694 }
3695
3696 #[tokio::test]
3697 async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
3698 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3699 let mut remaining_attempts = 3;
3700 let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
3701 let calls = calls.clone();
3702 move |index| {
3703 let calls = calls.clone();
3704 async move {
3705 calls.lock().unwrap().push(index);
3706 if index == 1 {
3707 Err(())
3708 } else {
3709 Ok(index)
3710 }
3711 }
3712 }
3713 })
3714 .await;
3715
3716 assert_eq!(batch.items, vec![0, 2]);
3717 assert_eq!(batch.attempted, 3);
3718 assert!(
3719 batch.incomplete,
3720 "100 reported children, 3 attempted: budget cut must be reported"
3721 );
3722 assert!(!batch.all_failed());
3723 assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
3724 assert_eq!(remaining_attempts, 0);
3725
3726 let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
3727 Ok::<_, ()>(99)
3728 })
3729 .await;
3730 assert!(no_children.items.is_empty());
3731 assert_eq!(no_children.attempted, 0);
3732 assert!(!no_children.all_failed());
3733
3734 let mut failed_attempts = 2;
3735 let all_failed =
3736 fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
3737 assert!(all_failed.all_failed());
3738 }
3739
3740 fn document() -> FocusedElementSummary {
3741 FocusedElementSummary {
3742 role: "document web".into(),
3743 name: Some("Synthetic editor host".into()),
3744 editable: false,
3745 states: vec!["focused".into()],
3746 is_terminal: false,
3747 }
3748 }
3749
3750 #[test]
3751 fn focused_document_does_not_end_the_search_before_contenteditable() {
3752 assert!(focus_is_container(&document()));
3753 let mut entry = document();
3754 entry.role = "entry".into();
3755 entry.states.push("editable".into());
3756 assert!(!entry.editable);
3758 assert!(!focus_is_container(&entry));
3759 }
3760
3761 #[test]
3762 fn incomplete_descendants_do_not_make_the_document_a_definitive_recipient() {
3763 assert!(matches!(
3764 finish_focus_probe(true, false, Some(document())),
3765 FocusProbe::Incomplete
3766 ));
3767 assert!(matches!(
3768 finish_focus_probe(false, false, Some(document())),
3769 FocusProbe::Found(_)
3770 ));
3771 }
3772
3773 #[test]
3774 fn opt_in_snapshot_budget_reaches_controls_after_embedded_browser_content() {
3775 let (limit, _) = snapshot_limits(Some(4_000), None);
3776 assert_eq!(limit, 4_000);
3777 let mut traversal = BoundedTraversal::new(limit);
3778 traversal.enqueue(0..3_000);
3779 let mut reached_editor = false;
3780 while let Some(node) = traversal.pop() {
3781 reached_editor |= node == 2_500;
3782 }
3783 assert!(reached_editor);
3784 assert!(!traversal.truncated());
3785 let (default, _) = snapshot_limits(None, None);
3786 assert_eq!(default, 1_000);
3787 }
3788
3789 #[test]
3790 fn narrowing_keeps_the_spent_budget_and_forgets_dropped_work() {
3791 let mut traversal = BoundedTraversal::new(4);
3792 traversal.enqueue(0..6);
3793 assert!(traversal.truncated());
3794 assert_eq!(traversal.pop(), Some(0));
3795 traversal.narrow();
3796 assert!(
3797 !traversal.truncated(),
3798 "work dropped outside the subtree no longer counts"
3799 );
3800 assert_eq!(traversal.pop(), None);
3801 traversal.enqueue([10, 11, 12, 13]);
3802 assert_eq!(traversal.pop(), Some(10));
3803 assert_eq!(traversal.pop(), Some(11));
3804 assert_eq!(traversal.pop(), Some(12));
3805 assert_eq!(
3806 traversal.pop(),
3807 None,
3808 "the budget spent before narrowing still counts"
3809 );
3810 assert!(traversal.truncated());
3811 }
3812
3813 #[tokio::test]
3814 async fn text_snapshot_reads_selections_through_get_n_selections() {
3815 let running = start_field_fixture(FieldFixture {
3816 contents: "/tmp/old",
3817 caret: 8,
3818 selection: Some((5, 8)),
3819 ..Default::default()
3820 })
3821 .await;
3822 let proxy = open_accessible(&running.conn, &running.fields[0].0)
3823 .await
3824 .unwrap();
3825 let proxies = proxy.proxies().await.unwrap();
3826 let text = text_from_proxies(Some(&proxies)).await.unwrap();
3827 assert_eq!(text.content.as_deref(), Some("/tmp/old"));
3828 assert_eq!(text.selections.len(), 1);
3829 assert_eq!(
3830 (
3831 text.selections[0].start_offset,
3832 text.selections[0].end_offset
3833 ),
3834 (5, 8)
3835 );
3836 }
3837
3838 fn verified(replaced_selection: bool) -> FocusedTextInsert {
3839 FocusedTextInsert::Verified {
3840 role: "text".to_string(),
3841 replaced_selection,
3842 caret_after_text: true,
3843 }
3844 }
3845
3846 #[tokio::test]
3847 async fn focused_field_insert_lands_at_the_caret_and_reads_back() {
3848 let (outcome, state) = insert_into_fixture(
3849 FieldFixture {
3850 contents: "/home/",
3851 caret: 6,
3852 ..Default::default()
3853 },
3854 "x/doc",
3855 )
3856 .await;
3857 assert_eq!(outcome.unwrap(), verified(false));
3858 assert_eq!(state.contents, "/home/x/doc");
3859 assert_eq!(state.calls, ["insert", "caret"]);
3860 assert_eq!(state.caret, 11, "the caret ends after the text");
3861
3862 let (outcome, state) = insert_into_fixture(
3863 FieldFixture {
3864 contents: "ab",
3865 caret: 1,
3866 ..Default::default()
3867 },
3868 "שלום",
3869 )
3870 .await;
3871 assert_eq!(outcome.unwrap(), verified(false));
3872 assert_eq!(state.contents, "aשלוםb");
3873 assert_eq!(state.caret, 5);
3874 assert_eq!(state.calls, ["insert", "caret"]);
3875
3876 let (outcome, state) = insert_into_fixture(
3878 FieldFixture {
3879 contents: "\u{fffc}\u{fffc}",
3880 caret: 1,
3881 ..Default::default()
3882 },
3883 "שלום",
3884 )
3885 .await;
3886 assert_eq!(outcome.unwrap(), verified(false));
3887 assert_eq!(state.contents, "\u{fffc}שלום\u{fffc}");
3888 assert_eq!(state.calls, ["insert", "caret"]);
3889 }
3890
3891 #[tokio::test]
3892 async fn focused_field_insert_is_exact_under_qt_length_rules() {
3893 for (text, selection, expected) in [
3894 ("x/doc", None, "/home/x/doc"),
3895 ("שלום", None, "/home/שלום"),
3896 ("שלום", Some((1, 5)), "/שלום/"),
3897 ] {
3898 let (outcome, state) = insert_into_fixture(
3899 FieldFixture {
3900 contents: "/home/",
3901 caret: 6,
3902 selection,
3903 edit: FieldEdit::QtLength,
3904 toolkit: Some("Qt"),
3905 ..Default::default()
3906 },
3907 text,
3908 )
3909 .await;
3910 assert_eq!(outcome.unwrap(), verified(selection.is_some()), "{text}");
3911 assert_eq!(state.contents, expected);
3912 }
3913 }
3914
3915 #[tokio::test]
3916 async fn focused_field_insert_needs_the_toolkit_for_non_ascii_text() {
3917 let (outcome, state) = insert_into_fixture(
3918 FieldFixture {
3919 contents: "/home/",
3920 caret: 6,
3921 toolkit: None,
3922 ..Default::default()
3923 },
3924 "שלום",
3925 )
3926 .await;
3927 assert!(
3928 matches!(&outcome, Ok(FocusedTextInsert::NotEditable(reason)) if reason.contains("toolkit")),
3929 "{outcome:?}"
3930 );
3931 assert_eq!(state.contents, "/home/");
3932 assert!(state.calls.is_empty(), "{:?}", state.calls);
3933
3934 let (outcome, state) = insert_into_fixture(
3936 FieldFixture {
3937 contents: "/home/",
3938 caret: 6,
3939 toolkit: None,
3940 ..Default::default()
3941 },
3942 "x",
3943 )
3944 .await;
3945 assert_eq!(outcome.unwrap(), verified(false));
3946 assert_eq!(state.contents, "/home/x");
3947 }
3948
3949 #[tokio::test]
3950 async fn focused_field_insert_replaces_the_selection() {
3951 let (outcome, state) = insert_into_fixture(
3952 FieldFixture {
3953 contents: "/tmp/old/x",
3954 caret: 8,
3955 selection: Some((8, 5)),
3956 ..Default::default()
3957 },
3958 "new",
3959 )
3960 .await;
3961 assert_eq!(outcome.unwrap(), verified(true));
3962 assert_eq!(state.contents, "/tmp/new/x");
3963 assert_eq!(state.calls, ["delete", "insert", "caret"]);
3964
3965 for (contents, selection) in [("", None), ("/old/path", Some((0, 9)))] {
3967 let (outcome, state) = insert_into_fixture(
3968 FieldFixture {
3969 contents,
3970 caret: contents.chars().count() as i32,
3971 selection,
3972 ..Default::default()
3973 },
3974 "/home/x",
3975 )
3976 .await;
3977 assert_eq!(outcome.unwrap(), verified(selection.is_some()));
3978 assert_eq!(state.contents, "/home/x");
3979 assert_eq!(state.calls, ["set", "caret"]);
3980 assert_eq!(state.caret, 7);
3981 }
3982 }
3983
3984 #[tokio::test]
3985 async fn focused_field_insert_needs_a_focused_editable_field() {
3986 let unfocused = StateSet::new(atspi::State::Editable | atspi::State::Showing);
3987 let read_only = StateSet::new(atspi::State::Focused | atspi::State::Showing);
3988 for states in [unfocused, read_only] {
3989 let (outcome, state) = insert_into_fixture(
3990 FieldFixture {
3991 contents: "keep",
3992 caret: 4,
3993 states,
3994 ..Default::default()
3995 },
3996 "x",
3997 )
3998 .await;
3999 assert!(
4000 matches!(outcome.unwrap(), FocusedTextInsert::NotEditable(_)),
4001 "{states:?}"
4002 );
4003 assert_eq!(state.contents, "keep");
4004 assert!(state.calls.is_empty(), "{:?}", state.calls);
4005 }
4006 }
4007
4008 fn entry(contents: &'static str, focused: bool) -> FieldFixture {
4009 let mut states = StateSet::new(atspi::State::Editable | atspi::State::Showing);
4010 if focused {
4011 states.insert(atspi::State::Focused);
4012 }
4013 FieldFixture {
4014 contents,
4015 caret: contents.chars().count() as i32,
4016 states,
4017 ..Default::default()
4018 }
4019 }
4020
4021 #[tokio::test]
4025 async fn focused_field_insert_stays_in_the_target_window() {
4026 let stale_and_target = |target_focused, target_active| {
4027 vec![
4028 FixtureWindow {
4029 name: "Inbox",
4030 active: false,
4031 field: entry("stale", true),
4032 },
4033 FixtureWindow {
4034 name: "Compose",
4035 active: target_active,
4036 field: entry("to: ", target_focused),
4037 },
4038 ]
4039 };
4040 let running = start_windows_fixture(stale_and_target(true, true)).await;
4041 let outcome =
4042 insert_text_at_focus_on(&running.conn, std::process::id(), Some("Compose"), "x").await;
4043 assert_eq!(outcome.unwrap(), verified(false));
4044 assert_eq!(running.take_state(0).contents, "stale");
4045 assert_eq!(running.take_state(1).contents, "to: x");
4046
4047 for (target_focused, target_active, title, why) in [
4048 (false, true, Some("Compose"), "has keyboard focus"),
4049 (true, false, Some("Compose"), "not active"),
4050 (true, true, Some("Drafts"), "could not be matched"),
4051 (true, true, None, "could not be matched"),
4052 ] {
4053 let running =
4054 start_windows_fixture(stale_and_target(target_focused, target_active)).await;
4055 let outcome =
4056 insert_text_at_focus_on(&running.conn, std::process::id(), title, "x").await;
4057 assert!(
4058 matches!(&outcome, Ok(FocusedTextInsert::NotEditable(reason)) if reason.contains(why)),
4059 "{title:?} focused={target_focused} active={target_active}: {outcome:?}"
4060 );
4061 for index in 0..2 {
4062 let state = running.take_state(index);
4063 assert!(state.calls.is_empty(), "{:?}", state.calls);
4064 }
4065 }
4066 }
4067
4068 #[tokio::test]
4069 async fn focused_field_insert_checks_the_untouched_text() {
4070 let (outcome, state) = insert_into_fixture(
4073 FieldFixture {
4074 contents: "xyx",
4075 caret: 1,
4076 edit: FieldEdit::AppendAtEnd,
4077 ..Default::default()
4078 },
4079 "y",
4080 )
4081 .await;
4082 assert!(
4083 matches!(outcome, Ok(FocusedTextInsert::Mismatch { .. })),
4084 "{outcome:?}"
4085 );
4086 assert_eq!(state.contents, "xyxy");
4087 }
4088
4089 #[tokio::test]
4090 async fn focused_field_insert_bounds_each_edit_call() {
4091 let (outcome, state) = timeout(
4092 ACTION_QUERY_TIMEOUT + Duration::from_secs(3),
4093 insert_into_fixture(
4094 FieldFixture {
4095 contents: "/home/",
4096 caret: 6,
4097 edit: FieldEdit::StallInsert,
4098 ..Default::default()
4099 },
4100 "x",
4101 ),
4102 )
4103 .await
4104 .expect("a stalled InsertText must not hold the call");
4105 assert!(
4106 matches!(
4107 &outcome,
4108 Ok(FocusedTextInsert::EditFailed { reason, may_have_changed: true })
4109 if reason.contains("did not answer")
4110 ),
4111 "{outcome:?}"
4112 );
4113 assert_eq!(state.contents, "/home/");
4114 }
4115
4116 #[tokio::test]
4117 async fn focused_field_insert_skips_password_fields() {
4118 let (outcome, state) = insert_into_fixture(
4119 FieldFixture {
4120 role: atspi::Role::PasswordText,
4121 role_name: "password text",
4122 ..Default::default()
4123 },
4124 "hunter2",
4125 )
4126 .await;
4127 assert_eq!(outcome.unwrap(), FocusedTextInsert::PasswordField);
4128 assert_eq!(state.contents, "");
4129 assert!(state.calls.is_empty(), "{:?}", state.calls);
4130 }
4131
4132 #[tokio::test]
4133 async fn focused_field_insert_reports_a_readback_mismatch() {
4134 let (outcome, state) = insert_into_fixture(
4135 FieldFixture {
4136 contents: "/home/",
4137 caret: 6,
4138 edit: FieldEdit::DropLastChar,
4139 ..Default::default()
4140 },
4141 "xyz",
4142 )
4143 .await;
4144 assert_eq!(
4145 outcome.unwrap(),
4146 FocusedTextInsert::Mismatch {
4147 expected_count: 9,
4148 read_count: 8,
4149 read_text: "xy".to_string(),
4150 read_truncated: false,
4151 }
4152 );
4153 assert_eq!(state.contents, "/home/xy");
4154 assert!(!state.calls.contains(&"caret"), "{:?}", state.calls);
4155 }
4156}
4157
4158#[cfg(test)]
4161pub(crate) mod insert_fixture {
4162 use super::*;
4163 use crate::test_sockets::PrivateBus;
4164 use std::sync::{Arc, Mutex};
4165
4166 const FOCUS_ROOT_PATH: &str = "/org/a11y/atspi/accessible/root";
4167
4168 #[derive(Default)]
4169 pub(crate) struct FieldState {
4170 pub(crate) contents: String,
4171 pub(crate) caret: i32,
4172 pub(crate) selection: Option<(i32, i32)>,
4173 pub(crate) calls: Vec<&'static str>,
4174 }
4175
4176 #[derive(Clone, Copy, PartialEq)]
4177 pub(crate) enum FieldEdit {
4178 Apply,
4179 QtLength,
4182 DropLastChar,
4184 AppendAtEnd,
4186 StallInsert,
4188 }
4189
4190 struct FocusNode {
4191 name: &'static str,
4192 role: atspi::Role,
4193 role_name: &'static str,
4194 states: StateSet,
4195 children: Vec<(String, OwnedObjectPath)>,
4196 text_field: bool,
4197 }
4198
4199 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
4200 impl FocusNode {
4201 #[zbus(property)]
4202 fn child_count(&self) -> i32 {
4203 self.children.len() as i32
4204 }
4205
4206 #[zbus(property)]
4207 fn name(&self) -> &str {
4208 self.name
4209 }
4210
4211 fn get_role(&self) -> atspi::Role {
4212 self.role
4213 }
4214
4215 fn get_role_name(&self) -> &str {
4216 self.role_name
4217 }
4218
4219 fn get_state(&self) -> StateSet {
4220 self.states
4221 }
4222
4223 fn get_interfaces(&self) -> Vec<&str> {
4224 let mut interfaces = vec!["org.a11y.atspi.Accessible"];
4225 if self.text_field {
4226 interfaces.extend(["org.a11y.atspi.EditableText", "org.a11y.atspi.Text"]);
4227 }
4228 interfaces
4229 }
4230
4231 fn get_child_at_index(
4232 &self,
4233 index: i32,
4234 ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
4235 self.children
4236 .get(index as usize)
4237 .cloned()
4238 .map(|child| (child,))
4239 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
4240 }
4241 }
4242
4243 fn char_byte(contents: &str, offset: i32) -> zbus::fdo::Result<usize> {
4244 if offset < 0 {
4245 return Err(zbus::fdo::Error::InvalidArgs("invalid text offset".into()));
4246 }
4247 contents
4248 .char_indices()
4249 .map(|(byte_index, _)| byte_index)
4250 .chain(std::iter::once(contents.len()))
4251 .nth(offset as usize)
4252 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offset".into()))
4253 }
4254
4255 struct FieldText(Arc<Mutex<FieldState>>);
4256
4257 #[zbus::interface(name = "org.a11y.atspi.Text")]
4258 impl FieldText {
4259 #[zbus(property)]
4260 fn character_count(&self) -> i32 {
4261 self.0.lock().unwrap().contents.chars().count() as i32
4262 }
4263
4264 #[zbus(property)]
4265 fn caret_offset(&self) -> i32 {
4266 self.0.lock().unwrap().caret
4267 }
4268
4269 #[zbus(name = "GetNSelections")]
4270 fn get_n_selections(&self) -> i32 {
4271 i32::from(self.0.lock().unwrap().selection.is_some())
4272 }
4273
4274 fn get_selection(&self, index: i32) -> zbus::fdo::Result<(i32, i32)> {
4275 let state = self.0.lock().unwrap();
4276 state
4277 .selection
4278 .filter(|_| index == 0)
4279 .ok_or_else(|| zbus::fdo::Error::InvalidArgs("no such selection".into()))
4280 }
4281
4282 fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
4283 let state = self.0.lock().unwrap();
4284 let start = char_byte(&state.contents, start_offset)?;
4285 let end = char_byte(&state.contents, end_offset)?;
4286 if end < start {
4287 return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
4288 }
4289 Ok(state.contents[start..end].to_string())
4290 }
4291
4292 fn set_caret_offset(&self, offset: i32) -> bool {
4293 let mut state = self.0.lock().unwrap();
4294 state.calls.push("caret");
4295 state.caret = offset;
4296 true
4297 }
4298 }
4299
4300 struct FixtureApplication(&'static str);
4301
4302 #[zbus::interface(name = "org.a11y.atspi.Application")]
4303 impl FixtureApplication {
4304 #[zbus(property)]
4305 fn toolkit_name(&self) -> &str {
4306 self.0
4307 }
4308 }
4309
4310 struct FieldEditable {
4311 state: Arc<Mutex<FieldState>>,
4312 edit: FieldEdit,
4313 }
4314
4315 impl FieldEditable {
4316 fn apply(&self, text: &str) -> String {
4317 let mut text = text.to_string();
4318 if self.edit == FieldEdit::DropLastChar {
4319 text.pop();
4320 }
4321 text
4322 }
4323 }
4324
4325 #[zbus::interface(name = "org.a11y.atspi.EditableText")]
4326 impl FieldEditable {
4327 async fn insert_text(
4328 &self,
4329 position: i32,
4330 text: &str,
4331 length: i32,
4332 ) -> zbus::fdo::Result<bool> {
4333 if self.edit == FieldEdit::StallInsert {
4334 std::future::pending::<()>().await;
4335 }
4336 let text = if self.edit == FieldEdit::QtLength {
4337 let mut units = text.encode_utf16().collect::<Vec<_>>();
4338 units.resize(length.max(0) as usize, 0);
4339 String::from_utf16_lossy(&units)
4340 } else {
4341 let take = (length.max(0) as usize).min(text.len());
4343 self.apply(text.get(..take).unwrap_or(text))
4344 };
4345 let mut state = self.state.lock().unwrap();
4346 state.calls.push("insert");
4347 let at = if self.edit == FieldEdit::AppendAtEnd {
4348 state.contents.len()
4349 } else {
4350 char_byte(&state.contents, position)?
4351 };
4352 state.contents.insert_str(at, &text);
4353 state.selection = None;
4355 Ok(true)
4356 }
4357
4358 fn delete_text(&self, start_pos: i32, end_pos: i32) -> zbus::fdo::Result<bool> {
4359 let mut state = self.state.lock().unwrap();
4360 state.calls.push("delete");
4361 let start = char_byte(&state.contents, start_pos)?;
4362 let end = char_byte(&state.contents, end_pos)?;
4363 state.contents.replace_range(start..end, "");
4364 state.caret = start_pos;
4365 state.selection = None;
4366 Ok(true)
4367 }
4368
4369 fn set_text_contents(&self, new_contents: &str) -> bool {
4370 let text = self.apply(new_contents);
4371 let mut state = self.state.lock().unwrap();
4372 state.calls.push("set");
4373 state.contents = text;
4374 state.caret = 0;
4375 state.selection = None;
4376 true
4377 }
4378 }
4379
4380 pub(crate) struct FieldFixture {
4381 pub(crate) role: atspi::Role,
4382 pub(crate) role_name: &'static str,
4383 pub(crate) states: StateSet,
4384 pub(crate) contents: &'static str,
4385 pub(crate) caret: i32,
4386 pub(crate) selection: Option<(i32, i32)>,
4387 pub(crate) edit: FieldEdit,
4388 pub(crate) toolkit: Option<&'static str>,
4391 }
4392
4393 impl Default for FieldFixture {
4394 fn default() -> Self {
4395 Self {
4396 role: atspi::Role::Text,
4397 role_name: "text",
4398 states: StateSet::new(
4399 atspi::State::Focused
4400 | atspi::State::Focusable
4401 | atspi::State::Editable
4402 | atspi::State::Showing,
4403 ),
4404 contents: "",
4405 caret: 0,
4406 selection: None,
4407 edit: FieldEdit::Apply,
4408 toolkit: Some("GTK"),
4409 }
4410 }
4411 }
4412
4413 pub(crate) struct FixtureWindow {
4414 pub(crate) name: &'static str,
4415 pub(crate) active: bool,
4416 pub(crate) field: FieldFixture,
4417 }
4418
4419 pub(crate) struct RunningFields {
4420 _peers: [zbus::Connection; 2],
4422 pub(crate) conn: AccessibilityConnection,
4423 pub(crate) fields: Vec<(ObjectRefOwned, Arc<Mutex<FieldState>>)>,
4424 _bus: PrivateBus,
4425 }
4426
4427 impl RunningFields {
4428 pub(crate) fn take_state(&self, index: usize) -> FieldState {
4429 std::mem::take(&mut *self.fields[index].1.lock().unwrap())
4430 }
4431 }
4432
4433 pub(crate) const FIXTURE_WINDOW: &str = "Files";
4434
4435 pub(crate) async fn start_windows_fixture(windows: Vec<FixtureWindow>) -> RunningFields {
4436 let bus = PrivateBus::start("cul-atspi-insert");
4437 let app = zbus::connection::Builder::address(bus.address())
4438 .unwrap()
4439 .build()
4440 .await
4441 .unwrap();
4442 let app_name = app.unique_name().unwrap().to_string();
4443 let toolkit = windows.first().and_then(|window| window.field.toolkit);
4444 let path = |kind: &str, index: usize| -> OwnedObjectPath {
4445 format!("/{kind}{index}").try_into().unwrap()
4446 };
4447 app.object_server()
4448 .at(
4449 FOCUS_ROOT_PATH,
4450 FocusNode {
4451 name: "fixture-app",
4452 role: atspi::Role::Application,
4453 role_name: "application",
4454 states: StateSet::empty(),
4455 children: (0..windows.len())
4456 .map(|index| (app_name.clone(), path("frame", index)))
4457 .collect(),
4458 text_field: false,
4459 },
4460 )
4461 .await
4462 .unwrap();
4463 if let Some(toolkit) = toolkit {
4464 app.object_server()
4465 .at(FOCUS_ROOT_PATH, FixtureApplication(toolkit))
4466 .await
4467 .unwrap();
4468 }
4469 let mut fields = Vec::new();
4470 for (index, window) in windows.into_iter().enumerate() {
4471 let mut frame_states = StateSet::new(atspi::State::Showing);
4472 if window.active {
4473 frame_states.insert(atspi::State::Active);
4474 }
4475 app.object_server()
4476 .at(
4477 path("frame", index),
4478 FocusNode {
4479 name: window.name,
4480 role: atspi::Role::Frame,
4481 role_name: "frame",
4482 states: frame_states,
4483 children: vec![(app_name.clone(), path("field", index))],
4484 text_field: false,
4485 },
4486 )
4487 .await
4488 .unwrap();
4489 let fixture = window.field;
4490 let state = Arc::new(Mutex::new(FieldState {
4491 contents: fixture.contents.to_string(),
4492 caret: fixture.caret,
4493 selection: fixture.selection,
4494 calls: Vec::new(),
4495 }));
4496 let field_path = path("field", index);
4497 app.object_server()
4498 .at(
4499 field_path.clone(),
4500 FocusNode {
4501 name: "Location",
4502 role: fixture.role,
4503 role_name: fixture.role_name,
4504 states: fixture.states,
4505 children: Vec::new(),
4506 text_field: true,
4507 },
4508 )
4509 .await
4510 .unwrap();
4511 app.object_server()
4512 .at(field_path.clone(), FieldText(Arc::clone(&state)))
4513 .await
4514 .unwrap();
4515 app.object_server()
4516 .at(
4517 field_path.clone(),
4518 FieldEditable {
4519 state: Arc::clone(&state),
4520 edit: fixture.edit,
4521 },
4522 )
4523 .await
4524 .unwrap();
4525 let field = object_ref_from_id(&format!("{app_name}{}", field_path.as_str())).unwrap();
4526 fields.push((field, state));
4527 }
4528 let registry = zbus::connection::Builder::address(bus.address())
4529 .unwrap()
4530 .name("org.a11y.atspi.Registry")
4531 .unwrap()
4532 .serve_at(
4533 FOCUS_ROOT_PATH,
4534 FocusNode {
4535 name: "main",
4536 role: atspi::Role::DesktopFrame,
4537 role_name: "desktop frame",
4538 states: StateSet::empty(),
4539 children: vec![(app_name, FOCUS_ROOT_PATH.try_into().unwrap())],
4540 text_field: false,
4541 },
4542 )
4543 .unwrap()
4544 .build()
4545 .await
4546 .unwrap();
4547 let conn = AccessibilityConnection::from_address(bus.address().parse().unwrap())
4548 .await
4549 .unwrap();
4550 RunningFields {
4551 _peers: [app, registry],
4552 conn,
4553 fields,
4554 _bus: bus,
4555 }
4556 }
4557
4558 pub(crate) async fn start_field_fixture(field: FieldFixture) -> RunningFields {
4560 start_windows_fixture(vec![FixtureWindow {
4561 name: FIXTURE_WINDOW,
4562 active: true,
4563 field,
4564 }])
4565 .await
4566 }
4567
4568 pub(crate) async fn insert_into_fixture(
4571 field: FieldFixture,
4572 text: &str,
4573 ) -> (Result<FocusedTextInsert>, FieldState) {
4574 let running = start_field_fixture(field).await;
4575 let outcome = insert_text_at_focus_on(
4576 &running.conn,
4577 std::process::id(),
4578 Some(FIXTURE_WINDOW),
4579 text,
4580 )
4581 .await;
4582 (outcome, running.take_state(0))
4583 }
4584}