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