1use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Mutex;
6
7use rayon::prelude::*;
8use xa11y_core::{
9 ElementData, ElementParts, Error, Provider, Rect, Result, Role, ShellSurfaceKind, StateParts,
10 StateSet, Subscription, Toggled,
11};
12use zbus::blocking::{Connection, Proxy};
13
14use crate::window::WindowManager;
15
16static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
18
19fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
22 match name {
23 "enabled" => Some(s.enabled.to_string()),
24 "visible" => Some(s.visible.to_string()),
25 "focused" => Some(s.focused.to_string()),
26 "active" => Some(s.active.to_string()),
27 "focusable" => Some(s.focusable.to_string()),
28 "selected" => Some(s.selected.to_string()),
29 "editable" => Some(s.editable.to_string()),
30 "modal" => Some(s.modal.to_string()),
31 "required" => Some(s.required.to_string()),
32 "busy" => Some(s.busy.to_string()),
33 "expanded" => s.expanded.map(|b| b.to_string()),
34 "checked" => s.checked.map(|c| {
35 match c {
36 Toggled::On => "on",
37 Toggled::Off => "off",
38 Toggled::Mixed => "mixed",
39 }
40 .to_string()
41 }),
42 _ => None,
43 }
44}
45
46pub struct LinuxProvider {
48 a11y_bus: Connection,
49 handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
51 action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
54 window_manager: WindowManager,
57}
58
59#[derive(Debug, Clone)]
61pub(crate) struct AccessibleRef {
62 pub(crate) bus_name: String,
63 pub(crate) path: String,
64}
65
66impl LinuxProvider {
67 pub fn new() -> Result<Self> {
72 let a11y_bus = Self::connect_a11y_bus()?;
73 Ok(Self {
74 a11y_bus,
75 handle_cache: Mutex::new(HashMap::new()),
76 action_indices: Mutex::new(HashMap::new()),
77 window_manager: WindowManager::new(),
78 })
79 }
80
81 pub(crate) fn connect_a11y_bus() -> Result<Connection> {
82 if let Ok(session) = Connection::session() {
86 let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
87 .map_err(|e| Error::Platform {
88 code: -1,
89 message: format!("Failed to create a11y bus proxy: {}", e),
90 })?;
91
92 if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
93 if let Ok(address) = addr_reply.body().deserialize::<String>() {
94 if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
95 if let Ok(Ok(conn)) =
96 zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
97 {
98 return Ok(conn);
99 }
100 }
101 }
102 }
103
104 return Ok(session);
106 }
107
108 Connection::session().map_err(|e| Error::Platform {
109 code: -1,
110 message: format!("Failed to connect to D-Bus session bus: {}", e),
111 })
112 }
113
114 fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
115 zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
118 .destination(bus_name.to_owned())
119 .map_err(|e| Error::Platform {
120 code: -1,
121 message: format!("Failed to set proxy destination: {}", e),
122 })?
123 .path(path.to_owned())
124 .map_err(|e| Error::Platform {
125 code: -1,
126 message: format!("Failed to set proxy path: {}", e),
127 })?
128 .interface(interface.to_owned())
129 .map_err(|e| Error::Platform {
130 code: -1,
131 message: format!("Failed to set proxy interface: {}", e),
132 })?
133 .cache_properties(zbus::proxy::CacheProperties::No)
134 .build()
135 .map_err(|e| Error::Platform {
136 code: -1,
137 message: format!("Failed to create proxy: {}", e),
138 })
139 }
140
141 fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
144 let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
145 Ok(p) => p,
146 Err(_) => return false,
147 };
148 let reply = match proxy.call_method("GetInterfaces", &()) {
149 Ok(r) => r,
150 Err(_) => return false,
151 };
152 let interfaces: Vec<String> = match reply.body().deserialize() {
153 Ok(v) => v,
154 Err(_) => return false,
155 };
156 interfaces.iter().any(|i| i.contains(iface))
157 }
158
159 fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
161 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
162 let reply = proxy
163 .call_method("GetRole", &())
164 .map_err(|e| Error::Platform {
165 code: -1,
166 message: format!("GetRole failed: {}", e),
167 })?;
168 reply
169 .body()
170 .deserialize::<u32>()
171 .map_err(|e| Error::Platform {
172 code: -1,
173 message: format!("GetRole deserialize failed: {}", e),
174 })
175 }
176
177 fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
179 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
180 let reply = proxy
181 .call_method("GetRoleName", &())
182 .map_err(|e| Error::Platform {
183 code: -1,
184 message: format!("GetRoleName failed: {}", e),
185 })?;
186 reply
187 .body()
188 .deserialize::<String>()
189 .map_err(|e| Error::Platform {
190 code: -1,
191 message: format!("GetRoleName deserialize failed: {}", e),
192 })
193 }
194
195 fn check_chromium_a11y_enabled(
212 &self,
213 parent: &AccessibleRef,
214 role_hint: Option<Role>,
215 ) -> Result<()> {
216 let app_root = AccessibleRef {
217 bus_name: parent.bus_name.clone(),
218 path: "/org/a11y/atspi/accessible/root".to_string(),
219 };
220 let toolkit = match self
221 .make_proxy(
222 &app_root.bus_name,
223 &app_root.path,
224 "org.a11y.atspi.Application",
225 )
226 .ok()
227 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
228 {
229 Some(t) => t,
230 None => return Ok(()),
231 };
232 if !toolkit.eq_ignore_ascii_case("Chromium") {
233 return Ok(());
234 }
235 let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
236 if role != Role::Window {
237 return Ok(());
238 }
239 let app_name = self.get_name(&app_root).unwrap_or_default();
240 Err(Error::AccessibilityNotEnabled {
241 app: app_name,
242 instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
243 Relaunch with `--force-renderer-accessibility` (or set the env var \
244 `ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
245 .to_string(),
246 })
247 }
248
249 fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
251 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
252 proxy
253 .get_property::<String>("Name")
254 .map_err(|e| Error::Platform {
255 code: -1,
256 message: format!("Get Name property failed: {}", e),
257 })
258 }
259
260 fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
262 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
263 proxy
264 .get_property::<String>("Description")
265 .map_err(|e| Error::Platform {
266 code: -1,
267 message: format!("Get Description property failed: {}", e),
268 })
269 }
270
271 fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
275 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
276 let reply = proxy
277 .call_method("GetChildren", &())
278 .map_err(|e| children_call_error(e, aref))?;
279 let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
280 reply.body().deserialize().map_err(|e| Error::Platform {
281 code: -1,
282 message: format!("GetChildren deserialize failed: {}", e),
283 })?;
284 Ok(children
285 .into_iter()
286 .map(|(bus_name, path)| AccessibleRef {
287 bus_name,
288 path: path.to_string(),
289 })
290 .collect())
291 }
292
293 fn live_atspi_children(&self, aref: &AccessibleRef) -> Result<Option<Vec<AccessibleRef>>> {
296 match self.get_atspi_children(aref) {
297 Ok(children) => Ok(Some(children)),
298 Err(Error::ElementStale { .. }) => Ok(None),
299 Err(err) => Err(err),
300 }
301 }
302
303 fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
305 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
306 let reply = proxy
307 .call_method("GetState", &())
308 .map_err(|e| Error::Platform {
309 code: -1,
310 message: format!("GetState failed: {}", e),
311 })?;
312 reply
313 .body()
314 .deserialize::<Vec<u32>>()
315 .map_err(|e| Error::Platform {
316 code: -1,
317 message: format!("GetState deserialize failed: {}", e),
318 })
319 }
320
321 fn scan_role_number(&self, aref: &AccessibleRef) -> Result<Option<u32>> {
327 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
328 let reply = match proxy.call_method("GetRole", &()) {
329 Ok(reply) => reply,
330 Err(e) if is_peer_gone(&e) => return Ok(None),
331 Err(e) => {
332 return Err(Error::Platform {
333 code: -1,
334 message: format!("GetRole failed: {}", e),
335 })
336 }
337 };
338 reply
339 .body()
340 .deserialize::<u32>()
341 .map(Some)
342 .map_err(|e| Error::Platform {
343 code: -1,
344 message: format!("GetRole deserialize failed: {}", e),
345 })
346 }
347
348 #[allow(
355 clippy::option_option,
356 reason = "The two Nones mean different things \
357 — peer gone versus member absent — and collapsing them would erase \
358 exactly the distinction this function exists to make."
359 )]
360 fn scan_attributes(
361 &self,
362 aref: &AccessibleRef,
363 ) -> Result<Option<Option<HashMap<String, String>>>> {
364 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
365 let reply = match proxy.call_method("GetAttributes", &()) {
366 Ok(reply) => reply,
367 Err(e) if is_peer_gone(&e) => return Ok(None),
368 Err(e) if is_absent_member(&e) => return Ok(Some(None)),
369 Err(e) => {
370 return Err(Error::Platform {
371 code: -1,
372 message: format!("GetAttributes failed: {}", e),
373 })
374 }
375 };
376 reply
377 .body()
378 .deserialize::<HashMap<String, String>>()
379 .map(|a| Some(Some(a)))
380 .map_err(|e| Error::Platform {
381 code: -1,
382 message: format!("GetAttributes deserialize failed: {}", e),
383 })
384 }
385
386 fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
392 let state_bits = self.get_state(aref).unwrap_or_default();
393 let bits: u64 = if state_bits.len() >= 2 {
394 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
395 } else if state_bits.len() == 1 {
396 state_bits[0] as u64
397 } else {
398 0
399 };
400 const MULTI_LINE: u64 = 1 << 17;
402 (bits & MULTI_LINE) != 0
403 }
404
405 fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
414 match coarse {
415 Role::TextArea | Role::TextField => {
416 if self.is_multi_line(aref) {
417 Role::TextArea
418 } else {
419 Role::TextField
420 }
421 }
422 other => other,
423 }
424 }
425
426 fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
430 if !self.has_interface(aref, "Component") {
431 return None;
432 }
433 let proxy = self
434 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
435 .ok()?;
436 let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
439 let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
440 if w <= 0 && h <= 0 {
441 return None;
442 }
443 Some(
448 Rect {
449 x,
450 y,
451 width: w.max(0) as u32,
452 height: h.max(0) as u32,
453 }
454 .to_logical(crate::scale::coordinate_scale()),
455 )
456 }
457
458 fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
464 let mut actions = Vec::new();
465 let mut indices = HashMap::new();
466
467 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
469 let n_actions = proxy
471 .get_property::<i32>("NActions")
472 .or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
473 .unwrap_or(0);
474 for i in 0..n_actions {
475 if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
476 if let Ok(name) = reply.body().deserialize::<String>() {
477 if let Some(action_name) = map_atspi_action_name(&name) {
478 if !actions.contains(&action_name) {
479 indices.insert(action_name.clone(), i);
480 actions.push(action_name);
481 }
482 }
483 }
484 }
485 }
486 }
487
488 (actions, indices)
496 }
497
498 fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
506 let app_root = AccessibleRef {
507 bus_name: aref.bus_name.clone(),
508 path: "/org/a11y/atspi/accessible/root".to_string(),
509 };
510 self.make_proxy(
511 &app_root.bus_name,
512 &app_root.path,
513 "org.a11y.atspi.Application",
514 )
515 .ok()
516 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
517 .map(|t| t.eq_ignore_ascii_case("GTK"))
518 .unwrap_or(false)
519 }
520
521 fn find_gtk_press_fallback(
534 &self,
535 outer: &AccessibleRef,
536 outer_name: &str,
537 ) -> Option<(AccessibleRef, i32)> {
538 let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
539 queue.push_back((outer.clone(), 0));
540 let mut visited: usize = 0;
541 let mut shallowest_depth: Option<u32> = None;
542 let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
543
544 while let Some((node, depth)) = queue.pop_front() {
545 if let Some(best) = shallowest_depth {
547 if depth > best {
548 continue;
549 }
550 }
551 if visited > GTK_FALLBACK_MAX_NODES {
552 break;
553 }
554
555 let role_name = if depth == 0 {
556 String::new()
557 } else {
558 visited += 1;
559 self.get_role_name(&node).unwrap_or_default().to_lowercase()
560 };
561
562 if depth > 0 && is_actionable_atspi_role(&role_name) {
563 if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
564 match shallowest_depth {
565 Some(d) if depth < d => {
566 shallowest_depth = Some(depth);
567 hits.clear();
568 hits.push((node.clone(), idx));
569 }
570 Some(d) if depth == d => hits.push((node.clone(), idx)),
571 Some(_) => {}
572 None => {
573 shallowest_depth = Some(depth);
574 hits.push((node.clone(), idx));
575 }
576 }
577 }
578 }
579
580 let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
585 || (depth > 0 && is_never_descend_atspi_role(&role_name));
586 if stop_descending {
587 continue;
588 }
589 if let Ok(children) = self.get_atspi_children(&node) {
590 for c in children {
591 queue.push_back((c, depth + 1));
592 }
593 }
594 }
595
596 if hits.len() == 1 {
597 Some(hits.into_iter().next().unwrap())
598 } else {
599 None
600 }
601 }
602
603 fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
606 let (_, index_map) = self.get_actions(aref);
607 let idx = *index_map.get("press")?;
608 if !self.is_showing_visible_sensitive(aref) {
609 return None;
610 }
611 if !outer_name.is_empty() {
615 let inner_name = self.get_name(aref).unwrap_or_default();
616 if !inner_name.is_empty() && inner_name != outer_name {
617 return None;
618 }
619 }
620 Some(idx)
621 }
622
623 fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
628 let raw = self.get_state(aref).unwrap_or_default();
629 let bits: u64 = if raw.len() >= 2 {
630 (raw[0] as u64) | ((raw[1] as u64) << 32)
631 } else if raw.len() == 1 {
632 raw[0] as u64
633 } else {
634 0
635 };
636 const SENSITIVE: u64 = 1 << 24;
637 const SHOWING: u64 = 1 << 25;
638 const VISIBLE: u64 = 1 << 30;
639 (bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
640 }
641
642 fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
645 let text_value = self.get_text_content(aref);
649 if text_value.is_some() {
650 return text_value;
651 }
652 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
654 if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
655 return Some(val.to_string());
656 }
657 }
658 None
659 }
660
661 fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
663 let proxy = self
664 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
665 .ok()?;
666 let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
667 if char_count > 0 {
668 let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
669 let text: String = reply.body().deserialize().ok()?;
670 if !text.is_empty() {
671 return Some(text);
672 }
673 }
674 None
675 }
676
677 fn cache_element(&self, aref: AccessibleRef) -> u64 {
679 let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
680 self.handle_cache
681 .lock()
682 .unwrap_or_else(|e| e.into_inner())
683 .insert(handle, aref);
684 handle
685 }
686
687 fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
689 self.handle_cache
690 .lock()
691 .unwrap_or_else(|e| e.into_inner())
692 .get(&handle)
693 .cloned()
694 .ok_or(Error::ElementStale {
695 selector: format!("handle:{}", handle),
696 })
697 }
698
699 fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
704 let role_name = self.get_role_name(aref).unwrap_or_default();
705 let role_num = self.get_role_number(aref).unwrap_or(0);
706 let coarse_role = {
707 let by_name = if !role_name.is_empty() {
708 map_atspi_role(&role_name)
709 } else {
710 Role::Unknown
711 };
712 let coarse = if by_name != Role::Unknown {
713 by_name
714 } else {
715 map_atspi_role_number(role_num)
716 };
717 self.refine_text_role(coarse, aref)
718 };
719 let prefetched_menu_actions = if coarse_role == Role::Menu {
726 Some(self.get_actions(aref))
727 } else {
728 None
729 };
730 let role = refine_menu_role(
731 coarse_role,
732 prefetched_menu_actions
733 .as_ref()
734 .map(|(actions, _)| actions.as_slice())
735 .unwrap_or(&[]),
736 );
737
738 let (
742 ((mut name, value), description),
743 (
744 ((states, state_bits), bounds),
745 ((actions, action_index_map), (numeric_value, min_value, max_value)),
746 ),
747 ) = rayon::join(
748 || {
749 rayon::join(
750 || {
751 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
752 let value = if role_has_value(role) {
753 self.get_value(aref)
754 } else {
755 None
756 };
757 (name, value)
758 },
759 || self.get_description(aref).ok().filter(|s| !s.is_empty()),
760 )
761 },
762 || {
763 rayon::join(
764 || {
765 rayon::join(
766 || self.parse_states_with_bits(aref, role),
767 || {
768 if role != Role::Application {
769 self.get_extents(aref)
770 } else {
771 None
772 }
773 },
774 )
775 },
776 || {
777 rayon::join(
778 || {
779 if let Some(actions) = prefetched_menu_actions.as_ref() {
780 actions.clone()
781 } else if role_has_actions(role) {
782 self.get_actions(aref)
783 } else {
784 (vec![], HashMap::new())
785 }
786 },
787 || {
788 if matches!(
789 role,
790 Role::Slider
791 | Role::ProgressBar
792 | Role::ScrollBar
793 | Role::SpinButton
794 ) {
795 if let Ok(proxy) = self.make_proxy(
796 &aref.bus_name,
797 &aref.path,
798 "org.a11y.atspi.Value",
799 ) {
800 (
801 proxy.get_property::<f64>("CurrentValue").ok(),
802 proxy.get_property::<f64>("MinimumValue").ok(),
803 proxy.get_property::<f64>("MaximumValue").ok(),
804 )
805 } else {
806 (None, None, None)
807 }
808 } else {
809 (None, None, None)
810 }
811 },
812 )
813 },
814 )
815 },
816 );
817
818 if name.is_none() && role == Role::StaticText {
821 if let Some(ref v) = value {
822 name = Some(v.clone());
823 }
824 }
825
826 let raw = {
846 let raw_role = if role_name.is_empty() {
847 format!("role_num:{}", role_num)
848 } else {
849 role_name
850 };
851 let mut raw = HashMap::new();
852 raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
853 raw.insert(
854 "bus_name".into(),
855 serde_json::Value::String(aref.bus_name.clone()),
856 );
857 raw.insert(
858 "object_path".into(),
859 serde_json::Value::String(aref.path.clone()),
860 );
861 if let Some(ref n) = name {
864 raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
865 }
866 if let Some(ref v) = value {
867 raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
868 }
869 if let Some(ref d) = description {
870 raw.insert(
871 "atspi_description".into(),
872 serde_json::Value::String(d.clone()),
873 );
874 }
875 if (state_bits & (1 << 6)) != 0 {
881 raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
882 }
883 raw
884 };
885
886 let name = xa11y_core::text::strip_bidi_opt(name);
889 let value = xa11y_core::text::strip_bidi_opt(value);
890 let description = xa11y_core::text::strip_bidi_opt(description);
891
892 let handle = self.cache_element(aref.clone());
893 if !action_index_map.is_empty() {
894 self.action_indices
895 .lock()
896 .unwrap_or_else(|e| e.into_inner())
897 .insert(handle, action_index_map);
898 }
899
900 let mut data: ElementData = ElementParts {
901 role,
902 name,
903 value,
904 description,
905 bounds,
906 actions,
907 states,
908 numeric_value,
909 min_value,
910 max_value,
911 stable_id: Some(aref.path.clone()),
912 pid,
913 raw,
914 handle,
915 }
916 .into();
917
918 if matches!(role, Role::Window | Role::Dialog) && self.is_top_level_window_ref(aref) {
919 let facts = self.window_manager.facts(&data);
920 if facts.bounds.is_some() {
921 data.bounds = facts.bounds;
922 }
923 for action in &facts.actions {
924 if !data.actions.iter().any(|existing| existing == action) {
925 data.actions.push((*action).to_string());
926 }
927 }
928 if facts.minimized.is_some() {
929 data.states.minimized = facts.minimized;
930 }
931 if facts.maximized.is_some() {
932 data.states.maximized = facts.maximized;
933 }
934 if facts.fullscreen.is_some() {
935 data.states.fullscreen = facts.fullscreen;
936 }
937 if let Some(backend) = facts.backend {
938 data.raw.insert(
939 "window_backend".to_string(),
940 serde_json::Value::String(backend.to_string()),
941 );
942 }
943 if let Some(version) = facts.protocol_version {
944 data.raw.insert(
945 "window_protocol_version".to_string(),
946 serde_json::Value::Number(version.into()),
947 );
948 }
949 let capabilities = [
950 "activate",
951 "minimize",
952 "maximize",
953 "enter_fullscreen",
954 "restore",
955 "close",
956 "move_to",
957 "resize_to",
958 ]
959 .into_iter()
960 .map(|action| {
961 let status = if facts.backend.is_none() {
962 "unknown"
963 } else if facts.actions.contains(&action) {
964 "supported"
965 } else {
966 "unsupported"
967 };
968 (
969 action.to_string(),
970 serde_json::Value::String(status.to_string()),
971 )
972 })
973 .collect();
974 data.raw.insert(
975 "window_capabilities".to_string(),
976 serde_json::Value::Object(capabilities),
977 );
978 }
979
980 data
981 }
982
983 fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
985 let proxy = self.make_proxy(
987 &aref.bus_name,
988 &aref.path,
989 "org.freedesktop.DBus.Properties",
990 )?;
991 let reply = proxy
992 .call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
993 .map_err(|e| Error::Platform {
994 code: -1,
995 message: format!("Get Parent property failed: {}", e),
996 })?;
997 let variant: zbus::zvariant::OwnedValue =
999 reply.body().deserialize().map_err(|e| Error::Platform {
1000 code: -1,
1001 message: format!("Parent deserialize variant failed: {}", e),
1002 })?;
1003 let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
1004 zbus::zvariant::Value::from(variant).try_into().map_err(
1005 |e: zbus::zvariant::Error| Error::Platform {
1006 code: -1,
1007 message: format!("Parent deserialize struct failed: {}", e),
1008 },
1009 )?;
1010 let path_str = path.as_str();
1011 if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
1012 return Ok(None);
1013 }
1014 if path_str == "/org/a11y/atspi/accessible/root" {
1016 return Ok(None);
1017 }
1018 Ok(Some(AccessibleRef {
1019 bus_name: bus,
1020 path: path_str.to_string(),
1021 }))
1022 }
1023
1024 fn state_bits(&self, aref: &AccessibleRef) -> (u64, bool) {
1034 match self.get_state(aref) {
1035 Ok(state_bits) if state_bits.len() >= 2 => (
1036 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32),
1037 true,
1038 ),
1039 Ok(state_bits) if state_bits.len() == 1 => (state_bits[0] as u64, true),
1040 Ok(_) => (0, true),
1041 Err(_) => (0, false),
1042 }
1043 }
1044
1045 fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
1047 self.parse_states_with_bits(aref, role).0
1048 }
1049
1050 fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
1054 let (bits, state_read_ok) = self.state_bits(aref);
1055
1056 const ACTIVE: u64 = 1 << 1;
1061 const BUSY: u64 = 1 << 3;
1062 const CHECKED: u64 = 1 << 4;
1063 const EDITABLE: u64 = 1 << 7;
1064 const ENABLED: u64 = 1 << 8;
1065 const EXPANDABLE: u64 = 1 << 9;
1066 const EXPANDED: u64 = 1 << 10;
1067 const FOCUSABLE: u64 = 1 << 11;
1068 const FOCUSED: u64 = 1 << 12;
1069 const ICONIFIED: u64 = 1 << 15;
1070 const MODAL: u64 = 1 << 16;
1071 const SELECTED: u64 = 1 << 23;
1072 const SENSITIVE: u64 = 1 << 24;
1073 const SHOWING: u64 = 1 << 25;
1074 const VISIBLE: u64 = 1 << 30;
1075 const INDETERMINATE: u64 = 1 << 32;
1076 const REQUIRED: u64 = 1 << 33;
1077
1078 let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
1079 let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
1080
1081 let checked = match role {
1082 Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
1083 if (bits & INDETERMINATE) != 0 {
1084 Some(Toggled::Mixed)
1085 } else if (bits & CHECKED) != 0 {
1086 Some(Toggled::On)
1087 } else {
1088 Some(Toggled::Off)
1089 }
1090 }
1091 _ => None,
1092 };
1093
1094 let expanded = if (bits & EXPANDABLE) != 0 {
1095 Some((bits & EXPANDED) != 0)
1096 } else {
1097 None
1098 };
1099
1100 let states = StateParts {
1101 enabled,
1102 visible,
1103 focused: (bits & FOCUSED) != 0,
1104 active: (bits & ACTIVE) != 0,
1107 checked,
1108 selected: (bits & SELECTED) != 0,
1109 expanded,
1110 editable: (bits & EDITABLE) != 0,
1111 focusable: (bits & FOCUSABLE) != 0,
1112 modal: (bits & MODAL) != 0,
1113 required: (bits & REQUIRED) != 0,
1114 busy: (bits & BUSY) != 0,
1115 minimized: if matches!(role, Role::Window | Role::Dialog) {
1125 if state_read_ok {
1126 Some((bits & ICONIFIED) != 0)
1127 } else {
1128 None
1129 }
1130 } else {
1131 None
1132 },
1133 maximized: None,
1134 fullscreen: None,
1135 }
1136 .into();
1137 (states, bits)
1138 }
1139
1140 pub(crate) fn find_apps_by_pid(&self, pid: u32) -> Result<Vec<AccessibleRef>> {
1145 let mut matches = Vec::new();
1146 let registry = AccessibleRef {
1147 bus_name: "org.a11y.atspi.Registry".to_string(),
1148 path: "/org/a11y/atspi/accessible/root".to_string(),
1149 };
1150 let children = self.get_atspi_children(®istry)?;
1151
1152 for child in &children {
1153 if child.path == "/org/a11y/atspi/null" {
1154 continue;
1155 }
1156 if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
1162 if app_pid == pid {
1163 matches.push(child.clone());
1164 continue;
1165 }
1166 }
1167 if let Ok(proxy) =
1169 self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
1170 {
1171 if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
1172 if app_pid as u32 == pid {
1173 matches.push(child.clone());
1174 }
1175 }
1176 }
1177 }
1178
1179 Ok(matches)
1180 }
1181
1182 fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
1184 let proxy = self
1185 .make_proxy(
1186 "org.freedesktop.DBus",
1187 "/org/freedesktop/DBus",
1188 "org.freedesktop.DBus",
1189 )
1190 .ok()?;
1191 let reply = proxy
1192 .call_method("GetConnectionUnixProcessID", &(bus_name,))
1193 .ok()?;
1194 let pid: u32 = reply.body().deserialize().ok()?;
1195 if pid > 0 {
1196 Some(pid)
1197 } else {
1198 None
1199 }
1200 }
1201
1202 fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
1204 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
1205 let reply = proxy
1206 .call_method("DoAction", &(index,))
1207 .map_err(|e| Error::Platform {
1208 code: -1,
1209 message: format!("DoAction({}) failed: {}", index, e),
1210 })?;
1211 let performed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
1215 code: -1,
1216 message: format!("DoAction({}) reply decode failed: {}", index, e),
1217 })?;
1218 if !performed {
1219 return Err(Error::Platform {
1220 code: -1,
1221 message: format!(
1222 "DoAction({}) returned false; the accessibility provider declined the action",
1223 index
1224 ),
1225 });
1226 }
1227 Ok(())
1228 }
1229
1230 fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
1232 self.action_indices
1233 .lock()
1234 .unwrap_or_else(|e| e.into_inner())
1235 .get(&handle)
1236 .and_then(|map| map.get(action).copied())
1237 .ok_or_else(|| Error::ActionNotSupported {
1238 action: action.to_string(),
1239 role: Role::Unknown, })
1241 }
1242
1243 fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
1249 if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
1250 return Some(pid);
1251 }
1252
1253 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
1260 {
1261 if let Ok(pid) = proxy.get_property::<i32>("Id") {
1262 if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
1263 return Some(pid as u32);
1264 }
1265 }
1266 }
1267
1268 None
1269 }
1270
1271 fn resolve_role(&self, aref: &AccessibleRef) -> Role {
1274 let role_name = self.get_role_name(aref).unwrap_or_default();
1275 let by_name = if !role_name.is_empty() {
1276 map_atspi_role(&role_name)
1277 } else {
1278 Role::Unknown
1279 };
1280 let coarse = if by_name != Role::Unknown {
1281 by_name
1282 } else {
1283 let role_num = self.get_role_number(aref).unwrap_or(0);
1285 map_atspi_role_number(role_num)
1286 };
1287 let role = self.refine_text_role(coarse, aref);
1288 if role == Role::Menu {
1289 let (actions, _) = self.get_actions(aref);
1290 refine_menu_role(role, &actions)
1291 } else {
1292 role
1293 }
1294 }
1295
1296 fn parent_role_is_application(&self, parent: &AccessibleRef) -> Result<bool> {
1308 let role_name = self.get_role_name(parent)?;
1309 let role = if role_name.is_empty() {
1310 map_atspi_role_number(self.get_role_number(parent)?)
1311 } else {
1312 map_atspi_role(&role_name)
1313 };
1314 Ok(role == Role::Application)
1315 }
1316
1317 fn ensure_top_level_window_target(&self, element: &ElementData, action: &str) -> Result<()> {
1318 let target = self.get_cached(element.handle)?;
1330 let Some(parent) = self.get_atspi_parent(&target)? else {
1331 return Ok(());
1332 };
1333 if self.parent_role_is_application(&parent)? {
1338 Ok(())
1339 } else {
1340 Err(Error::ActionNotSupported {
1341 action: action.to_string(),
1342 role: element.role,
1343 })
1344 }
1345 }
1346
1347 fn is_top_level_window_ref(&self, target: &AccessibleRef) -> bool {
1348 match self.get_atspi_parent(target) {
1349 Ok(None) => true,
1350 Ok(Some(parent)) => self.parent_role_is_application(&parent).unwrap_or(false),
1351 Err(_) => false,
1352 }
1353 }
1354
1355 fn matches_ref(
1369 &self,
1370 aref: &AccessibleRef,
1371 simple: &xa11y_core::selector::SimpleSelector,
1372 pid: Option<u32>,
1373 ) -> bool {
1374 let needs_role = simple.role.is_some()
1377 || simple
1378 .filters
1379 .iter()
1380 .any(|f| matches!(f.attr.as_str(), "role" | "checked"));
1381 let role = if needs_role {
1382 Some(self.resolve_role(aref))
1383 } else {
1384 None
1385 };
1386
1387 if let Some(ref role_match) = simple.role {
1388 match role_match {
1389 xa11y_core::selector::RoleMatch::Normalized(expected) => {
1390 if role != Some(*expected) {
1391 return false;
1392 }
1393 }
1394 xa11y_core::selector::RoleMatch::Platform(platform_role) => {
1395 let raw_role = self.get_role_name(aref).unwrap_or_default();
1396 if raw_role != *platform_role {
1397 return false;
1398 }
1399 }
1400 }
1401 }
1402
1403 let mut state_set: Option<StateSet> = None;
1404
1405 for filter in &simple.filters {
1406 let attr = filter.attr.as_str();
1407 let resolved: Option<Option<String>> = match attr {
1408 "role" => Some(role.map(|r| r.to_snake_case().to_string())),
1409 "name" => {
1410 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
1411 let resolved = if name.is_none() && role == Some(Role::StaticText) {
1414 self.get_value(aref)
1415 } else {
1416 name
1417 };
1418 Some(resolved)
1419 }
1420 "value" => Some(self.get_value(aref)),
1421 "description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
1422 "enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
1423 | "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
1424 let s = state_set.get_or_insert_with(|| {
1425 self.parse_states(aref, role.unwrap_or(Role::Unknown))
1429 });
1430 Some(state_attr_to_string(attr, s))
1431 }
1432 _ => None, };
1434
1435 match resolved {
1436 Some(value) => {
1437 if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
1438 {
1439 return false;
1440 }
1441 }
1442 None => {
1443 let data = self.build_element_data(aref, pid);
1450 return xa11y_core::selector::matches_simple(&data, simple);
1451 }
1452 }
1453 }
1454
1455 true
1456 }
1457
1458 fn collect_matching_refs_group(
1468 &self,
1469 parent: &AccessibleRef,
1470 clauses: &[&xa11y_core::selector::SimpleSelector],
1471 depth: u32,
1472 max_depth: u32,
1473 limit: Option<usize>,
1474 pid: Option<u32>,
1475 ) -> Result<Vec<(usize, AccessibleRef)>> {
1476 if depth > max_depth {
1477 return Ok(vec![]);
1478 }
1479
1480 let Some(children) = self.live_atspi_children(parent)? else {
1481 return Ok(Vec::new());
1482 };
1483
1484 let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
1492 let mut to_search: Vec<AccessibleRef> = Vec::new();
1493 for child in children {
1494 if child.path == "/org/a11y/atspi/null"
1495 || child.bus_name.is_empty()
1496 || child.path.is_empty()
1497 {
1498 continue;
1499 }
1500
1501 if !parent_is_registry {
1502 let child_role = self.get_role_name(&child).unwrap_or_default();
1503 if child_role == "application" {
1504 let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
1505 for gc in grandchildren {
1506 if gc.path == "/org/a11y/atspi/null"
1507 || gc.bus_name.is_empty()
1508 || gc.path.is_empty()
1509 {
1510 continue;
1511 }
1512 let gc_role = self.get_role_name(&gc).unwrap_or_default();
1513 if gc_role == "application" {
1514 continue;
1515 }
1516 to_search.push(gc);
1517 }
1518 continue;
1519 }
1520 }
1521 to_search.push(child);
1522 }
1523
1524 if to_search.is_empty() {
1527 self.check_chromium_a11y_enabled(parent, None)?;
1528 }
1529
1530 type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
1545 let per_child: Vec<ChildBatch> = to_search
1546 .par_iter()
1547 .map(|child| {
1548 let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
1549 for (idx, simple) in clauses.iter().enumerate() {
1550 if self.matches_ref(child, simple, pid) {
1551 child_results.push((idx, child.clone()));
1552 }
1553 }
1554 match self.collect_matching_refs_group(
1555 child,
1556 clauses,
1557 depth + 1,
1558 max_depth,
1559 limit,
1560 pid,
1561 ) {
1562 Ok(sub) => {
1563 child_results.extend(sub);
1564 (child_results, None)
1565 }
1566 Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
1567 Err(_) => (child_results, None),
1568 }
1569 })
1570 .collect();
1571
1572 let mut results = Vec::new();
1584 for (batch, maybe_err) in per_child {
1585 if let Some(err) = maybe_err {
1586 return Err(err);
1587 }
1588 for r in batch {
1589 results.push(r);
1590 if let Some(limit) = limit {
1591 if results.len() >= limit {
1592 return Ok(results);
1593 }
1594 }
1595 }
1596 }
1597 Ok(results)
1598 }
1599}
1600
1601impl Provider for LinuxProvider {
1602 fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
1603 match element {
1604 None => {
1605 self.list_apps()
1608 }
1609 Some(element_data) => {
1610 let aref = self.get_cached(element_data.handle)?;
1611 let Some(children) = self.live_atspi_children(&aref)? else {
1615 return Ok(Vec::new());
1616 };
1617 let pid = element_data.pid;
1618
1619 let mut to_build: Vec<AccessibleRef> = Vec::new();
1622 for child_ref in &children {
1623 if child_ref.path == "/org/a11y/atspi/null"
1624 || child_ref.bus_name.is_empty()
1625 || child_ref.path.is_empty()
1626 {
1627 continue;
1628 }
1629 let child_role = self.get_role_name(child_ref).unwrap_or_default();
1630 if child_role == "application" {
1631 let Some(grandchildren) = self.live_atspi_children(child_ref)? else {
1634 continue;
1635 };
1636 for gc_ref in grandchildren {
1637 if gc_ref.path == "/org/a11y/atspi/null"
1638 || gc_ref.bus_name.is_empty()
1639 || gc_ref.path.is_empty()
1640 {
1641 continue;
1642 }
1643 let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
1644 if gc_role == "application" {
1645 continue;
1646 }
1647 to_build.push(gc_ref);
1648 }
1649 continue;
1650 }
1651 to_build.push(child_ref.clone());
1652 }
1653
1654 if to_build.is_empty() {
1655 self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
1656 }
1657
1658 let results: Vec<ElementData> = to_build
1659 .par_iter()
1660 .map(|r| self.build_element_data(r, pid))
1661 .collect();
1662
1663 Ok(results)
1664 }
1665 }
1666 }
1667
1668 fn find_elements_group(
1669 &self,
1670 root: &ElementData,
1671 group: &xa11y_core::selector::SelectorGroup,
1672 limit: Option<usize>,
1673 max_depth: Option<u32>,
1674 ) -> Result<Vec<ElementData>> {
1675 if group.clauses.is_empty() {
1676 return Ok(vec![]);
1677 }
1678 if group.clauses.iter().any(|c| c.segments.is_empty()) {
1681 return Ok(vec![]);
1682 }
1683
1684 let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
1694
1695 let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
1696 .clauses
1697 .iter()
1698 .map(|c| &c.segments[0].simple)
1699 .collect();
1700
1701 let start_ref = self.get_cached(root.handle)?;
1702
1703 let phase1_limit = if group.clauses.len() == 1 {
1712 let clause = &group.clauses[0];
1713 let first = firsts[0];
1714 let outer = if clause.segments.len() == 1 {
1715 limit
1716 } else {
1717 None
1718 };
1719 match (outer, first.nth) {
1720 (Some(l), Some(n)) => Some(l.max(n)),
1721 (_, Some(n)) => Some(n),
1722 (l, None) => l,
1723 }
1724 } else {
1725 None
1726 };
1727
1728 let pid_from_root = root.pid;
1729 let phase1: Vec<(usize, AccessibleRef)> = self.collect_matching_refs_group(
1730 &start_ref,
1731 &firsts,
1732 0,
1733 max_depth_val,
1734 phase1_limit,
1735 pid_from_root,
1736 )?;
1737
1738 let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
1742 (0..group.clauses.len()).map(|_| Vec::new()).collect();
1743 for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
1744 by_clause[clause_idx].push((walk_pos, aref));
1745 }
1746
1747 let mut merged: Vec<(usize, ElementData)> = Vec::new();
1755 for (clause_idx, hits) in by_clause.into_iter().enumerate() {
1756 if hits.is_empty() {
1757 continue;
1758 }
1759 let clause = &group.clauses[clause_idx];
1760 let phase1_data: Vec<(usize, ElementData)> = hits
1762 .par_iter()
1763 .map(|(pos, aref)| {
1764 (*pos, self.build_element_data(aref, pid_from_root))
1768 })
1769 .collect();
1770
1771 if clause.segments.len() == 1 {
1772 let mut candidates = phase1_data;
1774 if let Some(nth) = clause.segments[0].simple.nth {
1775 if nth <= candidates.len() {
1776 let kept = candidates.remove(nth - 1);
1777 candidates.clear();
1778 candidates.push(kept);
1779 } else {
1780 candidates.clear();
1781 }
1782 }
1783 merged.extend(candidates);
1784 continue;
1785 }
1786
1787 for (anchor_pos, head) in phase1_data {
1792 let narrowed = self.narrow_multi_segment(
1793 vec![head],
1794 &clause.segments[1..],
1795 max_depth_val,
1796 None,
1797 )?;
1798 for n in narrowed {
1799 merged.push((anchor_pos, n));
1800 }
1801 }
1802 }
1803
1804 merged.sort_by_key(|(pos, _)| *pos);
1808 let mut seen: HashSet<(String, String)> = HashSet::new();
1809 let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
1810 for (_, data) in merged {
1811 if let Ok(aref) = self.get_cached(data.handle) {
1815 if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
1816 continue;
1817 }
1818 }
1819 out.push(data);
1820 }
1821 if let Some(l) = limit {
1822 out.truncate(l);
1823 }
1824 Ok(out)
1825 }
1826
1827 fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
1828 let aref = self.get_cached(element.handle)?;
1829 match self.get_atspi_parent(&aref)? {
1830 Some(parent_ref) => {
1831 let data = self.build_element_data(&parent_ref, element.pid);
1832 Ok(Some(data))
1833 }
1834 None => Ok(None),
1835 }
1836 }
1837
1838 fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
1875 let mut surfaces = Vec::new();
1876 'apps: for app in self.list_apps()? {
1877 let app_ref = self.get_cached(app.handle)?;
1878 let top_levels = match self.get_atspi_children(&app_ref) {
1879 Ok(t) => t,
1880 Err(_) => continue,
1884 };
1885 for top in top_levels
1886 .iter()
1887 .filter(|t| t.path != "/org/a11y/atspi/null")
1888 {
1889 let Some(role_number) = self
1900 .scan_role_number(top)
1901 .map_err(|e| classify_failure(&app, top, e))?
1902 else {
1903 continue 'apps;
1908 };
1909 let Some(attributes) = self
1918 .scan_attributes(top)
1919 .map_err(|e| classify_failure(&app, top, e))?
1920 else {
1921 continue 'apps;
1922 };
1923 let Some(attributes) = attributes else {
1924 continue;
1925 };
1926 if is_dock_frame(role_number, &attributes) {
1927 surfaces.push((
1928 ShellSurfaceKind::Panel,
1929 self.build_element_data(top, app.pid),
1930 ));
1931 }
1932 }
1933 }
1934 Ok(surfaces)
1935 }
1936
1937 fn splits_app_across_entries(&self) -> bool {
1942 true
1943 }
1944
1945 fn app_roots(&self, app: &ElementData) -> Result<Vec<ElementData>> {
1946 let Some(pid) = app.pid else {
1947 return Ok(vec![app.clone()]);
1948 };
1949 let mut roots: Vec<_> = self
1950 .list_apps()?
1951 .into_iter()
1952 .filter(|candidate| candidate.pid == Some(pid))
1953 .collect();
1954 if roots.is_empty() {
1955 roots.push(app.clone());
1956 }
1957 Ok(roots)
1958 }
1959
1960 fn list_apps(&self) -> Result<Vec<ElementData>> {
1967 let registry = AccessibleRef {
1968 bus_name: "org.a11y.atspi.Registry".to_string(),
1969 path: "/org/a11y/atspi/accessible/root".to_string(),
1970 };
1971 let children = self.get_atspi_children(®istry)?;
1972
1973 let valid: Vec<(&AccessibleRef, String)> = children
1975 .iter()
1976 .filter(|c| c.path != "/org/a11y/atspi/null")
1977 .filter_map(|c| {
1978 let name = self.get_name(c).unwrap_or_default();
1979 if name.is_empty() {
1980 None
1981 } else {
1982 Some((c, name))
1983 }
1984 })
1985 .collect();
1986
1987 let results: Vec<ElementData> = valid
1988 .par_iter()
1989 .map(|(child, app_name)| {
1990 let pid = self.get_app_pid(child);
1991 let mut data = self.build_element_data(child, pid);
1992 data.name = Some(app_name.clone());
1993 data
1994 })
1995 .collect();
1996
1997 Ok(results)
1998 }
1999
2000 fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
2012 let registry = AccessibleRef {
2013 bus_name: "org.a11y.atspi.Registry".to_string(),
2014 path: "/org/a11y/atspi/accessible/root".to_string(),
2015 };
2016 let children = self.get_atspi_children(®istry)?;
2017
2018 let mut total = 0usize;
2019 let mut unresolved = 0usize;
2020 for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
2021 total += 1;
2022 match self.get_app_pid(child) {
2023 Some(p) if p == pid => {
2024 let mut data = self.build_element_data(child, Some(p));
2025 let name = self.get_name(child).unwrap_or_default();
2027 if !name.is_empty() {
2028 data.name = Some(name);
2029 }
2030 return Ok(data);
2031 }
2032 Some(_) => {}
2033 None => unresolved += 1,
2034 }
2035 }
2036 Err(
2037 Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
2038 xa11y_core::Diagnosis::new().last_observed(format!(
2039 "{total} AT-SPI registry entries examined, {unresolved} without a \
2040 resolvable pid"
2041 )),
2042 ),
2043 )
2044 }
2045
2046 fn focused_app(&self) -> Result<ElementData> {
2059 const ACTIVE: u64 = 1 << 1;
2061
2062 let registry = AccessibleRef {
2063 bus_name: "org.a11y.atspi.Registry".to_string(),
2064 path: "/org/a11y/atspi/accessible/root".to_string(),
2065 };
2066 let apps = self.get_atspi_children(®istry)?;
2067
2068 for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
2069 let windows = match self.get_atspi_children(app) {
2070 Ok(w) => w,
2071 Err(_) => continue,
2074 };
2075 let has_active_window = windows
2076 .iter()
2077 .filter(|w| w.path != "/org/a11y/atspi/null")
2078 .any(|w| self.state_bits(w).0 & ACTIVE != 0);
2079 if has_active_window {
2080 let pid = self.get_app_pid(app);
2081 let mut data = self.build_element_data(app, pid);
2082 let name = self.get_name(app).unwrap_or_default();
2083 if !name.is_empty() {
2084 data.name = Some(name);
2085 }
2086 return Ok(data);
2087 }
2088 }
2089
2090 Err(Error::selector_not_matched("focused application"))
2091 }
2092
2093 fn press(&self, element: &ElementData) -> Result<()> {
2094 let target = self.get_cached(element.handle)?;
2095 if let Ok(index) = self.get_action_index(element.handle, "press") {
2097 return self.do_atspi_action_by_index(&target, index);
2098 }
2099 if element.role == Role::Button && self.is_gtk_toolkit(&target) {
2118 let outer_name = self.get_name(&target).unwrap_or_default();
2119 if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
2120 return self.do_atspi_action_by_index(&inner, index);
2121 }
2122 }
2123 Err(Error::ActionNotSupported {
2124 action: "press".to_string(),
2125 role: element.role,
2126 })
2127 }
2128
2129 fn focus(&self, element: &ElementData) -> Result<()> {
2130 let target = self.get_cached(element.handle)?;
2131 let proxy = self
2143 .make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
2144 .map_err(|e| Error::Platform {
2145 code: -1,
2146 message: format!("Component proxy while focusing failed: {}", e),
2147 })?;
2148 let reply = grab_focus_result(
2149 proxy.call_method("GrabFocus", &()),
2150 "focusing",
2151 "focus",
2152 element.role,
2153 )?;
2154 let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2155 code: -1,
2156 message: format!("Component.GrabFocus reply while focusing failed: {}", e),
2157 })?;
2158 if grabbed {
2159 return Ok(());
2160 }
2161 Err(Error::ActionNotSupported {
2164 action: "focus".to_string(),
2165 role: element.role,
2166 })
2167 }
2168
2169 fn blur(&self, element: &ElementData) -> Result<()> {
2170 let target = self.get_cached(element.handle)?;
2171 if let Some(parent_ref) = self.get_atspi_parent(&target)? {
2175 if parent_ref.path != "/org/a11y/atspi/null" {
2176 let p = self.make_proxy(
2177 &parent_ref.bus_name,
2178 &parent_ref.path,
2179 "org.a11y.atspi.Component",
2180 )?;
2181 p.call_method("GrabFocus", &())
2182 .map_err(|e| Error::Platform {
2183 code: -1,
2184 message: format!("Component.GrabFocus on parent failed: {}", e),
2185 })?;
2186 return Ok(());
2187 }
2188 }
2189 Err(Error::ActionNotSupported {
2190 action: "blur".to_string(),
2191 role: element.role,
2192 })
2193 }
2194
2195 fn toggle(&self, element: &ElementData) -> Result<()> {
2196 let target = self.get_cached(element.handle)?;
2197 if let Ok(index) = self.get_action_index(element.handle, "toggle") {
2202 return self.do_atspi_action_by_index(&target, index);
2203 }
2204 if matches!(
2227 element.role,
2228 Role::CheckBox | Role::RadioButton | Role::Switch
2229 ) {
2230 if let Ok(index) = self.get_action_index(element.handle, "press") {
2231 return self.do_atspi_action_by_index(&target, index);
2232 }
2233 }
2234 Err(Error::ActionNotSupported {
2235 action: "toggle".to_string(),
2236 role: element.role,
2237 })
2238 }
2239
2240 fn select(&self, element: &ElementData) -> Result<()> {
2241 let target = self.get_cached(element.handle)?;
2242 let index = self
2243 .get_action_index(element.handle, "select")
2244 .map_err(|_| Error::ActionNotSupported {
2245 action: "select".to_string(),
2246 role: element.role,
2247 })?;
2248 self.do_atspi_action_by_index(&target, index)
2249 }
2250
2251 fn expand(&self, element: &ElementData) -> Result<()> {
2252 let target = self.get_cached(element.handle)?;
2253 let index = self
2254 .get_action_index(element.handle, "expand")
2255 .map_err(|_| Error::ActionNotSupported {
2256 action: "expand".to_string(),
2257 role: element.role,
2258 })?;
2259 self.do_atspi_action_by_index(&target, index)
2260 }
2261
2262 fn collapse(&self, element: &ElementData) -> Result<()> {
2263 let target = self.get_cached(element.handle)?;
2264 let index = self
2265 .get_action_index(element.handle, "collapse")
2266 .map_err(|_| Error::ActionNotSupported {
2267 action: "collapse".to_string(),
2268 role: element.role,
2269 })?;
2270 self.do_atspi_action_by_index(&target, index)
2271 }
2272
2273 fn show_menu(&self, element: &ElementData) -> Result<()> {
2274 let target = self.get_cached(element.handle)?;
2275 let index = self
2276 .get_action_index(element.handle, "show_menu")
2277 .map_err(|_| Error::ActionNotSupported {
2278 action: "show_menu".to_string(),
2279 role: element.role,
2280 })?;
2281 self.do_atspi_action_by_index(&target, index)
2282 }
2283
2284 fn increment(&self, element: &ElementData) -> Result<()> {
2285 let target = self.get_cached(element.handle)?;
2286 if let Ok(index) = self.get_action_index(element.handle, "increment") {
2288 return self.do_atspi_action_by_index(&target, index);
2289 }
2290 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2291 let current: f64 = proxy
2292 .get_property("CurrentValue")
2293 .map_err(|e| Error::Platform {
2294 code: -1,
2295 message: format!("Value.CurrentValue failed: {}", e),
2296 })?;
2297 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2298 let step = if step <= 0.0 { 1.0 } else { step };
2299 proxy
2300 .set_property("CurrentValue", current + step)
2301 .map_err(|e| Error::Platform {
2302 code: -1,
2303 message: format!("Value.SetCurrentValue failed: {}", e),
2304 })
2305 }
2306
2307 fn decrement(&self, element: &ElementData) -> Result<()> {
2308 let target = self.get_cached(element.handle)?;
2309 if let Ok(index) = self.get_action_index(element.handle, "decrement") {
2310 return self.do_atspi_action_by_index(&target, index);
2311 }
2312 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2313 let current: f64 = proxy
2314 .get_property("CurrentValue")
2315 .map_err(|e| Error::Platform {
2316 code: -1,
2317 message: format!("Value.CurrentValue failed: {}", e),
2318 })?;
2319 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2320 let step = if step <= 0.0 { 1.0 } else { step };
2321 proxy
2322 .set_property("CurrentValue", current - step)
2323 .map_err(|e| Error::Platform {
2324 code: -1,
2325 message: format!("Value.SetCurrentValue failed: {}", e),
2326 })
2327 }
2328
2329 fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
2330 let target = self.get_cached(element.handle)?;
2331 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2332 proxy
2333 .call_method("ScrollTo", &(0u32,))
2334 .map_err(|e| Error::Platform {
2335 code: -1,
2336 message: format!("ScrollTo failed: {}", e),
2337 })?;
2338 Ok(())
2339 }
2340
2341 fn activate(&self, element: &ElementData) -> Result<()> {
2352 self.ensure_top_level_window_target(element, "activate")?;
2376 if self.window_manager.uses_native() {
2377 return self.window_manager.activate(element);
2378 }
2379 let target = self.get_cached(element.handle)?;
2380 let proxy = self
2381 .make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
2382 .map_err(|e| Error::Platform {
2383 code: -1,
2384 message: format!("Component proxy while activating failed: {}", e),
2385 })?;
2386 let reply = grab_focus_result(
2387 proxy.call_method("GrabFocus", &()),
2388 "activating",
2389 "activate",
2390 element.role,
2391 )?;
2392 let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2393 code: -1,
2394 message: format!("Component.GrabFocus reply while activating failed: {}", e),
2395 })?;
2396 if grabbed {
2397 return Ok(());
2398 }
2399 Err(Error::ActionNotSupported {
2400 action: "activate".to_string(),
2401 role: element.role,
2402 })
2403 }
2404
2405 fn minimize(&self, element: &ElementData) -> Result<()> {
2406 self.ensure_top_level_window_target(element, "minimize")?;
2407 self.window_manager.minimize(element)
2408 }
2409
2410 fn maximize(&self, element: &ElementData) -> Result<()> {
2411 self.ensure_top_level_window_target(element, "maximize")?;
2412 self.window_manager.maximize(element)
2413 }
2414
2415 fn enter_fullscreen(&self, element: &ElementData) -> Result<()> {
2416 self.ensure_top_level_window_target(element, "enter_fullscreen")?;
2417 self.window_manager.enter_fullscreen(element)
2418 }
2419
2420 fn restore(&self, element: &ElementData) -> Result<()> {
2421 self.ensure_top_level_window_target(element, "restore")?;
2422 self.window_manager.restore(element)
2423 }
2424
2425 fn close(&self, element: &ElementData) -> Result<()> {
2426 self.ensure_top_level_window_target(element, "close")?;
2427 self.window_manager.close(element)
2428 }
2429
2430 fn move_to(&self, element: &ElementData, x: i32, y: i32) -> Result<()> {
2431 self.ensure_top_level_window_target(element, "move_to")?;
2432 if self.window_manager.uses_native() {
2433 return self.window_manager.move_to(element, x, y);
2434 }
2435 let target = self.get_cached(element.handle)?;
2436 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2437 let scale = crate::scale::coordinate_scale();
2438 let point = xa11y_core::Point::new(x, y).to_physical(scale);
2439 let reply = proxy
2441 .call_method("SetPosition", &(point.x, point.y, 0u32))
2442 .map_err(|e| {
2443 if is_absent_member(&e) {
2444 Error::ActionNotSupported {
2445 action: "move_to".to_string(),
2446 role: element.role,
2447 }
2448 } else {
2449 Error::Platform {
2450 code: -1,
2451 message: format!("Component.SetPosition while moving window failed: {e}"),
2452 }
2453 }
2454 })?;
2455 let moved: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2456 code: -1,
2457 message: format!("Component.SetPosition reply while moving window failed: {e}"),
2458 })?;
2459 if moved {
2460 Ok(())
2461 } else {
2462 Err(Error::ActionNotSupported {
2463 action: "move_to".to_string(),
2464 role: element.role,
2465 })
2466 }
2467 }
2468
2469 fn resize_to(&self, element: &ElementData, w: u32, h: u32) -> Result<()> {
2470 self.ensure_top_level_window_target(element, "resize_to")?;
2471 if self.window_manager.uses_native() {
2472 return self.window_manager.resize_to(element, w, h);
2473 }
2474 let target = self.get_cached(element.handle)?;
2475 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2476 let physical = Rect {
2477 x: 0,
2478 y: 0,
2479 width: w,
2480 height: h,
2481 }
2482 .to_physical(crate::scale::coordinate_scale());
2483 let width = i32::try_from(physical.width).map_err(|_| Error::InvalidActionData {
2484 message: format!("resize width {w} is too large for AT-SPI"),
2485 })?;
2486 let height = i32::try_from(physical.height).map_err(|_| Error::InvalidActionData {
2487 message: format!("resize height {h} is too large for AT-SPI"),
2488 })?;
2489 let reply = proxy
2490 .call_method("SetSize", &(width, height))
2491 .map_err(|e| {
2492 if is_absent_member(&e) {
2493 Error::ActionNotSupported {
2494 action: "resize_to".to_string(),
2495 role: element.role,
2496 }
2497 } else {
2498 Error::Platform {
2499 code: -1,
2500 message: format!("Component.SetSize while resizing window failed: {e}"),
2501 }
2502 }
2503 })?;
2504 let resized: bool = reply.body().deserialize().map_err(|e| Error::Platform {
2505 code: -1,
2506 message: format!("Component.SetSize reply while resizing window failed: {e}"),
2507 })?;
2508 if resized {
2509 Ok(())
2510 } else {
2511 Err(Error::ActionNotSupported {
2512 action: "resize_to".to_string(),
2513 role: element.role,
2514 })
2515 }
2516 }
2517
2518 fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
2519 let target = self.get_cached(element.handle)?;
2520 let proxy = self
2521 .make_proxy(
2522 &target.bus_name,
2523 &target.path,
2524 "org.a11y.atspi.EditableText",
2525 )
2526 .map_err(|_| Error::TextValueNotSupported)?;
2527 if proxy.call_method("SetTextContents", &(value,)).is_ok() {
2529 return Ok(());
2530 }
2531 let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
2537 let msg = e.to_string();
2538 if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
2539 Error::TextValueNotSupported
2540 } else {
2541 Error::Platform {
2542 code: -1,
2543 message: format!("EditableText.{} failed: {}", op, msg),
2544 }
2545 }
2546 };
2547 if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
2548 return Err(classify_editable_text_error("DeleteText", e));
2549 }
2550 if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
2551 return Err(classify_editable_text_error("InsertText", e));
2552 }
2553 Ok(())
2554 }
2555
2556 fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
2557 let target = self.get_cached(element.handle)?;
2558 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2559 proxy
2560 .set_property("CurrentValue", value)
2561 .map_err(|e| Error::Platform {
2562 code: -1,
2563 message: format!("SetValue failed: {}", e),
2564 })
2565 }
2566
2567 fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
2568 let target = self.get_cached(element.handle)?;
2569 let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
2572 let insert_pos = text_proxy
2573 .as_ref()
2574 .ok()
2575 .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
2576 .unwrap_or(-1); let proxy = self
2579 .make_proxy(
2580 &target.bus_name,
2581 &target.path,
2582 "org.a11y.atspi.EditableText",
2583 )
2584 .map_err(|_| Error::TextValueNotSupported)?;
2585 let pos = if insert_pos >= 0 {
2586 insert_pos
2587 } else {
2588 i32::MAX
2589 };
2590 proxy
2591 .call_method("InsertText", &(pos, text, text.len() as i32))
2592 .map_err(|e| Error::Platform {
2593 code: -1,
2594 message: format!("EditableText.InsertText failed: {}", e),
2595 })?;
2596 Ok(())
2597 }
2598
2599 fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
2600 let target = self.get_cached(element.handle)?;
2601 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
2602 proxy
2603 .call_method("SetSelection", &(0i32, start as i32, end as i32))
2604 .map_err(|e| Error::Platform {
2605 code: -1,
2606 message: format!("Text.SetSelection failed: {}", e),
2607 })?;
2608 Ok(())
2609 }
2610
2611 fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
2612 match action {
2613 "press" => self.press(element),
2614 "focus" => self.focus(element),
2615 "blur" => self.blur(element),
2616 "toggle" => self.toggle(element),
2617 "select" => self.select(element),
2618 "expand" => self.expand(element),
2619 "collapse" => self.collapse(element),
2620 "show_menu" => self.show_menu(element),
2621 "increment" => self.increment(element),
2622 "decrement" => self.decrement(element),
2623 "scroll_into_view" => self.scroll_into_view(element),
2624 "activate" => self.activate(element),
2625 "minimize" => self.minimize(element),
2626 "maximize" => self.maximize(element),
2627 "enter_fullscreen" => self.enter_fullscreen(element),
2628 "restore" => self.restore(element),
2629 "close" => self.close(element),
2630 "move_to" => Err(Error::InvalidActionData {
2634 message: "perform_action(\"move_to\") requires coordinates; call move_to(x, y)"
2635 .to_string(),
2636 }),
2637 "resize_to" => Err(Error::InvalidActionData {
2638 message: "perform_action(\"resize_to\") requires dimensions; call \
2639 resize_to(width, height)"
2640 .to_string(),
2641 }),
2642 _ => Err(Error::ActionNotSupported {
2643 action: action.to_string(),
2644 role: element.role,
2645 }),
2646 }
2647 }
2648
2649 fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
2650 let pid = element.pid.ok_or(Error::Platform {
2651 code: -1,
2652 message: "Element has no PID for subscribe".to_string(),
2653 })?;
2654 let app_name = element.name.clone().unwrap_or_default();
2655 crate::events::subscribe_for_pid(self, pid, app_name)
2656 }
2657}
2658
2659fn role_has_value(role: Role) -> bool {
2662 !matches!(
2663 role,
2664 Role::Application
2665 | Role::Window
2666 | Role::Dialog
2667 | Role::Group
2668 | Role::MenuBar
2669 | Role::Toolbar
2670 | Role::TabGroup
2671 | Role::SplitGroup
2672 | Role::Table
2673 | Role::TableRow
2674 | Role::Separator
2675 )
2676}
2677
2678fn role_has_actions(role: Role) -> bool {
2690 matches!(
2691 role,
2692 Role::Button
2693 | Role::CheckBox
2694 | Role::RadioButton
2695 | Role::MenuItem
2696 | Role::Link
2697 | Role::ComboBox
2698 | Role::TextField
2699 | Role::TextArea
2700 | Role::SpinButton
2701 | Role::Tab
2702 | Role::TreeItem
2703 | Role::ListItem
2704 | Role::ScrollBar
2705 | Role::Slider
2706 | Role::Menu
2707 | Role::Image
2708 | Role::Unknown
2709 )
2710}
2711
2712fn grab_focus_result(
2724 call: std::result::Result<zbus::Message, zbus::Error>,
2725 verb: &str,
2726 action: &str,
2727 role: Role,
2728) -> Result<zbus::Message> {
2729 call.map_err(|e| match e {
2730 zbus::Error::MethodError(name, _, _) => {
2731 grab_focus_error(name.as_str().to_string(), verb, action, role)
2732 }
2733 other => Error::Platform {
2734 code: -1,
2735 message: format!("Component.GrabFocus while {verb} failed: {other}"),
2736 },
2737 })
2738}
2739
2740fn grab_focus_error(name: String, verb: &str, action: &str, role: Role) -> Error {
2743 if name == "org.freedesktop.DBus.Error.NotSupported" {
2744 Error::ActionNotSupported {
2745 action: action.to_string(),
2746 role,
2747 }
2748 } else {
2749 Error::Platform {
2750 code: -1,
2751 message: format!("Component.GrabFocus while {verb} failed: {name}"),
2752 }
2753 }
2754}
2755
2756pub(crate) fn map_atspi_role(role_name: &str) -> Role {
2758 match role_name.to_lowercase().as_str() {
2759 "application" => Role::Application,
2760 "window" | "frame" => Role::Window,
2761 "dialog" | "file chooser" => Role::Dialog,
2762 "alert" | "notification" | "info bar" => Role::Alert,
2763 "push button" | "push button menu" => Role::Button,
2764 "toggle button" => Role::Switch,
2765 "check box" | "check menu item" => Role::CheckBox,
2766 "radio button" | "radio menu item" => Role::RadioButton,
2767 "entry" | "password text" => Role::TextField,
2768 "spin button" | "spinbutton" => Role::SpinButton,
2769 "text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
2775 "label" | "static" | "caption" => Role::StaticText,
2776 "combo box" | "combobox" => Role::ComboBox,
2777 "list" | "list box" | "listbox" => Role::List,
2779 "list item" => Role::ListItem,
2780 "menu" | "popup menu" => Role::Menu,
2781 "menu item" | "tearoff menu item" => Role::MenuItem,
2782 "menu bar" => Role::MenuBar,
2783 "page tab" => Role::Tab,
2784 "page tab list" => Role::TabGroup,
2785 "table" | "tree table" => Role::Table,
2786 "table row" => Role::TableRow,
2787 "table cell"
2790 | "table column header"
2791 | "table row header"
2792 | "column header"
2793 | "row header" => Role::TableCell,
2794 "tool bar" => Role::Toolbar,
2795 "scroll bar" => Role::ScrollBar,
2796 "slider" => Role::Slider,
2797 "image" | "icon" | "desktop icon" => Role::Image,
2798 "link" => Role::Link,
2799 "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
2800 | "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
2801 | "comment" | "title bar" | "calendar" => Role::Group,
2802 "progress bar" | "level bar" => Role::ProgressBar,
2803 "tree item" => Role::TreeItem,
2804 "document web"
2805 | "document frame"
2806 | "document spreadsheet"
2807 | "document presentation"
2808 | "document text"
2809 | "document email" => Role::WebArea,
2810 "heading" => Role::Heading,
2811 "separator" => Role::Separator,
2812 "split pane" => Role::SplitGroup,
2813 "tooltip" | "tool tip" => Role::Tooltip,
2814 "status bar" | "statusbar" => Role::Status,
2815 "landmark" | "navigation" => Role::Navigation,
2816 _ => xa11y_core::unknown_role(role_name),
2817 }
2818}
2819
2820const ATSPI_ROLE_FRAME: u32 = 23;
2827
2828pub(crate) fn map_atspi_role_number(role: u32) -> Role {
2831 match role {
2832 2 => Role::Alert, 5 => Role::Group, 7 => Role::CheckBox, 8 => Role::CheckBox, 10 => Role::TableCell, 11 => Role::ComboBox, 13 => Role::Image, 16 => Role::Dialog, 19 => Role::Dialog, 20 => Role::Group, 23 => Role::Window, 26 => Role::Image, 27 => Role::Image, 29 => Role::StaticText, 31 => Role::List, 32 => Role::ListItem, 33 => Role::Menu, 34 => Role::MenuBar, 35 => Role::MenuItem, 37 => Role::Tab, 38 => Role::TabGroup, 39 => Role::Group, 40 => Role::TextField, 41 => Role::Menu, 42 => Role::ProgressBar, 43 => Role::Button, 44 => Role::RadioButton, 45 => Role::RadioButton, 47 => Role::TableCell, 48 => Role::ScrollBar, 49 => Role::Group, 50 => Role::Separator, 51 => Role::Slider, 52 => Role::SpinButton, 53 => Role::SplitGroup, 54 => Role::Status, 55 => Role::Table, 56 => Role::TableCell, 57 => Role::TableCell, 58 => Role::TableCell, 59 => Role::MenuItem, 60 => Role::TextArea, 61 => Role::TextArea, 62 => Role::Switch, 63 => Role::Toolbar, 64 => Role::Tooltip, 65 => Role::Group, 66 => Role::Table, 67 => Role::Unknown, 68 => Role::Group, 69 => Role::Window, 71 => Role::Group, 72 => Role::Group, 73 => Role::Group, 75 => Role::Application, 78 => Role::TextArea, 79 => Role::TextField, 81 => Role::StaticText, 82 => Role::WebArea, 83 => Role::Heading, 85 => Role::Group, 86 => Role::Group, 87 => Role::Group, 88 => Role::Link, 90 => Role::TableRow, 91 => Role::TreeItem, 92 => Role::WebArea, 93 => Role::WebArea, 94 => Role::WebArea, 95 => Role::WebArea, 96 => Role::WebArea, 97 => Role::Group, 98 => Role::List, 99 => Role::Group, 101 => Role::Alert, 102 => Role::Alert, 103 => Role::ProgressBar, 104 => Role::Group, 105 => Role::Group, 110 => Role::Navigation, 116 => Role::StaticText, 129 => Role::Button, _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
2926 }
2927}
2928
2929const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
2933
2934const GTK_FALLBACK_MAX_NODES: usize = 200;
2937
2938fn is_actionable_atspi_role(role: &str) -> bool {
2943 matches!(
2944 role,
2945 "push button"
2946 | "toggle button"
2947 | "check box"
2948 | "radio button"
2949 | "menu item"
2950 | "check menu item"
2951 | "radio menu item"
2952 | "link"
2953 | "page tab"
2954 | "tab"
2955 | "list item"
2956 | "tree item"
2957 )
2958}
2959
2960fn is_never_descend_atspi_role(role: &str) -> bool {
2965 matches!(
2966 role,
2967 "label" | "separator" | "image" | "icon" | "static" | "caption"
2968 )
2969}
2970
2971fn is_dock_frame(role_number: u32, attributes: &HashMap<String, String>) -> bool {
2985 role_number == ATSPI_ROLE_FRAME && attributes.get("window-type").is_some_and(|t| t == "dock")
2986}
2987
2988fn children_call_error(err: zbus::Error, aref: &AccessibleRef) -> Error {
2993 match &err {
2994 zbus::Error::MethodError(name, ..)
2995 if name.as_str() == "org.freedesktop.DBus.Error.UnknownObject" =>
2996 {
2997 Error::ElementStale {
2998 selector: format!("{}{}", aref.bus_name, aref.path),
2999 }
3000 }
3001 _ => Error::Platform {
3002 code: -1,
3003 message: format!("GetChildren failed: {err}"),
3004 },
3005 }
3006}
3007
3008const ABSENT_MEMBER_ERRORS: [&str; 3] = [
3017 "org.freedesktop.DBus.Error.UnknownMethod",
3018 "org.freedesktop.DBus.Error.UnknownInterface",
3019 "org.freedesktop.DBus.Error.UnknownObject",
3020];
3021
3022const PEER_GONE_ERRORS: [&str; 2] = [
3037 "org.freedesktop.DBus.Error.ServiceUnknown",
3038 "org.freedesktop.DBus.Error.NameHasNoOwner",
3039];
3040
3041fn is_peer_gone(err: &zbus::Error) -> bool {
3047 match err {
3048 zbus::Error::MethodError(name, ..) => PEER_GONE_ERRORS.contains(&name.as_str()),
3049 _ => false,
3050 }
3051}
3052
3053fn is_absent_member(err: &zbus::Error) -> bool {
3062 match err {
3063 zbus::Error::MethodError(name, ..) => ABSENT_MEMBER_ERRORS.contains(&name.as_str()),
3064 _ => false,
3065 }
3066}
3067
3068fn classify_failure(app: &ElementData, top: &AccessibleRef, err: Error) -> Error {
3076 let name = app.name.as_deref().unwrap_or("<unnamed>");
3077 let pid = app.pid.map(|p| format!(" pid={p}")).unwrap_or_default();
3078 Error::Platform {
3079 code: match &err {
3080 Error::Platform { code, .. } => *code,
3081 _ => -1,
3082 },
3083 message: format!(
3084 "could not classify top-level {} of application \"{name}\"{pid} as a shell \
3085 surface: {err}",
3086 top.path
3087 ),
3088 }
3089}
3090
3091fn map_atspi_action_name(action_name: &str) -> Option<String> {
3105 let lower = action_name.to_lowercase();
3114 let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
3115 let canonical = match collapsed.as_str() {
3116 "click" | "activate" | "press" | "invoke" | "dodefault" => "press",
3117 "toggle" | "check" | "uncheck" => "toggle",
3118 "expand" | "open" => "expand",
3119 "collapse" | "close" => "collapse",
3120 "select" => "select",
3121 "menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
3122 "increment" => "increment",
3123 "decrement" => "decrement",
3124 _ => return None,
3125 };
3126 Some(canonical.to_string())
3127}
3128
3129fn refine_menu_role(role: Role, actions: &[String]) -> Role {
3137 if role == Role::Menu && actions.iter().any(|action| action == "press") {
3138 Role::MenuItem
3139 } else {
3140 role
3141 }
3142}
3143
3144#[cfg(test)]
3145mod tests {
3146 use super::*;
3147
3148 #[test]
3149 fn test_role_mapping() {
3150 assert_eq!(map_atspi_role("push button"), Role::Button);
3151 assert_eq!(map_atspi_role("toggle button"), Role::Switch);
3152 assert_eq!(map_atspi_role("check box"), Role::CheckBox);
3153 assert_eq!(map_atspi_role("entry"), Role::TextField);
3154 assert_eq!(map_atspi_role("label"), Role::StaticText);
3155 assert_eq!(map_atspi_role("window"), Role::Window);
3156 assert_eq!(map_atspi_role("frame"), Role::Window);
3157 assert_eq!(map_atspi_role("dialog"), Role::Dialog);
3158 assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
3159 assert_eq!(map_atspi_role("slider"), Role::Slider);
3160 assert_eq!(map_atspi_role("panel"), Role::Group);
3161 assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
3162 }
3163
3164 #[test]
3165 fn menu_with_native_activation_is_a_menu_item() {
3166 assert_eq!(
3167 refine_menu_role(Role::Menu, &["press".to_string()]),
3168 Role::MenuItem
3169 );
3170 }
3171
3172 #[test]
3173 fn non_activatable_menu_stays_a_container() {
3174 assert_eq!(refine_menu_role(Role::Menu, &[]), Role::Menu);
3175 assert_eq!(
3176 refine_menu_role(Role::Menu, &["show_menu".to_string()]),
3177 Role::Menu
3178 );
3179 }
3180
3181 #[test]
3182 fn activation_does_not_change_other_roles() {
3183 assert_eq!(
3184 refine_menu_role(Role::MenuBar, &["press".to_string()]),
3185 Role::MenuBar
3186 );
3187 assert_eq!(
3188 refine_menu_role(Role::MenuItem, &["press".to_string()]),
3189 Role::MenuItem
3190 );
3191 }
3192
3193 #[test]
3194 fn test_numeric_role_mapping() {
3195 assert_eq!(map_atspi_role_number(62), Role::Switch);
3198 assert_eq!(map_atspi_role_number(43), Role::Button); assert_eq!(map_atspi_role_number(7), Role::CheckBox);
3201 assert_eq!(map_atspi_role_number(67), Role::Unknown); }
3203
3204 #[test]
3205 fn grab_focus_notsupported_is_action_not_supported() {
3206 let declined = grab_focus_error(
3212 "org.freedesktop.DBus.Error.NotSupported".into(),
3213 "focusing",
3214 "focus",
3215 Role::Button,
3216 );
3217 match declined {
3218 Error::ActionNotSupported { action, role } => {
3219 assert_eq!(action, "focus");
3220 assert_eq!(role, Role::Button);
3221 }
3222 other => panic!("expected ActionNotSupported, got {other:?}"),
3223 }
3224
3225 let broken = grab_focus_error(
3226 "org.freedesktop.DBus.Error.UnknownMethod".into(),
3227 "activating",
3228 "activate",
3229 Role::Window,
3230 );
3231 match broken {
3232 Error::Platform { code, .. } => assert_eq!(code, -1),
3233 other => panic!("expected Platform error, got {other:?}"),
3234 }
3235 }
3236
3237 const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
3241 (0, "invalid"),
3242 (1, "accelerator label"),
3243 (2, "alert"),
3244 (3, "animation"),
3245 (4, "arrow"),
3246 (5, "calendar"),
3247 (6, "canvas"),
3248 (7, "check box"),
3249 (8, "check menu item"),
3250 (9, "color chooser"),
3251 (10, "column header"),
3252 (11, "combo box"),
3253 (12, "date editor"),
3254 (13, "desktop icon"),
3255 (14, "desktop frame"),
3256 (15, "dial"),
3257 (16, "dialog"),
3258 (17, "directory pane"),
3259 (18, "drawing area"),
3260 (19, "file chooser"),
3261 (20, "filler"),
3262 (21, "focus traversable"),
3263 (22, "font chooser"),
3264 (23, "frame"),
3265 (24, "glass pane"),
3266 (25, "html container"),
3267 (26, "icon"),
3268 (27, "image"),
3269 (28, "internal frame"),
3270 (29, "label"),
3271 (30, "layered pane"),
3272 (31, "list"),
3273 (32, "list item"),
3274 (33, "menu"),
3275 (34, "menu bar"),
3276 (35, "menu item"),
3277 (36, "option pane"),
3278 (37, "page tab"),
3279 (38, "page tab list"),
3280 (39, "panel"),
3281 (40, "password text"),
3282 (41, "popup menu"),
3283 (42, "progress bar"),
3284 (43, "push button"),
3285 (44, "radio button"),
3286 (45, "radio menu item"),
3287 (46, "root pane"),
3288 (47, "row header"),
3289 (48, "scroll bar"),
3290 (49, "scroll pane"),
3291 (50, "separator"),
3292 (51, "slider"),
3293 (52, "spin button"),
3294 (53, "split pane"),
3295 (54, "status bar"),
3296 (55, "table"),
3297 (56, "table cell"),
3298 (57, "table column header"),
3299 (58, "table row header"),
3300 (59, "tearoff menu item"),
3301 (60, "terminal"),
3302 (61, "text"),
3303 (62, "toggle button"),
3304 (63, "tool bar"),
3305 (64, "tool tip"),
3306 (65, "tree"),
3307 (66, "tree table"),
3308 (67, "unknown"),
3309 (68, "viewport"),
3310 (69, "window"),
3311 (70, "extended"),
3312 (71, "header"),
3313 (72, "footer"),
3314 (73, "paragraph"),
3315 (74, "ruler"),
3316 (75, "application"),
3317 (76, "autocomplete"),
3318 (77, "editbar"),
3319 (78, "embedded"),
3320 (79, "entry"),
3321 (80, "chart"),
3322 (81, "caption"),
3323 (82, "document frame"),
3324 (83, "heading"),
3325 (84, "page"),
3326 (85, "section"),
3327 (86, "redundant object"),
3328 (87, "form"),
3329 (88, "link"),
3330 (89, "input method window"),
3331 (90, "table row"),
3332 (91, "tree item"),
3333 (92, "document spreadsheet"),
3334 (93, "document presentation"),
3335 (94, "document text"),
3336 (95, "document web"),
3337 (96, "document email"),
3338 (97, "comment"),
3339 (98, "list box"),
3340 (99, "grouping"),
3341 (100, "image map"),
3342 (101, "notification"),
3343 (102, "info bar"),
3344 (103, "level bar"),
3345 (104, "title bar"),
3346 (105, "block quote"),
3347 (106, "audio"),
3348 (107, "video"),
3349 (108, "definition"),
3350 (109, "article"),
3351 (110, "landmark"),
3352 (111, "log"),
3353 (112, "marquee"),
3354 (113, "math"),
3355 (114, "rating"),
3356 (115, "timer"),
3357 (116, "static"),
3358 (117, "math fraction"),
3359 (118, "math root"),
3360 (119, "subscript"),
3361 (120, "superscript"),
3362 (121, "description list"),
3363 (122, "description term"),
3364 (123, "description value"),
3365 (124, "footnote"),
3366 (125, "content deletion"),
3367 (126, "content insertion"),
3368 (127, "mark"),
3369 (128, "suggestion"),
3370 (129, "push button menu"),
3371 ];
3372
3373 #[test]
3374 fn numeric_and_name_role_maps_agree() {
3375 for &(num, name) in ATSPI_ROLE_ENUM {
3382 let by_num = map_atspi_role_number(num);
3383 let by_name = map_atspi_role(name);
3384 assert_eq!(
3385 by_num, by_name,
3386 "AT-SPI role {num} ({name:?}) diverges: numeric map gives \
3387 {by_num:?}, name map gives {by_name:?}"
3388 );
3389 }
3390 }
3391
3392 #[test]
3393 fn test_action_name_mapping() {
3394 assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
3395 assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
3396 assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
3397 assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
3398 assert_eq!(
3400 map_atspi_action_name("doDefault"),
3401 Some("press".to_string())
3402 );
3403 assert_eq!(
3404 map_atspi_action_name("do_default"),
3405 Some("press".to_string())
3406 );
3407 assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
3408 assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
3409 assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
3410 assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
3411 assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
3412 assert_eq!(
3413 map_atspi_action_name("collapse"),
3414 Some("collapse".to_string())
3415 );
3416 assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
3417 assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
3418 assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
3419 assert_eq!(
3420 map_atspi_action_name("showmenu"),
3421 Some("show_menu".to_string())
3422 );
3423 assert_eq!(
3424 map_atspi_action_name("popup"),
3425 Some("show_menu".to_string())
3426 );
3427 assert_eq!(
3428 map_atspi_action_name("show menu"),
3429 Some("show_menu".to_string())
3430 );
3431 assert_eq!(
3434 map_atspi_action_name("showContextMenu"),
3435 Some("show_menu".to_string())
3436 );
3437 assert_eq!(
3438 map_atspi_action_name("show_context_menu"),
3439 Some("show_menu".to_string())
3440 );
3441 assert_eq!(
3442 map_atspi_action_name("increment"),
3443 Some("increment".to_string())
3444 );
3445 assert_eq!(
3446 map_atspi_action_name("decrement"),
3447 Some("decrement".to_string())
3448 );
3449 assert_eq!(map_atspi_action_name("foobar"), None);
3450 }
3451
3452 #[test]
3455 fn test_action_name_aliases_roundtrip() {
3456 let atspi_names = [
3457 "click",
3458 "activate",
3459 "press",
3460 "invoke",
3461 "doDefault",
3462 "do_default",
3463 "toggle",
3464 "check",
3465 "uncheck",
3466 "expand",
3467 "open",
3468 "collapse",
3469 "close",
3470 "select",
3471 "menu",
3472 "showmenu",
3473 "showContextMenu",
3474 "show_context_menu",
3475 "popup",
3476 "show menu",
3477 "increment",
3478 "decrement",
3479 ];
3480 for name in atspi_names {
3481 let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
3482 panic!("AT-SPI2 name {:?} should map to a canonical name", name)
3483 });
3484 let back = map_atspi_action_name(&canonical)
3486 .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
3487 assert_eq!(
3488 canonical, back,
3489 "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
3490 name, canonical, back, canonical
3491 );
3492 }
3493 }
3494
3495 #[test]
3497 fn test_action_name_case_insensitive() {
3498 assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
3499 assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
3500 assert_eq!(
3501 map_atspi_action_name("Increment"),
3502 Some("increment".to_string())
3503 );
3504 }
3505
3506 #[test]
3511 fn test_gtk_fallback_actionable_roles() {
3512 for role in [
3513 "push button",
3514 "toggle button",
3515 "check box",
3516 "radio button",
3517 "menu item",
3518 "link",
3519 "tab",
3520 "list item",
3521 "tree item",
3522 ] {
3523 assert!(
3524 is_actionable_atspi_role(role),
3525 "{role:?} should be actionable"
3526 );
3527 }
3528 }
3529
3530 #[test]
3535 fn test_gtk_fallback_non_actionable_roles() {
3536 for role in [
3537 "label",
3538 "panel",
3539 "filler",
3540 "section",
3541 "group",
3542 "image",
3543 "separator",
3544 "static",
3545 "frame",
3546 "window",
3547 ] {
3548 assert!(
3549 !is_actionable_atspi_role(role),
3550 "{role:?} must not be treated as actionable"
3551 );
3552 }
3553 }
3554
3555 #[test]
3559 fn test_gtk_fallback_never_descend_roles() {
3560 for role in ["label", "separator", "image", "icon", "static", "caption"] {
3561 assert!(
3562 is_never_descend_atspi_role(role),
3563 "{role:?} must block BFS descent"
3564 );
3565 }
3566 for role in ["panel", "filler", "section", "group", "frame"] {
3569 assert!(
3570 !is_never_descend_atspi_role(role),
3571 "container role {role:?} must remain descendable"
3572 );
3573 }
3574 }
3575
3576 fn attrs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3577 pairs
3578 .iter()
3579 .map(|(k, v)| (k.to_string(), v.to_string()))
3580 .collect()
3581 }
3582
3583 #[test]
3587 fn test_dock_frame_detection() {
3588 assert!(is_dock_frame(
3589 ATSPI_ROLE_FRAME,
3590 &attrs(&[("toolkit", "GTK"), ("window-type", "dock")])
3591 ));
3592 assert!(!is_dock_frame(
3594 ATSPI_ROLE_FRAME,
3595 &attrs(&[("window-type", "normal")])
3596 ));
3597 assert!(!is_dock_frame(
3599 ATSPI_ROLE_FRAME,
3600 &attrs(&[("toolkit", "GTK")])
3601 ));
3602 assert!(!is_dock_frame(ATSPI_ROLE_FRAME, &HashMap::new()));
3603 }
3604
3605 #[test]
3609 fn test_frame_role_number_is_the_atspi_frame_role() {
3610 let (number, name) = ATSPI_ROLE_ENUM
3611 .iter()
3612 .find(|(_, name)| *name == "frame")
3613 .copied()
3614 .expect("the AtspiRole table must carry `frame`");
3615 assert_eq!(number, ATSPI_ROLE_FRAME, "{name} moved");
3616 }
3617
3618 #[test]
3624 fn test_dock_frame_requires_frame_role() {
3625 let dock = attrs(&[("window-type", "dock")]);
3626 for (number, name) in ATSPI_ROLE_ENUM
3627 .iter()
3628 .filter(|(number, _)| *number != ATSPI_ROLE_FRAME)
3629 {
3630 assert!(
3631 !is_dock_frame(*number, &dock),
3632 "role {number} ({name:?}) must not be reported as a shell surface"
3633 );
3634 }
3635 }
3636
3637 fn method_error(name: &str) -> zbus::Error {
3640 let call = zbus::Message::method_call("/org/a11y/atspi/accessible/7", "GetAttributes")
3641 .expect("method-call builder")
3642 .serial(std::num::NonZeroU32::new(1).expect("nonzero"))
3643 .build(&())
3644 .expect("build method call");
3645 let reply = zbus::Message::error(&call.header(), name)
3646 .expect("error builder")
3647 .serial(std::num::NonZeroU32::new(2).expect("nonzero"))
3648 .build(&"boom")
3649 .expect("build error reply");
3650 zbus::Error::from(reply)
3651 }
3652
3653 #[test]
3654 fn children_errors_classify_only_unknown_object_as_stale() {
3655 let aref = AccessibleRef {
3656 bus_name: ":1.42".into(),
3657 path: "/org/a11y/atspi/accessible/7".into(),
3658 };
3659 let err = children_call_error(
3660 method_error("org.freedesktop.DBus.Error.UnknownObject"),
3661 &aref,
3662 );
3663 assert!(matches!(err, Error::ElementStale { selector }
3664 if selector.contains(&aref.bus_name) && selector.contains(&aref.path)));
3665 for name in [
3666 "org.freedesktop.DBus.Error.UnknownMethod",
3667 "org.freedesktop.DBus.Error.UnknownInterface",
3668 "org.freedesktop.DBus.Error.NoReply",
3669 "org.freedesktop.DBus.Error.Timeout",
3670 "org.freedesktop.DBus.Error.AccessDenied",
3671 "org.freedesktop.DBus.Error.ServiceUnknown",
3672 "org.freedesktop.DBus.Error.NameHasNoOwner",
3673 ] {
3674 assert!(
3675 matches!(children_call_error(method_error(name), &aref),
3676 Error::Platform { message, .. } if message.contains(name)),
3677 "{name}"
3678 );
3679 }
3680 assert!(matches!(
3682 children_call_error(
3683 zbus::Error::Failure("org.freedesktop.DBus.Error.UnknownObject".into()),
3684 &aref
3685 ),
3686 Error::Platform { .. }
3687 ));
3688 }
3689
3690 struct LiveTreePeer {
3694 name: &'static str,
3695 role: &'static str,
3696 children: Vec<&'static str>,
3697 children_error: Option<&'static str>,
3698 }
3699
3700 #[zbus::interface(name = "org.a11y.atspi.Accessible")]
3701 impl LiveTreePeer {
3702 fn get_children(
3703 &self,
3704 ) -> zbus::fdo::Result<Vec<(String, zbus::zvariant::OwnedObjectPath)>> {
3705 if let Some(error) = self.children_error {
3706 return Err(match error {
3707 "UnknownObject" => zbus::fdo::Error::UnknownObject("destroyed".into()),
3708 "NoReply" => zbus::fdo::Error::NoReply("unresponsive".into()),
3709 _ => panic!("unsupported test error"),
3710 });
3711 }
3712 Ok(self
3713 .children
3714 .iter()
3715 .map(|path| {
3716 (
3717 "org.test.LiveTree".into(),
3718 zbus::zvariant::OwnedObjectPath::try_from(*path).expect("test path"),
3719 )
3720 })
3721 .collect())
3722 }
3723
3724 fn get_role_name(&self) -> &str {
3725 self.role
3726 }
3727
3728 #[zbus(property)]
3729 fn name(&self) -> &str {
3730 self.name
3731 }
3732 }
3733
3734 fn live_tree_peer(
3735 error: &'static str,
3736 vanished_role: &'static str,
3737 ) -> (Connection, std::sync::Arc<LinuxProvider>, ElementData) {
3738 let (server_socket, client_socket) =
3739 std::os::unix::net::UnixStream::pair().expect("socket pair");
3740 let server = std::thread::spawn(move || {
3741 zbus::blocking::connection::Builder::async_io_unix_stream(server_socket)
3742 .server(zbus::Guid::generate())
3743 .expect("server GUID")
3744 .p2p()
3745 .serve_at(
3746 "/root",
3747 LiveTreePeer {
3748 name: "App",
3749 role: "application",
3750 children: vec!["/vanished", "/survivor"],
3751 children_error: None,
3752 },
3753 )
3754 .expect("root interface")
3755 .serve_at(
3756 "/vanished",
3757 LiveTreePeer {
3758 name: "Vanished",
3759 role: vanished_role,
3760 children: vec![],
3761 children_error: Some(error),
3762 },
3763 )
3764 .expect("vanished interface")
3765 .serve_at(
3766 "/survivor",
3767 LiveTreePeer {
3768 name: "Survivor",
3769 role: "push button",
3770 children: vec![],
3771 children_error: None,
3772 },
3773 )
3774 .expect("survivor interface")
3775 .build()
3776 .expect("server connection")
3777 });
3778 let connection = zbus::blocking::connection::Builder::async_io_unix_stream(client_socket)
3779 .p2p()
3780 .build()
3781 .expect("client connection");
3782 let server = server.join().expect("server thread");
3783 let provider = std::sync::Arc::new(LinuxProvider {
3784 a11y_bus: connection,
3785 handle_cache: Mutex::new(HashMap::new()),
3786 action_indices: Mutex::new(HashMap::new()),
3787 window_manager: WindowManager::new(),
3788 });
3789 let root_ref = AccessibleRef {
3790 bus_name: "org.test.LiveTree".into(),
3791 path: "/root".into(),
3792 };
3793 let root = provider.build_element_data(&root_ref, None);
3794 (server, provider, root)
3795 }
3796
3797 #[test]
3798 fn dump_tree_and_locators_survive_a_vanished_subtree() {
3799 let (_server, provider, root) = live_tree_peer("UnknownObject", "panel");
3800 let app = xa11y_core::App::from_data(provider.clone(), root);
3801 let tree = app.tree(None).expect("tree prunes vanished descendants");
3802 assert!(tree
3803 .children
3804 .iter()
3805 .any(|child| child.name.as_deref() == Some("Survivor")));
3806 let dump = app.dump(None).expect("dump survives UnknownObject");
3807 assert!(dump.contains("Survivor"));
3808 assert_eq!(
3809 app.locator("button")
3810 .elements()
3811 .expect("locator survives")
3812 .len(),
3813 1
3814 );
3815 let vanished = app
3816 .children()
3817 .expect("children")
3818 .into_iter()
3819 .find(|child| child.name.as_deref() == Some("Vanished"))
3820 .expect("vanished snapshot");
3821 assert!(vanished
3822 .children()
3823 .expect("destroyed node has no live children")
3824 .is_empty());
3825 assert!(matches!(
3826 provider.get_atspi_children(&AccessibleRef {
3827 bus_name: "org.test.LiveTree".into(),
3828 path: "/vanished".into(),
3829 }),
3830 Err(Error::ElementStale { .. })
3831 ));
3832 }
3833
3834 #[test]
3835 fn nested_application_disappearance_does_not_drop_live_siblings() {
3836 let (_server, provider, root) = live_tree_peer("UnknownObject", "application");
3837 let app = xa11y_core::App::from_data(provider, root);
3838 let dump = app.dump(None).expect("nested application pruned");
3839 assert!(dump.contains("Survivor"));
3840 assert!(!dump.contains("Vanished"));
3841 }
3842
3843 #[test]
3844 fn dump_and_tree_preserve_real_children_failures() {
3845 let (_server, provider, root) = live_tree_peer("NoReply", "panel");
3846 let app = xa11y_core::App::from_data(provider, root);
3847 for result in [app.tree(None).map(|_| ()), app.dump(None).map(|_| ())] {
3848 assert!(matches!(result, Err(Error::Platform { message, .. })
3849 if message.contains("NoReply")));
3850 }
3851 }
3852
3853 #[test]
3859 fn an_unimplemented_member_reads_as_absence() {
3860 for name in ABSENT_MEMBER_ERRORS {
3861 assert!(
3862 is_absent_member(&method_error(name)),
3863 "{name} must read as absence"
3864 );
3865 }
3866 }
3867
3868 #[test]
3876 fn a_departed_peer_is_absence_not_failure() {
3877 for name in [
3878 "org.freedesktop.DBus.Error.ServiceUnknown",
3879 "org.freedesktop.DBus.Error.NameHasNoOwner",
3880 ] {
3881 assert!(
3882 is_peer_gone(&method_error(name)),
3883 "{name} means the peer left"
3884 );
3885 assert!(!is_absent_member(&method_error(name)), "{name}");
3888 }
3889 }
3890
3891 #[test]
3892 fn a_hung_or_broken_peer_is_still_a_failure() {
3893 for name in [
3894 "org.freedesktop.DBus.Error.NoReply",
3895 "org.freedesktop.DBus.Error.Timeout",
3896 "org.freedesktop.DBus.Error.Disconnected",
3897 "org.freedesktop.DBus.Error.AccessDenied",
3898 "org.freedesktop.DBus.Error.UnknownMethod",
3899 ] {
3900 assert!(
3901 !is_peer_gone(&method_error(name)),
3902 "{name} must not be read as the peer having left"
3903 );
3904 }
3905 assert!(!is_peer_gone(&zbus::Error::InvalidReply));
3906 }
3907
3908 #[test]
3909 fn other_dbus_failures_are_not_absence() {
3910 for name in [
3911 "org.freedesktop.DBus.Error.ServiceUnknown",
3912 "org.freedesktop.DBus.Error.NoReply",
3913 "org.freedesktop.DBus.Error.Timeout",
3914 "org.freedesktop.DBus.Error.Disconnected",
3915 "org.freedesktop.DBus.Error.AccessDenied",
3916 "org.freedesktop.DBus.Error.UnknownProperty",
3917 ] {
3918 assert!(
3919 !is_absent_member(&method_error(name)),
3920 "{name} must stay a failure"
3921 );
3922 }
3923 assert!(!is_absent_member(&zbus::Error::InvalidReply));
3925 }
3926
3927 #[test]
3932 fn a_classification_failure_names_the_app_and_the_top_level() {
3933 let mut app = ElementData::for_role(Role::Application);
3934 app.name = Some("xfce4-panel".to_string());
3935 app.pid = Some(4242);
3936 let top = AccessibleRef {
3937 bus_name: ":1.42".to_string(),
3938 path: "/org/a11y/atspi/accessible/7".to_string(),
3939 };
3940 let err = classify_failure(
3941 &app,
3942 &top,
3943 Error::Platform {
3944 code: -1,
3945 message: "GetRole failed: connection closed".to_string(),
3946 },
3947 );
3948 let Error::Platform { code, message } = err else {
3949 panic!("a D-Bus failure must stay a platform error");
3950 };
3951 assert_eq!(code, -1);
3952 assert!(message.contains("xfce4-panel"), "{message}");
3953 assert!(message.contains("pid=4242"), "{message}");
3954 assert!(
3955 message.contains("/org/a11y/atspi/accessible/7"),
3956 "{message}"
3957 );
3958 assert!(message.contains("GetRole failed"), "{message}");
3960 }
3961
3962 #[test]
3965 fn a_classification_failure_survives_an_unnamed_app() {
3966 let app = ElementData::for_role(Role::Application);
3967 let top = AccessibleRef {
3968 bus_name: ":1.9".to_string(),
3969 path: "/org/a11y/atspi/accessible/3".to_string(),
3970 };
3971 let err = classify_failure(
3972 &app,
3973 &top,
3974 Error::Platform {
3975 code: -1,
3976 message: "GetAttributes failed".to_string(),
3977 },
3978 );
3979 let msg = err.to_string();
3980 assert!(msg.contains("<unnamed>"), "{msg}");
3981 assert!(msg.contains("/org/a11y/atspi/accessible/3"), "{msg}");
3982 }
3983}