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
14static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
16
17fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
20 match name {
21 "enabled" => Some(s.enabled.to_string()),
22 "visible" => Some(s.visible.to_string()),
23 "focused" => Some(s.focused.to_string()),
24 "active" => Some(s.active.to_string()),
25 "focusable" => Some(s.focusable.to_string()),
26 "selected" => Some(s.selected.to_string()),
27 "editable" => Some(s.editable.to_string()),
28 "modal" => Some(s.modal.to_string()),
29 "required" => Some(s.required.to_string()),
30 "busy" => Some(s.busy.to_string()),
31 "expanded" => s.expanded.map(|b| b.to_string()),
32 "checked" => s.checked.map(|c| {
33 match c {
34 Toggled::On => "on",
35 Toggled::Off => "off",
36 Toggled::Mixed => "mixed",
37 }
38 .to_string()
39 }),
40 _ => None,
41 }
42}
43
44pub struct LinuxProvider {
46 a11y_bus: Connection,
47 handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
49 action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
52}
53
54#[derive(Debug, Clone)]
56pub(crate) struct AccessibleRef {
57 pub(crate) bus_name: String,
58 pub(crate) path: String,
59}
60
61impl LinuxProvider {
62 pub fn new() -> Result<Self> {
67 let a11y_bus = Self::connect_a11y_bus()?;
68 Ok(Self {
69 a11y_bus,
70 handle_cache: Mutex::new(HashMap::new()),
71 action_indices: Mutex::new(HashMap::new()),
72 })
73 }
74
75 pub(crate) fn connect_a11y_bus() -> Result<Connection> {
76 if let Ok(session) = Connection::session() {
80 let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
81 .map_err(|e| Error::Platform {
82 code: -1,
83 message: format!("Failed to create a11y bus proxy: {}", e),
84 })?;
85
86 if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
87 if let Ok(address) = addr_reply.body().deserialize::<String>() {
88 if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
89 if let Ok(Ok(conn)) =
90 zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
91 {
92 return Ok(conn);
93 }
94 }
95 }
96 }
97
98 return Ok(session);
100 }
101
102 Connection::session().map_err(|e| Error::Platform {
103 code: -1,
104 message: format!("Failed to connect to D-Bus session bus: {}", e),
105 })
106 }
107
108 fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
109 zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
112 .destination(bus_name.to_owned())
113 .map_err(|e| Error::Platform {
114 code: -1,
115 message: format!("Failed to set proxy destination: {}", e),
116 })?
117 .path(path.to_owned())
118 .map_err(|e| Error::Platform {
119 code: -1,
120 message: format!("Failed to set proxy path: {}", e),
121 })?
122 .interface(interface.to_owned())
123 .map_err(|e| Error::Platform {
124 code: -1,
125 message: format!("Failed to set proxy interface: {}", e),
126 })?
127 .cache_properties(zbus::proxy::CacheProperties::No)
128 .build()
129 .map_err(|e| Error::Platform {
130 code: -1,
131 message: format!("Failed to create proxy: {}", e),
132 })
133 }
134
135 fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
138 let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
139 Ok(p) => p,
140 Err(_) => return false,
141 };
142 let reply = match proxy.call_method("GetInterfaces", &()) {
143 Ok(r) => r,
144 Err(_) => return false,
145 };
146 let interfaces: Vec<String> = match reply.body().deserialize() {
147 Ok(v) => v,
148 Err(_) => return false,
149 };
150 interfaces.iter().any(|i| i.contains(iface))
151 }
152
153 fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
155 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
156 let reply = proxy
157 .call_method("GetRole", &())
158 .map_err(|e| Error::Platform {
159 code: -1,
160 message: format!("GetRole failed: {}", e),
161 })?;
162 reply
163 .body()
164 .deserialize::<u32>()
165 .map_err(|e| Error::Platform {
166 code: -1,
167 message: format!("GetRole deserialize failed: {}", e),
168 })
169 }
170
171 fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
173 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
174 let reply = proxy
175 .call_method("GetRoleName", &())
176 .map_err(|e| Error::Platform {
177 code: -1,
178 message: format!("GetRoleName failed: {}", e),
179 })?;
180 reply
181 .body()
182 .deserialize::<String>()
183 .map_err(|e| Error::Platform {
184 code: -1,
185 message: format!("GetRoleName deserialize failed: {}", e),
186 })
187 }
188
189 fn check_chromium_a11y_enabled(
206 &self,
207 parent: &AccessibleRef,
208 role_hint: Option<Role>,
209 ) -> Result<()> {
210 let app_root = AccessibleRef {
211 bus_name: parent.bus_name.clone(),
212 path: "/org/a11y/atspi/accessible/root".to_string(),
213 };
214 let toolkit = match self
215 .make_proxy(
216 &app_root.bus_name,
217 &app_root.path,
218 "org.a11y.atspi.Application",
219 )
220 .ok()
221 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
222 {
223 Some(t) => t,
224 None => return Ok(()),
225 };
226 if !toolkit.eq_ignore_ascii_case("Chromium") {
227 return Ok(());
228 }
229 let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
230 if role != Role::Window {
231 return Ok(());
232 }
233 let app_name = self.get_name(&app_root).unwrap_or_default();
234 Err(Error::AccessibilityNotEnabled {
235 app: app_name,
236 instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
237 Relaunch with `--force-renderer-accessibility` (or set the env var \
238 `ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
239 .to_string(),
240 })
241 }
242
243 fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
245 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
246 proxy
247 .get_property::<String>("Name")
248 .map_err(|e| Error::Platform {
249 code: -1,
250 message: format!("Get Name property failed: {}", e),
251 })
252 }
253
254 fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
256 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
257 proxy
258 .get_property::<String>("Description")
259 .map_err(|e| Error::Platform {
260 code: -1,
261 message: format!("Get Description property failed: {}", e),
262 })
263 }
264
265 fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
269 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
270 let reply = proxy
271 .call_method("GetChildren", &())
272 .map_err(|e| Error::Platform {
273 code: -1,
274 message: format!("GetChildren failed: {}", e),
275 })?;
276 let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
277 reply.body().deserialize().map_err(|e| Error::Platform {
278 code: -1,
279 message: format!("GetChildren deserialize failed: {}", e),
280 })?;
281 Ok(children
282 .into_iter()
283 .map(|(bus_name, path)| AccessibleRef {
284 bus_name,
285 path: path.to_string(),
286 })
287 .collect())
288 }
289
290 fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
292 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
293 let reply = proxy
294 .call_method("GetState", &())
295 .map_err(|e| Error::Platform {
296 code: -1,
297 message: format!("GetState failed: {}", e),
298 })?;
299 reply
300 .body()
301 .deserialize::<Vec<u32>>()
302 .map_err(|e| Error::Platform {
303 code: -1,
304 message: format!("GetState deserialize failed: {}", e),
305 })
306 }
307
308 fn scan_role_number(&self, aref: &AccessibleRef) -> Result<Option<u32>> {
314 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
315 let reply = match proxy.call_method("GetRole", &()) {
316 Ok(reply) => reply,
317 Err(e) if is_peer_gone(&e) => return Ok(None),
318 Err(e) => {
319 return Err(Error::Platform {
320 code: -1,
321 message: format!("GetRole failed: {}", e),
322 })
323 }
324 };
325 reply
326 .body()
327 .deserialize::<u32>()
328 .map(Some)
329 .map_err(|e| Error::Platform {
330 code: -1,
331 message: format!("GetRole deserialize failed: {}", e),
332 })
333 }
334
335 #[allow(
342 clippy::option_option,
343 reason = "The two Nones mean different things \
344 — peer gone versus member absent — and collapsing them would erase \
345 exactly the distinction this function exists to make."
346 )]
347 fn scan_attributes(
348 &self,
349 aref: &AccessibleRef,
350 ) -> Result<Option<Option<HashMap<String, String>>>> {
351 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
352 let reply = match proxy.call_method("GetAttributes", &()) {
353 Ok(reply) => reply,
354 Err(e) if is_peer_gone(&e) => return Ok(None),
355 Err(e) if is_absent_member(&e) => return Ok(Some(None)),
356 Err(e) => {
357 return Err(Error::Platform {
358 code: -1,
359 message: format!("GetAttributes failed: {}", e),
360 })
361 }
362 };
363 reply
364 .body()
365 .deserialize::<HashMap<String, String>>()
366 .map(|a| Some(Some(a)))
367 .map_err(|e| Error::Platform {
368 code: -1,
369 message: format!("GetAttributes deserialize failed: {}", e),
370 })
371 }
372
373 fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
379 let state_bits = self.get_state(aref).unwrap_or_default();
380 let bits: u64 = if state_bits.len() >= 2 {
381 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
382 } else if state_bits.len() == 1 {
383 state_bits[0] as u64
384 } else {
385 0
386 };
387 const MULTI_LINE: u64 = 1 << 17;
389 (bits & MULTI_LINE) != 0
390 }
391
392 fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
401 match coarse {
402 Role::TextArea | Role::TextField => {
403 if self.is_multi_line(aref) {
404 Role::TextArea
405 } else {
406 Role::TextField
407 }
408 }
409 other => other,
410 }
411 }
412
413 fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
417 if !self.has_interface(aref, "Component") {
418 return None;
419 }
420 let proxy = self
421 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
422 .ok()?;
423 let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
426 let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
427 if w <= 0 && h <= 0 {
428 return None;
429 }
430 Some(
435 Rect {
436 x,
437 y,
438 width: w.max(0) as u32,
439 height: h.max(0) as u32,
440 }
441 .to_logical(crate::scale::coordinate_scale()),
442 )
443 }
444
445 fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
451 let mut actions = Vec::new();
452 let mut indices = HashMap::new();
453
454 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
456 let n_actions = proxy
458 .get_property::<i32>("NActions")
459 .or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
460 .unwrap_or(0);
461 for i in 0..n_actions {
462 if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
463 if let Ok(name) = reply.body().deserialize::<String>() {
464 if let Some(action_name) = map_atspi_action_name(&name) {
465 if !actions.contains(&action_name) {
466 indices.insert(action_name.clone(), i);
467 actions.push(action_name);
468 }
469 }
470 }
471 }
472 }
473 }
474
475 (actions, indices)
483 }
484
485 fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
492 let app_root = AccessibleRef {
493 bus_name: aref.bus_name.clone(),
494 path: "/org/a11y/atspi/accessible/root".to_string(),
495 };
496 self.make_proxy(
497 &app_root.bus_name,
498 &app_root.path,
499 "org.a11y.atspi.Application",
500 )
501 .ok()
502 .and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
503 .map(|t| t.eq_ignore_ascii_case("GTK"))
504 .unwrap_or(false)
505 }
506
507 fn find_gtk_press_fallback(
520 &self,
521 outer: &AccessibleRef,
522 outer_name: &str,
523 ) -> Option<(AccessibleRef, i32)> {
524 let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
525 queue.push_back((outer.clone(), 0));
526 let mut visited: usize = 0;
527 let mut shallowest_depth: Option<u32> = None;
528 let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
529
530 while let Some((node, depth)) = queue.pop_front() {
531 if let Some(best) = shallowest_depth {
533 if depth > best {
534 continue;
535 }
536 }
537 if visited > GTK_FALLBACK_MAX_NODES {
538 break;
539 }
540
541 let role_name = if depth == 0 {
542 String::new()
543 } else {
544 visited += 1;
545 self.get_role_name(&node).unwrap_or_default().to_lowercase()
546 };
547
548 if depth > 0 && is_actionable_atspi_role(&role_name) {
549 if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
550 match shallowest_depth {
551 Some(d) if depth < d => {
552 shallowest_depth = Some(depth);
553 hits.clear();
554 hits.push((node.clone(), idx));
555 }
556 Some(d) if depth == d => hits.push((node.clone(), idx)),
557 Some(_) => {}
558 None => {
559 shallowest_depth = Some(depth);
560 hits.push((node.clone(), idx));
561 }
562 }
563 }
564 }
565
566 let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
571 || (depth > 0 && is_never_descend_atspi_role(&role_name));
572 if stop_descending {
573 continue;
574 }
575 if let Ok(children) = self.get_atspi_children(&node) {
576 for c in children {
577 queue.push_back((c, depth + 1));
578 }
579 }
580 }
581
582 if hits.len() == 1 {
583 Some(hits.into_iter().next().unwrap())
584 } else {
585 None
586 }
587 }
588
589 fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
592 let (_, index_map) = self.get_actions(aref);
593 let idx = *index_map.get("press")?;
594 if !self.is_showing_visible_sensitive(aref) {
595 return None;
596 }
597 if !outer_name.is_empty() {
601 let inner_name = self.get_name(aref).unwrap_or_default();
602 if !inner_name.is_empty() && inner_name != outer_name {
603 return None;
604 }
605 }
606 Some(idx)
607 }
608
609 fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
614 let raw = self.get_state(aref).unwrap_or_default();
615 let bits: u64 = if raw.len() >= 2 {
616 (raw[0] as u64) | ((raw[1] as u64) << 32)
617 } else if raw.len() == 1 {
618 raw[0] as u64
619 } else {
620 0
621 };
622 const SENSITIVE: u64 = 1 << 24;
623 const SHOWING: u64 = 1 << 25;
624 const VISIBLE: u64 = 1 << 30;
625 (bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
626 }
627
628 fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
631 let text_value = self.get_text_content(aref);
635 if text_value.is_some() {
636 return text_value;
637 }
638 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
640 if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
641 return Some(val.to_string());
642 }
643 }
644 None
645 }
646
647 fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
649 let proxy = self
650 .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
651 .ok()?;
652 let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
653 if char_count > 0 {
654 let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
655 let text: String = reply.body().deserialize().ok()?;
656 if !text.is_empty() {
657 return Some(text);
658 }
659 }
660 None
661 }
662
663 fn cache_element(&self, aref: AccessibleRef) -> u64 {
665 let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
666 self.handle_cache
667 .lock()
668 .unwrap_or_else(|e| e.into_inner())
669 .insert(handle, aref);
670 handle
671 }
672
673 fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
675 self.handle_cache
676 .lock()
677 .unwrap_or_else(|e| e.into_inner())
678 .get(&handle)
679 .cloned()
680 .ok_or(Error::ElementStale {
681 selector: format!("handle:{}", handle),
682 })
683 }
684
685 fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
690 let role_name = self.get_role_name(aref).unwrap_or_default();
691 let role_num = self.get_role_number(aref).unwrap_or(0);
692 let role = {
693 let by_name = if !role_name.is_empty() {
694 map_atspi_role(&role_name)
695 } else {
696 Role::Unknown
697 };
698 let coarse = if by_name != Role::Unknown {
699 by_name
700 } else {
701 map_atspi_role_number(role_num)
702 };
703 self.refine_text_role(coarse, aref)
704 };
705
706 let (
710 ((mut name, value), description),
711 (
712 ((states, state_bits), bounds),
713 ((actions, action_index_map), (numeric_value, min_value, max_value)),
714 ),
715 ) = rayon::join(
716 || {
717 rayon::join(
718 || {
719 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
720 let value = if role_has_value(role) {
721 self.get_value(aref)
722 } else {
723 None
724 };
725 (name, value)
726 },
727 || self.get_description(aref).ok().filter(|s| !s.is_empty()),
728 )
729 },
730 || {
731 rayon::join(
732 || {
733 rayon::join(
734 || self.parse_states_with_bits(aref, role),
735 || {
736 if role != Role::Application {
737 self.get_extents(aref)
738 } else {
739 None
740 }
741 },
742 )
743 },
744 || {
745 rayon::join(
746 || {
747 if role_has_actions(role) {
748 self.get_actions(aref)
749 } else {
750 (vec![], HashMap::new())
751 }
752 },
753 || {
754 if matches!(
755 role,
756 Role::Slider
757 | Role::ProgressBar
758 | Role::ScrollBar
759 | Role::SpinButton
760 ) {
761 if let Ok(proxy) = self.make_proxy(
762 &aref.bus_name,
763 &aref.path,
764 "org.a11y.atspi.Value",
765 ) {
766 (
767 proxy.get_property::<f64>("CurrentValue").ok(),
768 proxy.get_property::<f64>("MinimumValue").ok(),
769 proxy.get_property::<f64>("MaximumValue").ok(),
770 )
771 } else {
772 (None, None, None)
773 }
774 } else {
775 (None, None, None)
776 }
777 },
778 )
779 },
780 )
781 },
782 );
783
784 if name.is_none() && role == Role::StaticText {
787 if let Some(ref v) = value {
788 name = Some(v.clone());
789 }
790 }
791
792 let raw = {
793 let raw_role = if role_name.is_empty() {
794 format!("role_num:{}", role_num)
795 } else {
796 role_name
797 };
798 let mut raw = HashMap::new();
799 raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
800 raw.insert(
801 "bus_name".into(),
802 serde_json::Value::String(aref.bus_name.clone()),
803 );
804 raw.insert(
805 "object_path".into(),
806 serde_json::Value::String(aref.path.clone()),
807 );
808 if let Some(ref n) = name {
811 raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
812 }
813 if let Some(ref v) = value {
814 raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
815 }
816 if let Some(ref d) = description {
817 raw.insert(
818 "atspi_description".into(),
819 serde_json::Value::String(d.clone()),
820 );
821 }
822 if (state_bits & (1 << 6)) != 0 {
828 raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
829 }
830 raw
831 };
832
833 let name = xa11y_core::text::strip_bidi_opt(name);
836 let value = xa11y_core::text::strip_bidi_opt(value);
837 let description = xa11y_core::text::strip_bidi_opt(description);
838
839 let handle = self.cache_element(aref.clone());
840 if !action_index_map.is_empty() {
841 self.action_indices
842 .lock()
843 .unwrap_or_else(|e| e.into_inner())
844 .insert(handle, action_index_map);
845 }
846
847 ElementParts {
848 role,
849 name,
850 value,
851 description,
852 bounds,
853 actions,
854 states,
855 numeric_value,
856 min_value,
857 max_value,
858 stable_id: Some(aref.path.clone()),
859 pid,
860 raw,
861 handle,
862 }
863 .into()
864 }
865
866 fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
868 let proxy = self.make_proxy(
870 &aref.bus_name,
871 &aref.path,
872 "org.freedesktop.DBus.Properties",
873 )?;
874 let reply = proxy
875 .call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
876 .map_err(|e| Error::Platform {
877 code: -1,
878 message: format!("Get Parent property failed: {}", e),
879 })?;
880 let variant: zbus::zvariant::OwnedValue =
882 reply.body().deserialize().map_err(|e| Error::Platform {
883 code: -1,
884 message: format!("Parent deserialize variant failed: {}", e),
885 })?;
886 let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
887 zbus::zvariant::Value::from(variant).try_into().map_err(
888 |e: zbus::zvariant::Error| Error::Platform {
889 code: -1,
890 message: format!("Parent deserialize struct failed: {}", e),
891 },
892 )?;
893 let path_str = path.as_str();
894 if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
895 return Ok(None);
896 }
897 if path_str == "/org/a11y/atspi/accessible/root" {
899 return Ok(None);
900 }
901 Ok(Some(AccessibleRef {
902 bus_name: bus,
903 path: path_str.to_string(),
904 }))
905 }
906
907 fn state_bits(&self, aref: &AccessibleRef) -> u64 {
912 let state_bits = self.get_state(aref).unwrap_or_default();
913 if state_bits.len() >= 2 {
914 (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
915 } else if state_bits.len() == 1 {
916 state_bits[0] as u64
917 } else {
918 0
919 }
920 }
921
922 fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
924 self.parse_states_with_bits(aref, role).0
925 }
926
927 fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
931 let bits = self.state_bits(aref);
932
933 const ACTIVE: u64 = 1 << 1;
935 const BUSY: u64 = 1 << 3;
936 const CHECKED: u64 = 1 << 4;
937 const EDITABLE: u64 = 1 << 7;
938 const ENABLED: u64 = 1 << 8;
939 const EXPANDABLE: u64 = 1 << 9;
940 const EXPANDED: u64 = 1 << 10;
941 const FOCUSABLE: u64 = 1 << 11;
942 const FOCUSED: u64 = 1 << 12;
943 const MODAL: u64 = 1 << 16;
944 const SELECTED: u64 = 1 << 23;
945 const SENSITIVE: u64 = 1 << 24;
946 const SHOWING: u64 = 1 << 25;
947 const VISIBLE: u64 = 1 << 30;
948 const INDETERMINATE: u64 = 1 << 32;
949 const REQUIRED: u64 = 1 << 33;
950
951 let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
952 let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
953
954 let checked = match role {
955 Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
956 if (bits & INDETERMINATE) != 0 {
957 Some(Toggled::Mixed)
958 } else if (bits & CHECKED) != 0 {
959 Some(Toggled::On)
960 } else {
961 Some(Toggled::Off)
962 }
963 }
964 _ => None,
965 };
966
967 let expanded = if (bits & EXPANDABLE) != 0 {
968 Some((bits & EXPANDED) != 0)
969 } else {
970 None
971 };
972
973 let states = StateParts {
974 enabled,
975 visible,
976 focused: (bits & FOCUSED) != 0,
977 active: (bits & ACTIVE) != 0,
980 checked,
981 selected: (bits & SELECTED) != 0,
982 expanded,
983 editable: (bits & EDITABLE) != 0,
984 focusable: (bits & FOCUSABLE) != 0,
985 modal: (bits & MODAL) != 0,
986 required: (bits & REQUIRED) != 0,
987 busy: (bits & BUSY) != 0,
988 }
989 .into();
990 (states, bits)
991 }
992
993 pub(crate) fn find_app_by_pid(&self, pid: u32) -> Result<AccessibleRef> {
998 let registry = AccessibleRef {
999 bus_name: "org.a11y.atspi.Registry".to_string(),
1000 path: "/org/a11y/atspi/accessible/root".to_string(),
1001 };
1002 let children = self.get_atspi_children(®istry)?;
1003
1004 for child in &children {
1005 if child.path == "/org/a11y/atspi/null" {
1006 continue;
1007 }
1008 if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
1014 if app_pid == pid {
1015 return Ok(child.clone());
1016 }
1017 }
1018 if let Ok(proxy) =
1020 self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
1021 {
1022 if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
1023 if app_pid as u32 == pid {
1024 return Ok(child.clone());
1025 }
1026 }
1027 }
1028 }
1029
1030 Err(Error::Platform {
1031 code: -1,
1032 message: format!("No application found with PID {}", pid),
1033 })
1034 }
1035
1036 fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
1038 let proxy = self
1039 .make_proxy(
1040 "org.freedesktop.DBus",
1041 "/org/freedesktop/DBus",
1042 "org.freedesktop.DBus",
1043 )
1044 .ok()?;
1045 let reply = proxy
1046 .call_method("GetConnectionUnixProcessID", &(bus_name,))
1047 .ok()?;
1048 let pid: u32 = reply.body().deserialize().ok()?;
1049 if pid > 0 {
1050 Some(pid)
1051 } else {
1052 None
1053 }
1054 }
1055
1056 fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
1058 let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
1059 proxy
1060 .call_method("DoAction", &(index,))
1061 .map_err(|e| Error::Platform {
1062 code: -1,
1063 message: format!("DoAction({}) failed: {}", index, e),
1064 })?;
1065 Ok(())
1066 }
1067
1068 fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
1070 self.action_indices
1071 .lock()
1072 .unwrap_or_else(|e| e.into_inner())
1073 .get(&handle)
1074 .and_then(|map| map.get(action).copied())
1075 .ok_or_else(|| Error::ActionNotSupported {
1076 action: action.to_string(),
1077 role: Role::Unknown, })
1079 }
1080
1081 fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
1087 if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
1088 return Some(pid);
1089 }
1090
1091 if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
1098 {
1099 if let Ok(pid) = proxy.get_property::<i32>("Id") {
1100 if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
1101 return Some(pid as u32);
1102 }
1103 }
1104 }
1105
1106 None
1107 }
1108
1109 fn resolve_role(&self, aref: &AccessibleRef) -> Role {
1111 let role_name = self.get_role_name(aref).unwrap_or_default();
1112 let by_name = if !role_name.is_empty() {
1113 map_atspi_role(&role_name)
1114 } else {
1115 Role::Unknown
1116 };
1117 let coarse = if by_name != Role::Unknown {
1118 by_name
1119 } else {
1120 let role_num = self.get_role_number(aref).unwrap_or(0);
1122 map_atspi_role_number(role_num)
1123 };
1124 self.refine_text_role(coarse, aref)
1125 }
1126
1127 fn matches_ref(
1141 &self,
1142 aref: &AccessibleRef,
1143 simple: &xa11y_core::selector::SimpleSelector,
1144 ) -> bool {
1145 let needs_role = simple.role.is_some()
1148 || simple
1149 .filters
1150 .iter()
1151 .any(|f| matches!(f.attr.as_str(), "role" | "checked"));
1152 let role = if needs_role {
1153 Some(self.resolve_role(aref))
1154 } else {
1155 None
1156 };
1157
1158 if let Some(ref role_match) = simple.role {
1159 match role_match {
1160 xa11y_core::selector::RoleMatch::Normalized(expected) => {
1161 if role != Some(*expected) {
1162 return false;
1163 }
1164 }
1165 xa11y_core::selector::RoleMatch::Platform(platform_role) => {
1166 let raw_role = self.get_role_name(aref).unwrap_or_default();
1167 if raw_role != *platform_role {
1168 return false;
1169 }
1170 }
1171 }
1172 }
1173
1174 let mut state_set: Option<StateSet> = None;
1175
1176 for filter in &simple.filters {
1177 let attr = filter.attr.as_str();
1178 let resolved: Option<Option<String>> = match attr {
1179 "role" => Some(role.map(|r| r.to_snake_case().to_string())),
1180 "name" => {
1181 let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
1182 let resolved = if name.is_none() && role == Some(Role::StaticText) {
1185 self.get_value(aref)
1186 } else {
1187 name
1188 };
1189 Some(resolved)
1190 }
1191 "value" => Some(self.get_value(aref)),
1192 "description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
1193 "enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
1194 | "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
1195 let s = state_set.get_or_insert_with(|| {
1196 self.parse_states(aref, role.unwrap_or(Role::Unknown))
1200 });
1201 Some(state_attr_to_string(attr, s))
1202 }
1203 _ => None, };
1205
1206 match resolved {
1207 Some(value) => {
1208 if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
1209 {
1210 return false;
1211 }
1212 }
1213 None => {
1214 let pid = None; let data = self.build_element_data(aref, pid);
1222 return xa11y_core::selector::matches_simple(&data, simple);
1223 }
1224 }
1225 }
1226
1227 true
1228 }
1229
1230 fn collect_matching_refs_group(
1240 &self,
1241 parent: &AccessibleRef,
1242 clauses: &[&xa11y_core::selector::SimpleSelector],
1243 depth: u32,
1244 max_depth: u32,
1245 limit: Option<usize>,
1246 ) -> Result<Vec<(usize, AccessibleRef)>> {
1247 if depth > max_depth {
1248 return Ok(vec![]);
1249 }
1250
1251 let children = self.get_atspi_children(parent)?;
1252
1253 let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
1261 let mut to_search: Vec<AccessibleRef> = Vec::new();
1262 for child in children {
1263 if child.path == "/org/a11y/atspi/null"
1264 || child.bus_name.is_empty()
1265 || child.path.is_empty()
1266 {
1267 continue;
1268 }
1269
1270 if !parent_is_registry {
1271 let child_role = self.get_role_name(&child).unwrap_or_default();
1272 if child_role == "application" {
1273 let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
1274 for gc in grandchildren {
1275 if gc.path == "/org/a11y/atspi/null"
1276 || gc.bus_name.is_empty()
1277 || gc.path.is_empty()
1278 {
1279 continue;
1280 }
1281 let gc_role = self.get_role_name(&gc).unwrap_or_default();
1282 if gc_role == "application" {
1283 continue;
1284 }
1285 to_search.push(gc);
1286 }
1287 continue;
1288 }
1289 }
1290 to_search.push(child);
1291 }
1292
1293 if to_search.is_empty() {
1296 self.check_chromium_a11y_enabled(parent, None)?;
1297 }
1298
1299 type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
1314 let per_child: Vec<ChildBatch> = to_search
1315 .par_iter()
1316 .map(|child| {
1317 let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
1318 for (idx, simple) in clauses.iter().enumerate() {
1319 if self.matches_ref(child, simple) {
1320 child_results.push((idx, child.clone()));
1321 }
1322 }
1323 match self.collect_matching_refs_group(child, clauses, depth + 1, max_depth, limit)
1324 {
1325 Ok(sub) => {
1326 child_results.extend(sub);
1327 (child_results, None)
1328 }
1329 Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
1330 Err(_) => (child_results, None),
1331 }
1332 })
1333 .collect();
1334
1335 let mut results = Vec::new();
1347 for (batch, maybe_err) in per_child {
1348 if let Some(err) = maybe_err {
1349 return Err(err);
1350 }
1351 for r in batch {
1352 results.push(r);
1353 if let Some(limit) = limit {
1354 if results.len() >= limit {
1355 return Ok(results);
1356 }
1357 }
1358 }
1359 }
1360 Ok(results)
1361 }
1362}
1363
1364impl Provider for LinuxProvider {
1365 fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
1366 match element {
1367 None => {
1368 self.list_apps()
1371 }
1372 Some(element_data) => {
1373 let aref = self.get_cached(element_data.handle)?;
1374 let children = self.get_atspi_children(&aref).unwrap_or_default();
1375 let pid = element_data.pid;
1376
1377 let mut to_build: Vec<AccessibleRef> = Vec::new();
1380 for child_ref in &children {
1381 if child_ref.path == "/org/a11y/atspi/null"
1382 || child_ref.bus_name.is_empty()
1383 || child_ref.path.is_empty()
1384 {
1385 continue;
1386 }
1387 let child_role = self.get_role_name(child_ref).unwrap_or_default();
1388 if child_role == "application" {
1389 let grandchildren = self.get_atspi_children(child_ref).unwrap_or_default();
1390 for gc_ref in grandchildren {
1391 if gc_ref.path == "/org/a11y/atspi/null"
1392 || gc_ref.bus_name.is_empty()
1393 || gc_ref.path.is_empty()
1394 {
1395 continue;
1396 }
1397 let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
1398 if gc_role == "application" {
1399 continue;
1400 }
1401 to_build.push(gc_ref);
1402 }
1403 continue;
1404 }
1405 to_build.push(child_ref.clone());
1406 }
1407
1408 if to_build.is_empty() {
1409 self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
1410 }
1411
1412 let results: Vec<ElementData> = to_build
1413 .par_iter()
1414 .map(|r| self.build_element_data(r, pid))
1415 .collect();
1416
1417 Ok(results)
1418 }
1419 }
1420 }
1421
1422 fn find_elements_group(
1423 &self,
1424 root: &ElementData,
1425 group: &xa11y_core::selector::SelectorGroup,
1426 limit: Option<usize>,
1427 max_depth: Option<u32>,
1428 ) -> Result<Vec<ElementData>> {
1429 if group.clauses.is_empty() {
1430 return Ok(vec![]);
1431 }
1432 if group.clauses.iter().any(|c| c.segments.is_empty()) {
1435 return Ok(vec![]);
1436 }
1437
1438 let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
1448
1449 let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
1450 .clauses
1451 .iter()
1452 .map(|c| &c.segments[0].simple)
1453 .collect();
1454
1455 let start_ref = self.get_cached(root.handle)?;
1456
1457 let phase1_limit = if group.clauses.len() == 1 {
1466 let clause = &group.clauses[0];
1467 let first = firsts[0];
1468 let outer = if clause.segments.len() == 1 {
1469 limit
1470 } else {
1471 None
1472 };
1473 match (outer, first.nth) {
1474 (Some(l), Some(n)) => Some(l.max(n)),
1475 (_, Some(n)) => Some(n),
1476 (l, None) => l,
1477 }
1478 } else {
1479 None
1480 };
1481
1482 let phase1: Vec<(usize, AccessibleRef)> =
1483 self.collect_matching_refs_group(&start_ref, &firsts, 0, max_depth_val, phase1_limit)?;
1484
1485 let pid_from_root = root.pid;
1486
1487 let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
1491 (0..group.clauses.len()).map(|_| Vec::new()).collect();
1492 for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
1493 by_clause[clause_idx].push((walk_pos, aref));
1494 }
1495
1496 let mut merged: Vec<(usize, ElementData)> = Vec::new();
1504 for (clause_idx, hits) in by_clause.into_iter().enumerate() {
1505 if hits.is_empty() {
1506 continue;
1507 }
1508 let clause = &group.clauses[clause_idx];
1509 let phase1_data: Vec<(usize, ElementData)> = hits
1511 .par_iter()
1512 .map(|(pos, aref)| {
1513 (*pos, self.build_element_data(aref, pid_from_root))
1517 })
1518 .collect();
1519
1520 if clause.segments.len() == 1 {
1521 let mut candidates = phase1_data;
1523 if let Some(nth) = clause.segments[0].simple.nth {
1524 if nth <= candidates.len() {
1525 let kept = candidates.remove(nth - 1);
1526 candidates.clear();
1527 candidates.push(kept);
1528 } else {
1529 candidates.clear();
1530 }
1531 }
1532 merged.extend(candidates);
1533 continue;
1534 }
1535
1536 for (anchor_pos, head) in phase1_data {
1541 let narrowed = self.narrow_multi_segment(
1542 vec![head],
1543 &clause.segments[1..],
1544 max_depth_val,
1545 None,
1546 )?;
1547 for n in narrowed {
1548 merged.push((anchor_pos, n));
1549 }
1550 }
1551 }
1552
1553 merged.sort_by_key(|(pos, _)| *pos);
1557 let mut seen: HashSet<(String, String)> = HashSet::new();
1558 let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
1559 for (_, data) in merged {
1560 if let Ok(aref) = self.get_cached(data.handle) {
1564 if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
1565 continue;
1566 }
1567 }
1568 out.push(data);
1569 }
1570 if let Some(l) = limit {
1571 out.truncate(l);
1572 }
1573 Ok(out)
1574 }
1575
1576 fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
1577 let aref = self.get_cached(element.handle)?;
1578 match self.get_atspi_parent(&aref)? {
1579 Some(parent_ref) => {
1580 let data = self.build_element_data(&parent_ref, element.pid);
1581 Ok(Some(data))
1582 }
1583 None => Ok(None),
1584 }
1585 }
1586
1587 fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
1624 let mut surfaces = Vec::new();
1625 'apps: for app in self.list_apps()? {
1626 let app_ref = self.get_cached(app.handle)?;
1627 let top_levels = match self.get_atspi_children(&app_ref) {
1628 Ok(t) => t,
1629 Err(_) => continue,
1633 };
1634 for top in top_levels
1635 .iter()
1636 .filter(|t| t.path != "/org/a11y/atspi/null")
1637 {
1638 let Some(role_number) = self
1649 .scan_role_number(top)
1650 .map_err(|e| classify_failure(&app, top, e))?
1651 else {
1652 continue 'apps;
1657 };
1658 let Some(attributes) = self
1667 .scan_attributes(top)
1668 .map_err(|e| classify_failure(&app, top, e))?
1669 else {
1670 continue 'apps;
1671 };
1672 let Some(attributes) = attributes else {
1673 continue;
1674 };
1675 if is_dock_frame(role_number, &attributes) {
1676 surfaces.push((
1677 ShellSurfaceKind::Panel,
1678 self.build_element_data(top, app.pid),
1679 ));
1680 }
1681 }
1682 }
1683 Ok(surfaces)
1684 }
1685
1686 fn list_apps(&self) -> Result<Vec<ElementData>> {
1693 let registry = AccessibleRef {
1694 bus_name: "org.a11y.atspi.Registry".to_string(),
1695 path: "/org/a11y/atspi/accessible/root".to_string(),
1696 };
1697 let children = self.get_atspi_children(®istry)?;
1698
1699 let valid: Vec<(&AccessibleRef, String)> = children
1701 .iter()
1702 .filter(|c| c.path != "/org/a11y/atspi/null")
1703 .filter_map(|c| {
1704 let name = self.get_name(c).unwrap_or_default();
1705 if name.is_empty() {
1706 None
1707 } else {
1708 Some((c, name))
1709 }
1710 })
1711 .collect();
1712
1713 let results: Vec<ElementData> = valid
1714 .par_iter()
1715 .map(|(child, app_name)| {
1716 let pid = self.get_app_pid(child);
1717 let mut data = self.build_element_data(child, pid);
1718 data.name = Some(app_name.clone());
1719 data
1720 })
1721 .collect();
1722
1723 Ok(results)
1724 }
1725
1726 fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
1738 let registry = AccessibleRef {
1739 bus_name: "org.a11y.atspi.Registry".to_string(),
1740 path: "/org/a11y/atspi/accessible/root".to_string(),
1741 };
1742 let children = self.get_atspi_children(®istry)?;
1743
1744 let mut total = 0usize;
1745 let mut unresolved = 0usize;
1746 for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
1747 total += 1;
1748 match self.get_app_pid(child) {
1749 Some(p) if p == pid => {
1750 let mut data = self.build_element_data(child, Some(p));
1751 let name = self.get_name(child).unwrap_or_default();
1753 if !name.is_empty() {
1754 data.name = Some(name);
1755 }
1756 return Ok(data);
1757 }
1758 Some(_) => {}
1759 None => unresolved += 1,
1760 }
1761 }
1762 Err(
1763 Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
1764 xa11y_core::Diagnosis::new().last_observed(format!(
1765 "{total} AT-SPI registry entries examined, {unresolved} without a \
1766 resolvable pid"
1767 )),
1768 ),
1769 )
1770 }
1771
1772 fn focused_app(&self) -> Result<ElementData> {
1785 const ACTIVE: u64 = 1 << 1;
1787
1788 let registry = AccessibleRef {
1789 bus_name: "org.a11y.atspi.Registry".to_string(),
1790 path: "/org/a11y/atspi/accessible/root".to_string(),
1791 };
1792 let apps = self.get_atspi_children(®istry)?;
1793
1794 for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
1795 let windows = match self.get_atspi_children(app) {
1796 Ok(w) => w,
1797 Err(_) => continue,
1800 };
1801 let has_active_window = windows
1802 .iter()
1803 .filter(|w| w.path != "/org/a11y/atspi/null")
1804 .any(|w| self.state_bits(w) & ACTIVE != 0);
1805 if has_active_window {
1806 let pid = self.get_app_pid(app);
1807 let mut data = self.build_element_data(app, pid);
1808 let name = self.get_name(app).unwrap_or_default();
1809 if !name.is_empty() {
1810 data.name = Some(name);
1811 }
1812 return Ok(data);
1813 }
1814 }
1815
1816 Err(Error::selector_not_matched("focused application"))
1817 }
1818
1819 fn press(&self, element: &ElementData) -> Result<()> {
1820 let target = self.get_cached(element.handle)?;
1821 if let Ok(index) = self.get_action_index(element.handle, "press") {
1823 return self.do_atspi_action_by_index(&target, index);
1824 }
1825 if element.role == Role::Button && self.is_gtk_toolkit(&target) {
1844 let outer_name = self.get_name(&target).unwrap_or_default();
1845 if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
1846 return self.do_atspi_action_by_index(&inner, index);
1847 }
1848 }
1849 Err(Error::ActionNotSupported {
1850 action: "press".to_string(),
1851 role: element.role,
1852 })
1853 }
1854
1855 fn focus(&self, element: &ElementData) -> Result<()> {
1856 let target = self.get_cached(element.handle)?;
1857 if let Ok(proxy) =
1861 self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
1862 {
1863 if let Ok(reply) = proxy.call_method("GrabFocus", &()) {
1864 if let Ok(true) = reply.body().deserialize::<bool>() {
1866 return Ok(());
1867 }
1868 }
1870 }
1871 if let Ok(index) = self.get_action_index(element.handle, "focus") {
1872 return self.do_atspi_action_by_index(&target, index);
1873 }
1874 Err(Error::ActionNotSupported {
1875 action: "focus".to_string(),
1876 role: element.role,
1877 })
1878 }
1879
1880 fn blur(&self, element: &ElementData) -> Result<()> {
1881 let target = self.get_cached(element.handle)?;
1882 if let Some(parent_ref) = self.get_atspi_parent(&target)? {
1886 if parent_ref.path != "/org/a11y/atspi/null" {
1887 let p = self.make_proxy(
1888 &parent_ref.bus_name,
1889 &parent_ref.path,
1890 "org.a11y.atspi.Component",
1891 )?;
1892 p.call_method("GrabFocus", &())
1893 .map_err(|e| Error::Platform {
1894 code: -1,
1895 message: format!("Component.GrabFocus on parent failed: {}", e),
1896 })?;
1897 return Ok(());
1898 }
1899 }
1900 Err(Error::ActionNotSupported {
1901 action: "blur".to_string(),
1902 role: element.role,
1903 })
1904 }
1905
1906 fn toggle(&self, element: &ElementData) -> Result<()> {
1907 let target = self.get_cached(element.handle)?;
1908 if let Ok(index) = self.get_action_index(element.handle, "toggle") {
1913 return self.do_atspi_action_by_index(&target, index);
1914 }
1915 if matches!(
1938 element.role,
1939 Role::CheckBox | Role::RadioButton | Role::Switch
1940 ) {
1941 if let Ok(index) = self.get_action_index(element.handle, "press") {
1942 return self.do_atspi_action_by_index(&target, index);
1943 }
1944 }
1945 Err(Error::ActionNotSupported {
1946 action: "toggle".to_string(),
1947 role: element.role,
1948 })
1949 }
1950
1951 fn select(&self, element: &ElementData) -> Result<()> {
1952 let target = self.get_cached(element.handle)?;
1953 let index = self
1954 .get_action_index(element.handle, "select")
1955 .map_err(|_| Error::ActionNotSupported {
1956 action: "select".to_string(),
1957 role: element.role,
1958 })?;
1959 self.do_atspi_action_by_index(&target, index)
1960 }
1961
1962 fn expand(&self, element: &ElementData) -> Result<()> {
1963 let target = self.get_cached(element.handle)?;
1964 let index = self
1965 .get_action_index(element.handle, "expand")
1966 .map_err(|_| Error::ActionNotSupported {
1967 action: "expand".to_string(),
1968 role: element.role,
1969 })?;
1970 self.do_atspi_action_by_index(&target, index)
1971 }
1972
1973 fn collapse(&self, element: &ElementData) -> Result<()> {
1974 let target = self.get_cached(element.handle)?;
1975 let index = self
1976 .get_action_index(element.handle, "collapse")
1977 .map_err(|_| Error::ActionNotSupported {
1978 action: "collapse".to_string(),
1979 role: element.role,
1980 })?;
1981 self.do_atspi_action_by_index(&target, index)
1982 }
1983
1984 fn show_menu(&self, element: &ElementData) -> Result<()> {
1985 let target = self.get_cached(element.handle)?;
1986 let index = self
1987 .get_action_index(element.handle, "show_menu")
1988 .map_err(|_| Error::ActionNotSupported {
1989 action: "show_menu".to_string(),
1990 role: element.role,
1991 })?;
1992 self.do_atspi_action_by_index(&target, index)
1993 }
1994
1995 fn increment(&self, element: &ElementData) -> Result<()> {
1996 let target = self.get_cached(element.handle)?;
1997 if let Ok(index) = self.get_action_index(element.handle, "increment") {
1999 return self.do_atspi_action_by_index(&target, index);
2000 }
2001 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2002 let current: f64 = proxy
2003 .get_property("CurrentValue")
2004 .map_err(|e| Error::Platform {
2005 code: -1,
2006 message: format!("Value.CurrentValue failed: {}", e),
2007 })?;
2008 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2009 let step = if step <= 0.0 { 1.0 } else { step };
2010 proxy
2011 .set_property("CurrentValue", current + step)
2012 .map_err(|e| Error::Platform {
2013 code: -1,
2014 message: format!("Value.SetCurrentValue failed: {}", e),
2015 })
2016 }
2017
2018 fn decrement(&self, element: &ElementData) -> Result<()> {
2019 let target = self.get_cached(element.handle)?;
2020 if let Ok(index) = self.get_action_index(element.handle, "decrement") {
2021 return self.do_atspi_action_by_index(&target, index);
2022 }
2023 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2024 let current: f64 = proxy
2025 .get_property("CurrentValue")
2026 .map_err(|e| Error::Platform {
2027 code: -1,
2028 message: format!("Value.CurrentValue failed: {}", e),
2029 })?;
2030 let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
2031 let step = if step <= 0.0 { 1.0 } else { step };
2032 proxy
2033 .set_property("CurrentValue", current - step)
2034 .map_err(|e| Error::Platform {
2035 code: -1,
2036 message: format!("Value.SetCurrentValue failed: {}", e),
2037 })
2038 }
2039
2040 fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
2041 let target = self.get_cached(element.handle)?;
2042 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
2043 proxy
2044 .call_method("ScrollTo", &(0u32,))
2045 .map_err(|e| Error::Platform {
2046 code: -1,
2047 message: format!("ScrollTo failed: {}", e),
2048 })?;
2049 Ok(())
2050 }
2051
2052 fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
2053 let target = self.get_cached(element.handle)?;
2054 let proxy = self
2055 .make_proxy(
2056 &target.bus_name,
2057 &target.path,
2058 "org.a11y.atspi.EditableText",
2059 )
2060 .map_err(|_| Error::TextValueNotSupported)?;
2061 if proxy.call_method("SetTextContents", &(value,)).is_ok() {
2063 return Ok(());
2064 }
2065 let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
2071 let msg = e.to_string();
2072 if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
2073 Error::TextValueNotSupported
2074 } else {
2075 Error::Platform {
2076 code: -1,
2077 message: format!("EditableText.{} failed: {}", op, msg),
2078 }
2079 }
2080 };
2081 if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
2082 return Err(classify_editable_text_error("DeleteText", e));
2083 }
2084 if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
2085 return Err(classify_editable_text_error("InsertText", e));
2086 }
2087 Ok(())
2088 }
2089
2090 fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
2091 let target = self.get_cached(element.handle)?;
2092 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
2093 proxy
2094 .set_property("CurrentValue", value)
2095 .map_err(|e| Error::Platform {
2096 code: -1,
2097 message: format!("SetValue failed: {}", e),
2098 })
2099 }
2100
2101 fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
2102 let target = self.get_cached(element.handle)?;
2103 let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
2106 let insert_pos = text_proxy
2107 .as_ref()
2108 .ok()
2109 .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
2110 .unwrap_or(-1); let proxy = self
2113 .make_proxy(
2114 &target.bus_name,
2115 &target.path,
2116 "org.a11y.atspi.EditableText",
2117 )
2118 .map_err(|_| Error::TextValueNotSupported)?;
2119 let pos = if insert_pos >= 0 {
2120 insert_pos
2121 } else {
2122 i32::MAX
2123 };
2124 proxy
2125 .call_method("InsertText", &(pos, text, text.len() as i32))
2126 .map_err(|e| Error::Platform {
2127 code: -1,
2128 message: format!("EditableText.InsertText failed: {}", e),
2129 })?;
2130 Ok(())
2131 }
2132
2133 fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
2134 let target = self.get_cached(element.handle)?;
2135 let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
2136 proxy
2137 .call_method("SetSelection", &(0i32, start as i32, end as i32))
2138 .map_err(|e| Error::Platform {
2139 code: -1,
2140 message: format!("Text.SetSelection failed: {}", e),
2141 })?;
2142 Ok(())
2143 }
2144
2145 fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
2146 match action {
2147 "press" => self.press(element),
2148 "focus" => self.focus(element),
2149 "blur" => self.blur(element),
2150 "toggle" => self.toggle(element),
2151 "select" => self.select(element),
2152 "expand" => self.expand(element),
2153 "collapse" => self.collapse(element),
2154 "show_menu" => self.show_menu(element),
2155 "increment" => self.increment(element),
2156 "decrement" => self.decrement(element),
2157 "scroll_into_view" => self.scroll_into_view(element),
2158 _ => Err(Error::ActionNotSupported {
2159 action: action.to_string(),
2160 role: element.role,
2161 }),
2162 }
2163 }
2164
2165 fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
2166 let pid = element.pid.ok_or(Error::Platform {
2167 code: -1,
2168 message: "Element has no PID for subscribe".to_string(),
2169 })?;
2170 let app_name = element.name.clone().unwrap_or_default();
2171 crate::events::subscribe_for_pid(self, pid, app_name)
2172 }
2173}
2174
2175fn role_has_value(role: Role) -> bool {
2178 !matches!(
2179 role,
2180 Role::Application
2181 | Role::Window
2182 | Role::Dialog
2183 | Role::Group
2184 | Role::MenuBar
2185 | Role::Toolbar
2186 | Role::TabGroup
2187 | Role::SplitGroup
2188 | Role::Table
2189 | Role::TableRow
2190 | Role::Separator
2191 )
2192}
2193
2194fn role_has_actions(role: Role) -> bool {
2197 matches!(
2198 role,
2199 Role::Button
2200 | Role::CheckBox
2201 | Role::RadioButton
2202 | Role::MenuItem
2203 | Role::Link
2204 | Role::ComboBox
2205 | Role::TextField
2206 | Role::TextArea
2207 | Role::SpinButton
2208 | Role::Tab
2209 | Role::TreeItem
2210 | Role::ListItem
2211 | Role::ScrollBar
2212 | Role::Slider
2213 | Role::Menu
2214 | Role::Image
2215 | Role::Unknown
2216 )
2217}
2218
2219pub(crate) fn map_atspi_role(role_name: &str) -> Role {
2221 match role_name.to_lowercase().as_str() {
2222 "application" => Role::Application,
2223 "window" | "frame" => Role::Window,
2224 "dialog" | "file chooser" => Role::Dialog,
2225 "alert" | "notification" | "info bar" => Role::Alert,
2226 "push button" | "push button menu" => Role::Button,
2227 "toggle button" => Role::Switch,
2228 "check box" | "check menu item" => Role::CheckBox,
2229 "radio button" | "radio menu item" => Role::RadioButton,
2230 "entry" | "password text" => Role::TextField,
2231 "spin button" | "spinbutton" => Role::SpinButton,
2232 "text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
2238 "label" | "static" | "caption" => Role::StaticText,
2239 "combo box" | "combobox" => Role::ComboBox,
2240 "list" | "list box" | "listbox" => Role::List,
2242 "list item" => Role::ListItem,
2243 "menu" | "popup menu" => Role::Menu,
2244 "menu item" | "tearoff menu item" => Role::MenuItem,
2245 "menu bar" => Role::MenuBar,
2246 "page tab" => Role::Tab,
2247 "page tab list" => Role::TabGroup,
2248 "table" | "tree table" => Role::Table,
2249 "table row" => Role::TableRow,
2250 "table cell"
2253 | "table column header"
2254 | "table row header"
2255 | "column header"
2256 | "row header" => Role::TableCell,
2257 "tool bar" => Role::Toolbar,
2258 "scroll bar" => Role::ScrollBar,
2259 "slider" => Role::Slider,
2260 "image" | "icon" | "desktop icon" => Role::Image,
2261 "link" => Role::Link,
2262 "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
2263 | "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
2264 | "comment" | "title bar" | "calendar" => Role::Group,
2265 "progress bar" | "level bar" => Role::ProgressBar,
2266 "tree item" => Role::TreeItem,
2267 "document web"
2268 | "document frame"
2269 | "document spreadsheet"
2270 | "document presentation"
2271 | "document text"
2272 | "document email" => Role::WebArea,
2273 "heading" => Role::Heading,
2274 "separator" => Role::Separator,
2275 "split pane" => Role::SplitGroup,
2276 "tooltip" | "tool tip" => Role::Tooltip,
2277 "status bar" | "statusbar" => Role::Status,
2278 "landmark" | "navigation" => Role::Navigation,
2279 _ => xa11y_core::unknown_role(role_name),
2280 }
2281}
2282
2283const ATSPI_ROLE_FRAME: u32 = 23;
2290
2291pub(crate) fn map_atspi_role_number(role: u32) -> Role {
2294 match role {
2295 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}")),
2389 }
2390}
2391
2392const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
2396
2397const GTK_FALLBACK_MAX_NODES: usize = 200;
2400
2401fn is_actionable_atspi_role(role: &str) -> bool {
2406 matches!(
2407 role,
2408 "push button"
2409 | "toggle button"
2410 | "check box"
2411 | "radio button"
2412 | "menu item"
2413 | "check menu item"
2414 | "radio menu item"
2415 | "link"
2416 | "page tab"
2417 | "tab"
2418 | "list item"
2419 | "tree item"
2420 )
2421}
2422
2423fn is_never_descend_atspi_role(role: &str) -> bool {
2428 matches!(
2429 role,
2430 "label" | "separator" | "image" | "icon" | "static" | "caption"
2431 )
2432}
2433
2434fn is_dock_frame(role_number: u32, attributes: &HashMap<String, String>) -> bool {
2448 role_number == ATSPI_ROLE_FRAME && attributes.get("window-type").is_some_and(|t| t == "dock")
2449}
2450
2451const ABSENT_MEMBER_ERRORS: [&str; 3] = [
2460 "org.freedesktop.DBus.Error.UnknownMethod",
2461 "org.freedesktop.DBus.Error.UnknownInterface",
2462 "org.freedesktop.DBus.Error.UnknownObject",
2463];
2464
2465const PEER_GONE_ERRORS: [&str; 2] = [
2480 "org.freedesktop.DBus.Error.ServiceUnknown",
2481 "org.freedesktop.DBus.Error.NameHasNoOwner",
2482];
2483
2484fn is_peer_gone(err: &zbus::Error) -> bool {
2490 match err {
2491 zbus::Error::MethodError(name, ..) => PEER_GONE_ERRORS.contains(&name.as_str()),
2492 _ => false,
2493 }
2494}
2495
2496fn is_absent_member(err: &zbus::Error) -> bool {
2505 match err {
2506 zbus::Error::MethodError(name, ..) => ABSENT_MEMBER_ERRORS.contains(&name.as_str()),
2507 _ => false,
2508 }
2509}
2510
2511fn classify_failure(app: &ElementData, top: &AccessibleRef, err: Error) -> Error {
2519 let name = app.name.as_deref().unwrap_or("<unnamed>");
2520 let pid = app.pid.map(|p| format!(" pid={p}")).unwrap_or_default();
2521 Error::Platform {
2522 code: match &err {
2523 Error::Platform { code, .. } => *code,
2524 _ => -1,
2525 },
2526 message: format!(
2527 "could not classify top-level {} of application \"{name}\"{pid} as a shell \
2528 surface: {err}",
2529 top.path
2530 ),
2531 }
2532}
2533
2534fn map_atspi_action_name(action_name: &str) -> Option<String> {
2548 let lower = action_name.to_lowercase();
2557 let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
2558 let canonical = match collapsed.as_str() {
2559 "click" | "activate" | "press" | "invoke" | "dodefault" => "press",
2560 "toggle" | "check" | "uncheck" => "toggle",
2561 "expand" | "open" => "expand",
2562 "collapse" | "close" => "collapse",
2563 "select" => "select",
2564 "menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
2565 "increment" => "increment",
2566 "decrement" => "decrement",
2567 _ => return None,
2568 };
2569 Some(canonical.to_string())
2570}
2571
2572#[cfg(test)]
2573mod tests {
2574 use super::*;
2575
2576 #[test]
2577 fn test_role_mapping() {
2578 assert_eq!(map_atspi_role("push button"), Role::Button);
2579 assert_eq!(map_atspi_role("toggle button"), Role::Switch);
2580 assert_eq!(map_atspi_role("check box"), Role::CheckBox);
2581 assert_eq!(map_atspi_role("entry"), Role::TextField);
2582 assert_eq!(map_atspi_role("label"), Role::StaticText);
2583 assert_eq!(map_atspi_role("window"), Role::Window);
2584 assert_eq!(map_atspi_role("frame"), Role::Window);
2585 assert_eq!(map_atspi_role("dialog"), Role::Dialog);
2586 assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
2587 assert_eq!(map_atspi_role("slider"), Role::Slider);
2588 assert_eq!(map_atspi_role("panel"), Role::Group);
2589 assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
2590 }
2591
2592 #[test]
2593 fn test_numeric_role_mapping() {
2594 assert_eq!(map_atspi_role_number(62), Role::Switch);
2597 assert_eq!(map_atspi_role_number(43), Role::Button); assert_eq!(map_atspi_role_number(7), Role::CheckBox);
2600 assert_eq!(map_atspi_role_number(67), Role::Unknown); }
2602
2603 const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
2607 (0, "invalid"),
2608 (1, "accelerator label"),
2609 (2, "alert"),
2610 (3, "animation"),
2611 (4, "arrow"),
2612 (5, "calendar"),
2613 (6, "canvas"),
2614 (7, "check box"),
2615 (8, "check menu item"),
2616 (9, "color chooser"),
2617 (10, "column header"),
2618 (11, "combo box"),
2619 (12, "date editor"),
2620 (13, "desktop icon"),
2621 (14, "desktop frame"),
2622 (15, "dial"),
2623 (16, "dialog"),
2624 (17, "directory pane"),
2625 (18, "drawing area"),
2626 (19, "file chooser"),
2627 (20, "filler"),
2628 (21, "focus traversable"),
2629 (22, "font chooser"),
2630 (23, "frame"),
2631 (24, "glass pane"),
2632 (25, "html container"),
2633 (26, "icon"),
2634 (27, "image"),
2635 (28, "internal frame"),
2636 (29, "label"),
2637 (30, "layered pane"),
2638 (31, "list"),
2639 (32, "list item"),
2640 (33, "menu"),
2641 (34, "menu bar"),
2642 (35, "menu item"),
2643 (36, "option pane"),
2644 (37, "page tab"),
2645 (38, "page tab list"),
2646 (39, "panel"),
2647 (40, "password text"),
2648 (41, "popup menu"),
2649 (42, "progress bar"),
2650 (43, "push button"),
2651 (44, "radio button"),
2652 (45, "radio menu item"),
2653 (46, "root pane"),
2654 (47, "row header"),
2655 (48, "scroll bar"),
2656 (49, "scroll pane"),
2657 (50, "separator"),
2658 (51, "slider"),
2659 (52, "spin button"),
2660 (53, "split pane"),
2661 (54, "status bar"),
2662 (55, "table"),
2663 (56, "table cell"),
2664 (57, "table column header"),
2665 (58, "table row header"),
2666 (59, "tearoff menu item"),
2667 (60, "terminal"),
2668 (61, "text"),
2669 (62, "toggle button"),
2670 (63, "tool bar"),
2671 (64, "tool tip"),
2672 (65, "tree"),
2673 (66, "tree table"),
2674 (67, "unknown"),
2675 (68, "viewport"),
2676 (69, "window"),
2677 (70, "extended"),
2678 (71, "header"),
2679 (72, "footer"),
2680 (73, "paragraph"),
2681 (74, "ruler"),
2682 (75, "application"),
2683 (76, "autocomplete"),
2684 (77, "editbar"),
2685 (78, "embedded"),
2686 (79, "entry"),
2687 (80, "chart"),
2688 (81, "caption"),
2689 (82, "document frame"),
2690 (83, "heading"),
2691 (84, "page"),
2692 (85, "section"),
2693 (86, "redundant object"),
2694 (87, "form"),
2695 (88, "link"),
2696 (89, "input method window"),
2697 (90, "table row"),
2698 (91, "tree item"),
2699 (92, "document spreadsheet"),
2700 (93, "document presentation"),
2701 (94, "document text"),
2702 (95, "document web"),
2703 (96, "document email"),
2704 (97, "comment"),
2705 (98, "list box"),
2706 (99, "grouping"),
2707 (100, "image map"),
2708 (101, "notification"),
2709 (102, "info bar"),
2710 (103, "level bar"),
2711 (104, "title bar"),
2712 (105, "block quote"),
2713 (106, "audio"),
2714 (107, "video"),
2715 (108, "definition"),
2716 (109, "article"),
2717 (110, "landmark"),
2718 (111, "log"),
2719 (112, "marquee"),
2720 (113, "math"),
2721 (114, "rating"),
2722 (115, "timer"),
2723 (116, "static"),
2724 (117, "math fraction"),
2725 (118, "math root"),
2726 (119, "subscript"),
2727 (120, "superscript"),
2728 (121, "description list"),
2729 (122, "description term"),
2730 (123, "description value"),
2731 (124, "footnote"),
2732 (125, "content deletion"),
2733 (126, "content insertion"),
2734 (127, "mark"),
2735 (128, "suggestion"),
2736 (129, "push button menu"),
2737 ];
2738
2739 #[test]
2740 fn numeric_and_name_role_maps_agree() {
2741 for &(num, name) in ATSPI_ROLE_ENUM {
2748 let by_num = map_atspi_role_number(num);
2749 let by_name = map_atspi_role(name);
2750 assert_eq!(
2751 by_num, by_name,
2752 "AT-SPI role {num} ({name:?}) diverges: numeric map gives \
2753 {by_num:?}, name map gives {by_name:?}"
2754 );
2755 }
2756 }
2757
2758 #[test]
2759 fn test_action_name_mapping() {
2760 assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
2761 assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
2762 assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
2763 assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
2764 assert_eq!(
2766 map_atspi_action_name("doDefault"),
2767 Some("press".to_string())
2768 );
2769 assert_eq!(
2770 map_atspi_action_name("do_default"),
2771 Some("press".to_string())
2772 );
2773 assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
2774 assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
2775 assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
2776 assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
2777 assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
2778 assert_eq!(
2779 map_atspi_action_name("collapse"),
2780 Some("collapse".to_string())
2781 );
2782 assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
2783 assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
2784 assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
2785 assert_eq!(
2786 map_atspi_action_name("showmenu"),
2787 Some("show_menu".to_string())
2788 );
2789 assert_eq!(
2790 map_atspi_action_name("popup"),
2791 Some("show_menu".to_string())
2792 );
2793 assert_eq!(
2794 map_atspi_action_name("show menu"),
2795 Some("show_menu".to_string())
2796 );
2797 assert_eq!(
2800 map_atspi_action_name("showContextMenu"),
2801 Some("show_menu".to_string())
2802 );
2803 assert_eq!(
2804 map_atspi_action_name("show_context_menu"),
2805 Some("show_menu".to_string())
2806 );
2807 assert_eq!(
2808 map_atspi_action_name("increment"),
2809 Some("increment".to_string())
2810 );
2811 assert_eq!(
2812 map_atspi_action_name("decrement"),
2813 Some("decrement".to_string())
2814 );
2815 assert_eq!(map_atspi_action_name("foobar"), None);
2816 }
2817
2818 #[test]
2821 fn test_action_name_aliases_roundtrip() {
2822 let atspi_names = [
2823 "click",
2824 "activate",
2825 "press",
2826 "invoke",
2827 "doDefault",
2828 "do_default",
2829 "toggle",
2830 "check",
2831 "uncheck",
2832 "expand",
2833 "open",
2834 "collapse",
2835 "close",
2836 "select",
2837 "menu",
2838 "showmenu",
2839 "showContextMenu",
2840 "show_context_menu",
2841 "popup",
2842 "show menu",
2843 "increment",
2844 "decrement",
2845 ];
2846 for name in atspi_names {
2847 let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
2848 panic!("AT-SPI2 name {:?} should map to a canonical name", name)
2849 });
2850 let back = map_atspi_action_name(&canonical)
2852 .unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
2853 assert_eq!(
2854 canonical, back,
2855 "AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
2856 name, canonical, back, canonical
2857 );
2858 }
2859 }
2860
2861 #[test]
2863 fn test_action_name_case_insensitive() {
2864 assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
2865 assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
2866 assert_eq!(
2867 map_atspi_action_name("Increment"),
2868 Some("increment".to_string())
2869 );
2870 }
2871
2872 #[test]
2877 fn test_gtk_fallback_actionable_roles() {
2878 for role in [
2879 "push button",
2880 "toggle button",
2881 "check box",
2882 "radio button",
2883 "menu item",
2884 "link",
2885 "tab",
2886 "list item",
2887 "tree item",
2888 ] {
2889 assert!(
2890 is_actionable_atspi_role(role),
2891 "{role:?} should be actionable"
2892 );
2893 }
2894 }
2895
2896 #[test]
2901 fn test_gtk_fallback_non_actionable_roles() {
2902 for role in [
2903 "label",
2904 "panel",
2905 "filler",
2906 "section",
2907 "group",
2908 "image",
2909 "separator",
2910 "static",
2911 "frame",
2912 "window",
2913 ] {
2914 assert!(
2915 !is_actionable_atspi_role(role),
2916 "{role:?} must not be treated as actionable"
2917 );
2918 }
2919 }
2920
2921 #[test]
2925 fn test_gtk_fallback_never_descend_roles() {
2926 for role in ["label", "separator", "image", "icon", "static", "caption"] {
2927 assert!(
2928 is_never_descend_atspi_role(role),
2929 "{role:?} must block BFS descent"
2930 );
2931 }
2932 for role in ["panel", "filler", "section", "group", "frame"] {
2935 assert!(
2936 !is_never_descend_atspi_role(role),
2937 "container role {role:?} must remain descendable"
2938 );
2939 }
2940 }
2941
2942 fn attrs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2943 pairs
2944 .iter()
2945 .map(|(k, v)| (k.to_string(), v.to_string()))
2946 .collect()
2947 }
2948
2949 #[test]
2953 fn test_dock_frame_detection() {
2954 assert!(is_dock_frame(
2955 ATSPI_ROLE_FRAME,
2956 &attrs(&[("toolkit", "GTK"), ("window-type", "dock")])
2957 ));
2958 assert!(!is_dock_frame(
2960 ATSPI_ROLE_FRAME,
2961 &attrs(&[("window-type", "normal")])
2962 ));
2963 assert!(!is_dock_frame(
2965 ATSPI_ROLE_FRAME,
2966 &attrs(&[("toolkit", "GTK")])
2967 ));
2968 assert!(!is_dock_frame(ATSPI_ROLE_FRAME, &HashMap::new()));
2969 }
2970
2971 #[test]
2975 fn test_frame_role_number_is_the_atspi_frame_role() {
2976 let (number, name) = ATSPI_ROLE_ENUM
2977 .iter()
2978 .find(|(_, name)| *name == "frame")
2979 .copied()
2980 .expect("the AtspiRole table must carry `frame`");
2981 assert_eq!(number, ATSPI_ROLE_FRAME, "{name} moved");
2982 }
2983
2984 #[test]
2990 fn test_dock_frame_requires_frame_role() {
2991 let dock = attrs(&[("window-type", "dock")]);
2992 for (number, name) in ATSPI_ROLE_ENUM
2993 .iter()
2994 .filter(|(number, _)| *number != ATSPI_ROLE_FRAME)
2995 {
2996 assert!(
2997 !is_dock_frame(*number, &dock),
2998 "role {number} ({name:?}) must not be reported as a shell surface"
2999 );
3000 }
3001 }
3002
3003 fn method_error(name: &str) -> zbus::Error {
3006 let call = zbus::Message::method_call("/org/a11y/atspi/accessible/7", "GetAttributes")
3007 .expect("method-call builder")
3008 .serial(std::num::NonZeroU32::new(1).expect("nonzero"))
3009 .build(&())
3010 .expect("build method call");
3011 let reply = zbus::Message::error(&call.header(), name)
3012 .expect("error builder")
3013 .serial(std::num::NonZeroU32::new(2).expect("nonzero"))
3014 .build(&"boom")
3015 .expect("build error reply");
3016 zbus::Error::from(reply)
3017 }
3018
3019 #[test]
3025 fn an_unimplemented_member_reads_as_absence() {
3026 for name in ABSENT_MEMBER_ERRORS {
3027 assert!(
3028 is_absent_member(&method_error(name)),
3029 "{name} must read as absence"
3030 );
3031 }
3032 }
3033
3034 #[test]
3042 fn a_departed_peer_is_absence_not_failure() {
3043 for name in [
3044 "org.freedesktop.DBus.Error.ServiceUnknown",
3045 "org.freedesktop.DBus.Error.NameHasNoOwner",
3046 ] {
3047 assert!(
3048 is_peer_gone(&method_error(name)),
3049 "{name} means the peer left"
3050 );
3051 assert!(!is_absent_member(&method_error(name)), "{name}");
3054 }
3055 }
3056
3057 #[test]
3058 fn a_hung_or_broken_peer_is_still_a_failure() {
3059 for name in [
3060 "org.freedesktop.DBus.Error.NoReply",
3061 "org.freedesktop.DBus.Error.Timeout",
3062 "org.freedesktop.DBus.Error.Disconnected",
3063 "org.freedesktop.DBus.Error.AccessDenied",
3064 "org.freedesktop.DBus.Error.UnknownMethod",
3065 ] {
3066 assert!(
3067 !is_peer_gone(&method_error(name)),
3068 "{name} must not be read as the peer having left"
3069 );
3070 }
3071 assert!(!is_peer_gone(&zbus::Error::InvalidReply));
3072 }
3073
3074 #[test]
3075 fn other_dbus_failures_are_not_absence() {
3076 for name in [
3077 "org.freedesktop.DBus.Error.ServiceUnknown",
3078 "org.freedesktop.DBus.Error.NoReply",
3079 "org.freedesktop.DBus.Error.Timeout",
3080 "org.freedesktop.DBus.Error.Disconnected",
3081 "org.freedesktop.DBus.Error.AccessDenied",
3082 "org.freedesktop.DBus.Error.UnknownProperty",
3083 ] {
3084 assert!(
3085 !is_absent_member(&method_error(name)),
3086 "{name} must stay a failure"
3087 );
3088 }
3089 assert!(!is_absent_member(&zbus::Error::InvalidReply));
3091 }
3092
3093 #[test]
3098 fn a_classification_failure_names_the_app_and_the_top_level() {
3099 let mut app = ElementData::for_role(Role::Application);
3100 app.name = Some("xfce4-panel".to_string());
3101 app.pid = Some(4242);
3102 let top = AccessibleRef {
3103 bus_name: ":1.42".to_string(),
3104 path: "/org/a11y/atspi/accessible/7".to_string(),
3105 };
3106 let err = classify_failure(
3107 &app,
3108 &top,
3109 Error::Platform {
3110 code: -1,
3111 message: "GetRole failed: connection closed".to_string(),
3112 },
3113 );
3114 let Error::Platform { code, message } = err else {
3115 panic!("a D-Bus failure must stay a platform error");
3116 };
3117 assert_eq!(code, -1);
3118 assert!(message.contains("xfce4-panel"), "{message}");
3119 assert!(message.contains("pid=4242"), "{message}");
3120 assert!(
3121 message.contains("/org/a11y/atspi/accessible/7"),
3122 "{message}"
3123 );
3124 assert!(message.contains("GetRole failed"), "{message}");
3126 }
3127
3128 #[test]
3131 fn a_classification_failure_survives_an_unnamed_app() {
3132 let app = ElementData::for_role(Role::Application);
3133 let top = AccessibleRef {
3134 bus_name: ":1.9".to_string(),
3135 path: "/org/a11y/atspi/accessible/3".to_string(),
3136 };
3137 let err = classify_failure(
3138 &app,
3139 &top,
3140 Error::Platform {
3141 code: -1,
3142 message: "GetAttributes failed".to_string(),
3143 },
3144 );
3145 let msg = err.to_string();
3146 assert!(msg.contains("<unnamed>"), "{msg}");
3147 assert!(msg.contains("/org/a11y/atspi/accessible/3"), "{msg}");
3148 }
3149}