Skip to main content

xa11y_linux/
atspi.rs

1//! Real AT-SPI2 backend implementation using zbus D-Bus bindings.
2
3use std::sync::Mutex;
4use std::time::Duration;
5
6use xa11y_core::{
7    Action, ActionData, AppTarget, CancelHandle, ElementState, Error, Event, EventFilter,
8    EventKind, EventProvider, EventReceiver, NodeData, PermissionStatus, Provider, Rect, Result,
9    Role, StateSet, Subscription, Toggled, Tree,
10};
11use zbus::blocking::{Connection, Proxy};
12
13/// Linux accessibility provider using AT-SPI2 over D-Bus.
14pub struct LinuxProvider {
15    a11y_bus: Connection,
16    /// Cached AT-SPI accessible refs for action dispatch (keyed by node index).
17    cached_refs: Mutex<Vec<AccessibleRef>>,
18}
19
20/// AT-SPI2 accessible reference: (bus_name, object_path).
21#[derive(Debug, Clone)]
22struct AccessibleRef {
23    bus_name: String,
24    path: String,
25}
26
27impl LinuxProvider {
28    /// Create a new Linux accessibility provider.
29    ///
30    /// Connects to the AT-SPI2 bus. Falls back to the session bus
31    /// if the dedicated AT-SPI bus is unavailable.
32    pub fn new() -> Result<Self> {
33        let a11y_bus = Self::connect_a11y_bus()?;
34        Ok(Self {
35            a11y_bus,
36            cached_refs: Mutex::new(Vec::new()),
37        })
38    }
39
40    fn connect_a11y_bus() -> Result<Connection> {
41        // Try getting the AT-SPI bus address from the a11y bus launcher,
42        // then connect to it. If that fails, fall back to the session bus
43        // (AT-SPI2 may use the session bus directly).
44        if let Ok(session) = Connection::session() {
45            let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
46                .map_err(|e| Error::Platform {
47                    code: -1,
48                    message: format!("Failed to create a11y bus proxy: {}", e),
49                })?;
50
51            if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
52                if let Ok(address) = addr_reply.body().deserialize::<String>() {
53                    if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
54                        if let Ok(Ok(conn)) =
55                            zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
56                        {
57                            return Ok(conn);
58                        }
59                    }
60                }
61            }
62
63            // Fall back to session bus
64            return Ok(session);
65        }
66
67        Connection::session().map_err(|e| Error::Platform {
68            code: -1,
69            message: format!("Failed to connect to D-Bus session bus: {}", e),
70        })
71    }
72
73    fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
74        Proxy::new(
75            &self.a11y_bus,
76            bus_name.to_owned(),
77            path.to_owned(),
78            interface.to_owned(),
79        )
80        .map_err(|e| Error::Platform {
81            code: -1,
82            message: format!("Failed to create proxy: {}", e),
83        })
84    }
85
86    /// Check whether an accessible object implements a given interface.
87    /// Queries the AT-SPI GetInterfaces method on the Accessible interface.
88    fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
89        let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
90            Ok(p) => p,
91            Err(_) => return false,
92        };
93        let reply = match proxy.call_method("GetInterfaces", &()) {
94            Ok(r) => r,
95            Err(_) => return false,
96        };
97        let interfaces: Vec<String> = match reply.body().deserialize() {
98            Ok(v) => v,
99            Err(_) => return false,
100        };
101        interfaces.iter().any(|i| i.contains(iface))
102    }
103
104    /// Get the numeric AT-SPI role via GetRole method.
105    fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
106        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
107        let reply = proxy
108            .call_method("GetRole", &())
109            .map_err(|e| Error::Platform {
110                code: -1,
111                message: format!("GetRole failed: {}", e),
112            })?;
113        reply
114            .body()
115            .deserialize::<u32>()
116            .map_err(|e| Error::Platform {
117                code: -1,
118                message: format!("GetRole deserialize failed: {}", e),
119            })
120    }
121
122    /// Get the AT-SPI role name string.
123    fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
124        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
125        let reply = proxy
126            .call_method("GetRoleName", &())
127            .map_err(|e| Error::Platform {
128                code: -1,
129                message: format!("GetRoleName failed: {}", e),
130            })?;
131        reply
132            .body()
133            .deserialize::<String>()
134            .map_err(|e| Error::Platform {
135                code: -1,
136                message: format!("GetRoleName deserialize failed: {}", e),
137            })
138    }
139
140    /// Get the name of an accessible element.
141    fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
142        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
143        proxy
144            .get_property::<String>("Name")
145            .map_err(|e| Error::Platform {
146                code: -1,
147                message: format!("Get Name property failed: {}", e),
148            })
149    }
150
151    /// Get the description of an accessible element.
152    fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
153        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
154        proxy
155            .get_property::<String>("Description")
156            .map_err(|e| Error::Platform {
157                code: -1,
158                message: format!("Get Description property failed: {}", e),
159            })
160    }
161
162    /// Get children via the GetChildren method.
163    /// AT-SPI registryd doesn't always implement standard D-Bus Properties,
164    /// so we use GetChildren which is more reliable.
165    fn get_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
166        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
167        let reply = proxy
168            .call_method("GetChildren", &())
169            .map_err(|e| Error::Platform {
170                code: -1,
171                message: format!("GetChildren failed: {}", e),
172            })?;
173        let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
174            reply.body().deserialize().map_err(|e| Error::Platform {
175                code: -1,
176                message: format!("GetChildren deserialize failed: {}", e),
177            })?;
178        Ok(children
179            .into_iter()
180            .map(|(bus_name, path)| AccessibleRef {
181                bus_name,
182                path: path.to_string(),
183            })
184            .collect())
185    }
186
187    /// Get the state set as raw u32 values.
188    fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
189        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
190        let reply = proxy
191            .call_method("GetState", &())
192            .map_err(|e| Error::Platform {
193                code: -1,
194                message: format!("GetState failed: {}", e),
195            })?;
196        reply
197            .body()
198            .deserialize::<Vec<u32>>()
199            .map_err(|e| Error::Platform {
200                code: -1,
201                message: format!("GetState deserialize failed: {}", e),
202            })
203    }
204
205    /// Get bounds via Component interface.
206    /// Checks for Component support first to avoid GTK CRITICAL warnings
207    /// on objects (e.g. TreeView cell renderers) that don't implement it.
208    fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
209        if !self.has_interface(aref, "Component") {
210            return None;
211        }
212        let proxy = self
213            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
214            .ok()?;
215        // GetExtents(coord_type: u32) -> (x, y, width, height)
216        // coord_type 0 = screen coordinates
217        let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
218        let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
219        if w <= 0 && h <= 0 {
220            return None;
221        }
222        Some(Rect {
223            x,
224            y,
225            width: w.max(0) as u32,
226            height: h.max(0) as u32,
227        })
228    }
229
230    /// Get available actions via Action interface.
231    /// Probes the interface directly rather than relying on the Interfaces property,
232    /// which some AT-SPI adapters (e.g. AccessKit) don't expose.
233    fn get_actions(&self, aref: &AccessibleRef) -> Vec<Action> {
234        let mut actions = Vec::new();
235
236        // Try Action interface directly
237        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
238            if let Ok(n_actions) = proxy.get_property::<i32>("NActions") {
239                for i in 0..n_actions {
240                    if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
241                        if let Ok(name) = reply.body().deserialize::<String>() {
242                            if let Some(action) = map_atspi_action(&name) {
243                                if !actions.contains(&action) {
244                                    actions.push(action);
245                                }
246                            }
247                        }
248                    }
249                }
250            }
251        }
252
253        // Try Component interface for Focus
254        if !actions.contains(&Action::Focus) {
255            if let Ok(proxy) =
256                self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
257            {
258                // Verify the interface exists by trying a method
259                if proxy.call_method("GetExtents", &(0u32,)).is_ok() {
260                    actions.push(Action::Focus);
261                }
262            }
263        }
264
265        actions
266    }
267
268    /// Get value via Value or Text interface.
269    /// Probes interfaces directly rather than relying on the Interfaces property.
270    fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
271        // Try Text interface first for text content (text fields, labels, combo boxes).
272        // This must come before Value because some AT-SPI adapters (e.g. AccessKit)
273        // may expose both interfaces, and Value.CurrentValue returns 0.0 for text nodes.
274        let text_value = self.get_text_content(aref);
275        if text_value.is_some() {
276            return text_value;
277        }
278        // Try Value interface (sliders, progress bars, scroll bars, spinners)
279        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
280            if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
281                return Some(val.to_string());
282            }
283        }
284        None
285    }
286
287    /// Read text content via the AT-SPI Text interface.
288    fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
289        let proxy = self
290            .make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
291            .ok()?;
292        let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
293        if char_count > 0 {
294            let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
295            let text: String = reply.body().deserialize().ok()?;
296            if !text.is_empty() {
297                return Some(text);
298            }
299        }
300        None
301    }
302
303    /// Traverse the accessibility tree rooted at `aref`, building nodes.
304    #[allow(clippy::too_many_arguments)]
305    #[allow(clippy::only_used_in_recursion)]
306    fn traverse(
307        &self,
308        aref: &AccessibleRef,
309        nodes: &mut Vec<NodeData>,
310        refs: &mut Vec<AccessibleRef>,
311        parent_idx: Option<u32>,
312        depth: u32,
313        screen_size: (u32, u32),
314    ) {
315        let role_name = self.get_role_name(aref).unwrap_or_default();
316        let role_num = self.get_role_number(aref).unwrap_or(0);
317        let role = if !role_name.is_empty() {
318            map_atspi_role(&role_name)
319        } else {
320            map_atspi_role_number(role_num)
321        };
322
323        let mut name = self.get_name(aref).ok().filter(|s| !s.is_empty());
324        let description = self.get_description(aref).ok().filter(|s| !s.is_empty());
325        let value = self.get_value(aref);
326
327        // For label/static text nodes, AT-SPI may put content in the Text interface
328        // (returned as value) rather than the Name property. Use it as the name.
329        if name.is_none() && role == Role::StaticText {
330            if let Some(ref v) = value {
331                name = Some(v.clone());
332            }
333        }
334        let bounds = self.get_extents(aref);
335        let states = self.parse_states(aref, role);
336        let actions = self.get_actions(aref);
337
338        let raw = {
339            let raw_role = if role_name.is_empty() {
340                format!("role_num:{}", role_num)
341            } else {
342                role_name
343            };
344            xa11y_core::RawPlatformData::Linux {
345                atspi_role: raw_role,
346                bus_name: aref.bus_name.clone(),
347                object_path: aref.path.clone(),
348            }
349        };
350
351        let (numeric_value, min_value, max_value) = if matches!(
352            role,
353            Role::Slider | Role::ProgressBar | Role::ScrollBar | Role::SpinButton
354        ) {
355            if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
356                (
357                    proxy.get_property::<f64>("CurrentValue").ok(),
358                    proxy.get_property::<f64>("MinimumValue").ok(),
359                    proxy.get_property::<f64>("MaximumValue").ok(),
360                )
361            } else {
362                (None, None, None)
363            }
364        } else {
365            (None, None, None)
366        };
367
368        let node_idx = nodes.len() as u32;
369        nodes.push(NodeData {
370            role,
371            name,
372            value,
373            description,
374            bounds,
375            actions,
376            states,
377            numeric_value,
378            min_value,
379            max_value,
380            pid: None,
381            stable_id: Some(aref.path.clone()),
382            raw,
383            index: node_idx,
384            children_indices: vec![], // filled in below
385            parent_index: parent_idx,
386        });
387        refs.push(aref.clone());
388
389        // Get children
390        let children = self.get_children(aref).unwrap_or_default();
391        let mut child_ids = Vec::new();
392
393        for child_ref in &children {
394            // Skip invalid refs
395            if child_ref.path == "/org/a11y/atspi/null"
396                || child_ref.bus_name.is_empty()
397                || child_ref.path.is_empty()
398            {
399                continue;
400            }
401            let child_idx = nodes.len() as u32;
402            child_ids.push(child_idx);
403            self.traverse(
404                child_ref,
405                nodes,
406                refs,
407                Some(node_idx),
408                depth + 1,
409                screen_size,
410            );
411        }
412
413        // Update children list
414        nodes[node_idx as usize].children_indices = child_ids;
415    }
416
417    /// Parse AT-SPI2 state bitfield into xa11y StateSet.
418    fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
419        let state_bits = self.get_state(aref).unwrap_or_default();
420
421        // AT-SPI2 states are a bitfield across two u32s
422        let bits: u64 = if state_bits.len() >= 2 {
423            (state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
424        } else if state_bits.len() == 1 {
425            state_bits[0] as u64
426        } else {
427            0
428        };
429
430        // AT-SPI2 state bit positions (AtspiStateType enum values)
431        const BUSY: u64 = 1 << 3;
432        const CHECKED: u64 = 1 << 4;
433        const EDITABLE: u64 = 1 << 7;
434        const ENABLED: u64 = 1 << 8;
435        const EXPANDABLE: u64 = 1 << 9;
436        const EXPANDED: u64 = 1 << 10;
437        const FOCUSABLE: u64 = 1 << 11;
438        const FOCUSED: u64 = 1 << 12;
439        const MODAL: u64 = 1 << 16;
440        const SELECTED: u64 = 1 << 23;
441        const SENSITIVE: u64 = 1 << 24;
442        const SHOWING: u64 = 1 << 25;
443        const VISIBLE: u64 = 1 << 30;
444        const INDETERMINATE: u64 = 1 << 32;
445        const REQUIRED: u64 = 1 << 33;
446
447        let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
448        let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
449
450        let checked = match role {
451            Role::CheckBox | Role::RadioButton | Role::MenuItem => {
452                if (bits & INDETERMINATE) != 0 {
453                    Some(Toggled::Mixed)
454                } else if (bits & CHECKED) != 0 {
455                    Some(Toggled::On)
456                } else {
457                    Some(Toggled::Off)
458                }
459            }
460            _ => None,
461        };
462
463        let expanded = if (bits & EXPANDABLE) != 0 {
464            Some((bits & EXPANDED) != 0)
465        } else {
466            None
467        };
468
469        StateSet {
470            enabled,
471            visible,
472            focused: (bits & FOCUSED) != 0,
473            checked,
474            selected: (bits & SELECTED) != 0,
475            expanded,
476            editable: (bits & EDITABLE) != 0,
477            focusable: (bits & FOCUSABLE) != 0,
478            modal: (bits & MODAL) != 0,
479            required: (bits & REQUIRED) != 0,
480            busy: (bits & BUSY) != 0,
481        }
482    }
483
484    /// Get screen size.
485    fn detect_screen_size() -> (u32, u32) {
486        if let Ok(output) = std::process::Command::new("xdpyinfo").output() {
487            let stdout = String::from_utf8_lossy(&output.stdout);
488            for line in stdout.lines() {
489                let trimmed = line.trim();
490                if trimmed.starts_with("dimensions:") {
491                    if let Some(dims) = trimmed.split_whitespace().nth(1) {
492                        let parts: Vec<&str> = dims.split('x').collect();
493                        if parts.len() == 2 {
494                            if let (Ok(w), Ok(h)) = (parts[0].parse(), parts[1].parse()) {
495                                return (w, h);
496                            }
497                        }
498                    }
499                }
500            }
501        }
502        (1920, 1080)
503    }
504
505    /// Find an application by name.
506    fn find_app_by_name(&self, name: &str) -> Result<AccessibleRef> {
507        let registry = AccessibleRef {
508            bus_name: "org.a11y.atspi.Registry".to_string(),
509            path: "/org/a11y/atspi/accessible/root".to_string(),
510        };
511        let children = self.get_children(&registry)?;
512        let name_lower = name.to_lowercase();
513
514        for child in &children {
515            if child.path == "/org/a11y/atspi/null" {
516                continue;
517            }
518            if let Ok(app_name) = self.get_name(child) {
519                if app_name.to_lowercase().contains(&name_lower) {
520                    return Ok(child.clone());
521                }
522            }
523        }
524
525        Err(Error::AppNotFound {
526            target: name.to_string(),
527        })
528    }
529
530    /// Find an application by PID.
531    fn find_app_by_pid(&self, pid: u32) -> Result<AccessibleRef> {
532        let registry = AccessibleRef {
533            bus_name: "org.a11y.atspi.Registry".to_string(),
534            path: "/org/a11y/atspi/accessible/root".to_string(),
535        };
536        let children = self.get_children(&registry)?;
537
538        for child in &children {
539            if child.path == "/org/a11y/atspi/null" {
540                continue;
541            }
542            // Try Application.Id first
543            if let Ok(proxy) =
544                self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
545            {
546                if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
547                    if app_pid as u32 == pid {
548                        return Ok(child.clone());
549                    }
550                }
551            }
552            // Fall back to D-Bus connection PID
553            if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
554                if app_pid == pid {
555                    return Ok(child.clone());
556                }
557            }
558        }
559
560        Err(Error::AppNotFound {
561            target: format!("PID {}", pid),
562        })
563    }
564
565    /// Get PID via D-Bus GetConnectionUnixProcessID.
566    fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
567        let proxy = self
568            .make_proxy(
569                "org.freedesktop.DBus",
570                "/org/freedesktop/DBus",
571                "org.freedesktop.DBus",
572            )
573            .ok()?;
574        let reply = proxy
575            .call_method("GetConnectionUnixProcessID", &(bus_name,))
576            .ok()?;
577        let pid: u32 = reply.body().deserialize().ok()?;
578        if pid > 0 {
579            Some(pid)
580        } else {
581            None
582        }
583    }
584
585    /// Perform an AT-SPI action by name.
586    fn do_atspi_action(&self, aref: &AccessibleRef, action_name: &str) -> Result<()> {
587        let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
588        let n_actions: i32 = proxy.get_property("NActions").unwrap_or(0);
589
590        for i in 0..n_actions {
591            if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
592                if let Ok(name) = reply.body().deserialize::<String>() {
593                    if name == action_name {
594                        let _ =
595                            proxy
596                                .call_method("DoAction", &(i,))
597                                .map_err(|e| Error::Platform {
598                                    code: -1,
599                                    message: format!("DoAction failed: {}", e),
600                                })?;
601                        return Ok(());
602                    }
603                }
604            }
605        }
606
607        Err(Error::Platform {
608            code: -1,
609            message: format!("Action '{}' not found", action_name),
610        })
611    }
612
613    /// Get PID from Application interface, falling back to D-Bus connection PID.
614    fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
615        // Try Application.Id first
616        if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
617        {
618            if let Ok(pid) = proxy.get_property::<i32>("Id") {
619                if pid > 0 {
620                    return Some(pid as u32);
621                }
622            }
623        }
624
625        // Fall back to D-Bus GetConnectionUnixProcessID
626        if let Ok(proxy) = self.make_proxy(
627            "org.freedesktop.DBus",
628            "/org/freedesktop/DBus",
629            "org.freedesktop.DBus",
630        ) {
631            if let Ok(reply) =
632                proxy.call_method("GetConnectionUnixProcessID", &(aref.bus_name.as_str(),))
633            {
634                if let Ok(pid) = reply.body().deserialize::<u32>() {
635                    if pid > 0 {
636                        return Some(pid);
637                    }
638                }
639            }
640        }
641
642        None
643    }
644}
645
646impl Provider for LinuxProvider {
647    fn get_app_tree(&self, target: &AppTarget) -> Result<Tree> {
648        let app_ref = match target {
649            AppTarget::ByName(name) => self.find_app_by_name(name)?,
650            AppTarget::ByPid(pid) => self.find_app_by_pid(*pid)?,
651            AppTarget::ByWindow(_) => {
652                return Err(Error::Platform {
653                    code: -1,
654                    message: "ByWindow not supported on Linux AT-SPI2".to_string(),
655                });
656            }
657        };
658
659        let app_name = self.get_name(&app_ref).unwrap_or_default();
660        let screen_size = Self::detect_screen_size();
661        let mut nodes = Vec::new();
662        let mut refs = Vec::new();
663
664        self.traverse(&app_ref, &mut nodes, &mut refs, None, 0, screen_size);
665
666        if nodes.is_empty() {
667            return Err(Error::AppNotFound {
668                target: format!("{:?}", target),
669            });
670        }
671
672        // Cache refs for action dispatch
673        *self.cached_refs.lock().unwrap() = refs;
674
675        let pid = self.get_app_pid(&app_ref);
676
677        Ok(Tree::new(app_name, pid, screen_size, nodes))
678    }
679
680    fn get_apps(&self) -> Result<Tree> {
681        let screen_size = Self::detect_screen_size();
682        let mut nodes = Vec::new();
683
684        nodes.push(NodeData {
685            role: Role::Application,
686            name: Some("Desktop".to_string()),
687            value: None,
688            description: None,
689            bounds: Some(Rect {
690                x: 0,
691                y: 0,
692                width: screen_size.0,
693                height: screen_size.1,
694            }),
695            actions: vec![],
696            states: StateSet::default(),
697            numeric_value: None,
698            min_value: None,
699            max_value: None,
700            pid: None,
701            stable_id: None,
702            raw: xa11y_core::RawPlatformData::Synthetic,
703            index: 0,
704            children_indices: vec![],
705            parent_index: None,
706        });
707
708        let mut refs = Vec::new();
709        refs.push(AccessibleRef {
710            bus_name: String::new(),
711            path: String::new(),
712        }); // placeholder for desktop root
713
714        let registry = AccessibleRef {
715            bus_name: "org.a11y.atspi.Registry".to_string(),
716            path: "/org/a11y/atspi/accessible/root".to_string(),
717        };
718        let children = self.get_children(&registry).unwrap_or_default();
719        let mut root_children = Vec::new();
720
721        for child in &children {
722            if child.path == "/org/a11y/atspi/null" {
723                continue;
724            }
725            let app_name = self.get_name(child).unwrap_or_default();
726            if app_name.is_empty() {
727                continue;
728            }
729            let child_idx = nodes.len() as u32;
730            root_children.push(child_idx);
731            self.traverse(child, &mut nodes, &mut refs, Some(0), 1, screen_size);
732        }
733
734        nodes[0].children_indices = root_children;
735
736        *self.cached_refs.lock().unwrap() = refs;
737
738        Ok(Tree::new("Desktop".to_string(), None, screen_size, nodes))
739    }
740
741    fn perform_action(
742        &self,
743        tree: &Tree,
744        node: &NodeData,
745        action: Action,
746        data: Option<ActionData>,
747    ) -> Result<()> {
748        let node_idx = tree.node_index(node);
749
750        // Look up cached accessible ref for action dispatch
751        let cache = self.cached_refs.lock().unwrap();
752        let target = cache
753            .get(node_idx as usize)
754            .ok_or(Error::ElementStale {
755                selector: format!("index:{}", node_idx),
756            })?
757            .clone();
758        drop(cache);
759
760        match action {
761            Action::Press => self
762                .do_atspi_action(&target, "click")
763                .or_else(|_| self.do_atspi_action(&target, "activate"))
764                .or_else(|_| self.do_atspi_action(&target, "press")),
765            Action::Focus => {
766                // Try Component.GrabFocus first, then fall back to Action interface
767                if let Ok(proxy) =
768                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
769                {
770                    if proxy.call_method("GrabFocus", &()).is_ok() {
771                        return Ok(());
772                    }
773                }
774                self.do_atspi_action(&target, "focus")
775                    .or_else(|_| self.do_atspi_action(&target, "setFocus"))
776            }
777            Action::SetValue => match data {
778                Some(ActionData::NumericValue(v)) => {
779                    let proxy =
780                        self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
781                    proxy
782                        .set_property("CurrentValue", v)
783                        .map_err(|e| Error::Platform {
784                            code: -1,
785                            message: format!("SetValue failed: {}", e),
786                        })
787                }
788                Some(ActionData::Value(text)) => {
789                    let proxy = self
790                        .make_proxy(
791                            &target.bus_name,
792                            &target.path,
793                            "org.a11y.atspi.EditableText",
794                        )
795                        .map_err(|_| Error::TextValueNotSupported)?;
796                    let _ = proxy.call_method("DeleteText", &(0i32, i32::MAX));
797                    proxy
798                        .call_method("InsertText", &(0i32, &*text, text.len() as i32))
799                        .map_err(|_| Error::TextValueNotSupported)?;
800                    Ok(())
801                }
802                _ => Err(Error::Platform {
803                    code: -1,
804                    message: "SetValue requires ActionData".to_string(),
805                }),
806            },
807            Action::Toggle => self
808                .do_atspi_action(&target, "toggle")
809                .or_else(|_| self.do_atspi_action(&target, "click"))
810                .or_else(|_| self.do_atspi_action(&target, "activate")),
811            Action::Expand => self
812                .do_atspi_action(&target, "expand")
813                .or_else(|_| self.do_atspi_action(&target, "open")),
814            Action::Collapse => self
815                .do_atspi_action(&target, "collapse")
816                .or_else(|_| self.do_atspi_action(&target, "close")),
817            Action::Select => self.do_atspi_action(&target, "select"),
818            Action::ShowMenu => self
819                .do_atspi_action(&target, "menu")
820                .or_else(|_| self.do_atspi_action(&target, "showmenu")),
821            Action::ScrollIntoView => {
822                let proxy =
823                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
824                proxy
825                    .call_method("ScrollTo", &(0u32,))
826                    .map_err(|e| Error::Platform {
827                        code: -1,
828                        message: format!("ScrollTo failed: {}", e),
829                    })?;
830                Ok(())
831            }
832            Action::Increment => self.do_atspi_action(&target, "increment").or_else(|_| {
833                // Fall back to Value interface: current + step (or +1)
834                let proxy =
835                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
836                let current: f64 =
837                    proxy
838                        .get_property("CurrentValue")
839                        .map_err(|e| Error::Platform {
840                            code: -1,
841                            message: format!("Value.CurrentValue failed: {}", e),
842                        })?;
843                let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
844                let step = if step <= 0.0 { 1.0 } else { step };
845                proxy
846                    .set_property("CurrentValue", current + step)
847                    .map_err(|e| Error::Platform {
848                        code: -1,
849                        message: format!("Value.SetCurrentValue failed: {}", e),
850                    })
851            }),
852            Action::Decrement => self.do_atspi_action(&target, "decrement").or_else(|_| {
853                let proxy =
854                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
855                let current: f64 =
856                    proxy
857                        .get_property("CurrentValue")
858                        .map_err(|e| Error::Platform {
859                            code: -1,
860                            message: format!("Value.CurrentValue failed: {}", e),
861                        })?;
862                let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
863                let step = if step <= 0.0 { 1.0 } else { step };
864                proxy
865                    .set_property("CurrentValue", current - step)
866                    .map_err(|e| Error::Platform {
867                        code: -1,
868                        message: format!("Value.SetCurrentValue failed: {}", e),
869                    })
870            }),
871            Action::Blur => {
872                // Grab focus on parent element to blur the current one
873                let proxy =
874                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Accessible")?;
875                if let Ok(reply) = proxy.call_method("GetParent", &()) {
876                    if let Ok((bus, path)) = reply
877                        .body()
878                        .deserialize::<(String, zbus::zvariant::OwnedObjectPath)>()
879                    {
880                        let path_str = path.as_str();
881                        if path_str != "/org/a11y/atspi/null" {
882                            if let Ok(p) =
883                                self.make_proxy(&bus, path_str, "org.a11y.atspi.Component")
884                            {
885                                let _ = p.call_method("GrabFocus", &());
886                                return Ok(());
887                            }
888                        }
889                    }
890                }
891                Ok(())
892            }
893
894            Action::ScrollDown | Action::ScrollRight => {
895                let amount = match data {
896                    Some(ActionData::ScrollAmount(amount)) => amount,
897                    _ => {
898                        return Err(Error::Platform {
899                            code: -1,
900                            message: "Scroll requires ActionData::ScrollAmount".to_string(),
901                        })
902                    }
903                };
904                let is_vertical = matches!(action, Action::ScrollDown);
905                let (pos_name, neg_name) = if is_vertical {
906                    ("scroll down", "scroll up")
907                } else {
908                    ("scroll right", "scroll left")
909                };
910                let action_name = if amount >= 0.0 { pos_name } else { neg_name };
911                // Repeat scroll action for each logical unit (AT-SPI has no scroll-by-amount)
912                let count = (amount.abs() as u32).max(1);
913                for _ in 0..count {
914                    if self.do_atspi_action(&target, action_name).is_err() {
915                        // Fall back to Component.ScrollTo (single call, not repeatable)
916                        let proxy = self.make_proxy(
917                            &target.bus_name,
918                            &target.path,
919                            "org.a11y.atspi.Component",
920                        )?;
921                        let scroll_type: u32 = if is_vertical {
922                            if amount >= 0.0 {
923                                3
924                            } else {
925                                2
926                            } // BOTTOM_EDGE / TOP_EDGE
927                        } else {
928                            if amount >= 0.0 {
929                                5
930                            } else {
931                                4
932                            } // RIGHT_EDGE / LEFT_EDGE
933                        };
934                        proxy
935                            .call_method("ScrollTo", &(scroll_type,))
936                            .map_err(|e| Error::Platform {
937                                code: -1,
938                                message: format!("ScrollTo failed: {}", e),
939                            })?;
940                        return Ok(());
941                    }
942                }
943                Ok(())
944            }
945
946            Action::SetTextSelection => {
947                let (start, end) = match data {
948                    Some(ActionData::TextSelection { start, end }) => (start, end),
949                    _ => {
950                        return Err(Error::Platform {
951                            code: -1,
952                            message: "SetTextSelection requires ActionData::TextSelection"
953                                .to_string(),
954                        })
955                    }
956                };
957                let proxy =
958                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
959                // Try SetSelection first, fall back to AddSelection
960                if proxy
961                    .call_method("SetSelection", &(0i32, start as i32, end as i32))
962                    .is_err()
963                {
964                    proxy
965                        .call_method("AddSelection", &(start as i32, end as i32))
966                        .map_err(|e| Error::Platform {
967                            code: -1,
968                            message: format!("Text.AddSelection failed: {}", e),
969                        })?;
970                }
971                Ok(())
972            }
973
974            Action::TypeText => {
975                let text = match data {
976                    Some(ActionData::Value(text)) => text,
977                    _ => {
978                        return Err(Error::Platform {
979                            code: -1,
980                            message: "TypeText requires ActionData::Value".to_string(),
981                        })
982                    }
983                };
984                // Insert text via EditableText interface (accessibility API, not input simulation).
985                // Get cursor position from Text interface, then insert at that position.
986                let text_proxy =
987                    self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
988                let insert_pos = text_proxy
989                    .as_ref()
990                    .ok()
991                    .and_then(|p| p.get_property::<i32>("CaretOffset").ok())
992                    .unwrap_or(-1); // -1 = append at end
993
994                let proxy = self
995                    .make_proxy(
996                        &target.bus_name,
997                        &target.path,
998                        "org.a11y.atspi.EditableText",
999                    )
1000                    .map_err(|_| Error::TextValueNotSupported)?;
1001                let pos = if insert_pos >= 0 {
1002                    insert_pos
1003                } else {
1004                    i32::MAX
1005                };
1006                proxy
1007                    .call_method("InsertText", &(pos, &*text, text.len() as i32))
1008                    .map_err(|e| Error::Platform {
1009                        code: -1,
1010                        message: format!("EditableText.InsertText failed: {}", e),
1011                    })?;
1012                Ok(())
1013            }
1014        }
1015    }
1016
1017    fn check_permissions(&self) -> Result<PermissionStatus> {
1018        let registry = AccessibleRef {
1019            bus_name: "org.a11y.atspi.Registry".to_string(),
1020            path: "/org/a11y/atspi/accessible/root".to_string(),
1021        };
1022        match self.get_children(&registry) {
1023            Ok(_) => Ok(PermissionStatus::Granted),
1024            Err(_) => Ok(PermissionStatus::Denied {
1025                instructions:
1026                    "Enable accessibility: gsettings set org.gnome.desktop.interface toolkit-accessibility true\nEnsure at-spi2-core is installed."
1027                        .to_string(),
1028            }),
1029        }
1030    }
1031}
1032
1033// ── EventProvider ────────────────────────────────────────────────────────────
1034
1035impl EventProvider for LinuxProvider {
1036    fn subscribe(&self, target: &AppTarget, filter: EventFilter) -> Result<Subscription> {
1037        let (tx, rx) = std::sync::mpsc::channel();
1038
1039        let (app_name, app_pid) = match target {
1040            AppTarget::ByName(name) => {
1041                let app_ref = self.find_app_by_name(name)?;
1042                let pid = self.get_app_pid(&app_ref).unwrap_or(0);
1043                (self.get_name(&app_ref).unwrap_or_default(), pid)
1044            }
1045            AppTarget::ByPid(pid) => {
1046                let app_ref = self.find_app_by_pid(*pid)?;
1047                (self.get_name(&app_ref).unwrap_or_default(), *pid)
1048            }
1049            AppTarget::ByWindow(_) => {
1050                return Err(Error::Platform {
1051                    code: -1,
1052                    message: "ByWindow not supported for event subscription".to_string(),
1053                })
1054            }
1055        };
1056
1057        // Create a separate provider for polling on the background thread
1058        let poll_provider = LinuxProvider::new()?;
1059        let target_clone = target.clone();
1060        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1061        let stop_clone = stop.clone();
1062
1063        // Poll for tree changes on a background thread, emitting events for diffs
1064        let handle = std::thread::spawn(move || {
1065            let mut prev_focused: Option<String> = None;
1066            let mut prev_node_count: usize = 0;
1067
1068            while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
1069                std::thread::sleep(Duration::from_millis(100));
1070
1071                let tree = match poll_provider.get_app_tree(&target_clone) {
1072                    Ok(t) => t,
1073                    Err(_) => continue,
1074                };
1075
1076                // Detect focus changes
1077                let focused_name = tree
1078                    .iter()
1079                    .find(|n| n.states.focused)
1080                    .and_then(|n| n.name.clone());
1081                if focused_name != prev_focused {
1082                    if prev_focused.is_some() {
1083                        let kind = EventKind::FocusChanged;
1084                        if filter.kinds.is_empty() || filter.kinds.contains(&kind) {
1085                            let _ = tx.send(Event {
1086                                kind,
1087                                app_name: app_name.clone(),
1088                                app_pid,
1089                                target: tree.iter().find(|n| n.states.focused).cloned(),
1090                                state_flag: None,
1091                                state_value: None,
1092                                text_change: None,
1093                                timestamp: std::time::Instant::now(),
1094                            });
1095                        }
1096                    }
1097                    prev_focused = focused_name;
1098                }
1099
1100                // Detect structure changes (node count changed)
1101                let node_count = tree.len();
1102                if node_count != prev_node_count && prev_node_count > 0 {
1103                    let kind = EventKind::StructureChanged;
1104                    if filter.kinds.is_empty() || filter.kinds.contains(&kind) {
1105                        let _ = tx.send(Event {
1106                            kind,
1107                            app_name: app_name.clone(),
1108                            app_pid,
1109                            target: None,
1110                            state_flag: None,
1111                            state_value: None,
1112                            text_change: None,
1113                            timestamp: std::time::Instant::now(),
1114                        });
1115                    }
1116                }
1117                prev_node_count = node_count;
1118            }
1119        });
1120
1121        let cancel = CancelHandle::new(move || {
1122            stop.store(true, std::sync::atomic::Ordering::Relaxed);
1123            let _ = handle.join();
1124        });
1125
1126        Ok(Subscription::new(EventReceiver::new(rx), cancel))
1127    }
1128
1129    fn wait_for_event(
1130        &self,
1131        target: &AppTarget,
1132        filter: EventFilter,
1133        timeout: Duration,
1134    ) -> Result<Event> {
1135        let sub = self.subscribe(target, filter)?;
1136        let start = std::time::Instant::now();
1137        loop {
1138            if let Some(event) = sub.try_recv() {
1139                return Ok(event);
1140            }
1141            let elapsed = start.elapsed();
1142            if elapsed >= timeout {
1143                return Err(Error::Timeout { elapsed });
1144            }
1145            std::thread::sleep(Duration::from_millis(10));
1146        }
1147    }
1148
1149    fn wait_for(
1150        &self,
1151        target: &AppTarget,
1152        selector: &str,
1153        state: ElementState,
1154        timeout: Duration,
1155    ) -> Result<Option<NodeData>> {
1156        let start = std::time::Instant::now();
1157        let poll_interval = Duration::from_millis(100);
1158
1159        loop {
1160            let elapsed = start.elapsed();
1161            if elapsed >= timeout {
1162                return Err(Error::Timeout { elapsed });
1163            }
1164
1165            let tree = self.get_app_tree(target)?;
1166            let matches = tree.query(selector).ok();
1167            let node = matches.as_ref().and_then(|m| m.first().copied());
1168
1169            if state.is_met(node) {
1170                return Ok(node.cloned());
1171            }
1172
1173            std::thread::sleep(poll_interval);
1174        }
1175    }
1176}
1177
1178/// Map AT-SPI2 role name to xa11y Role.
1179fn map_atspi_role(role_name: &str) -> Role {
1180    match role_name.to_lowercase().as_str() {
1181        "application" => Role::Application,
1182        "window" | "frame" => Role::Window,
1183        "dialog" | "file chooser" => Role::Dialog,
1184        "alert" | "notification" => Role::Alert,
1185        "push button" | "push button menu" => Role::Button,
1186        "check box" | "check menu item" => Role::CheckBox,
1187        "radio button" | "radio menu item" => Role::RadioButton,
1188        "entry" | "password text" => Role::TextField,
1189        "spin button" => Role::SpinButton,
1190        "text" => Role::TextArea,
1191        "label" | "static" | "caption" => Role::StaticText,
1192        "combo box" => Role::ComboBox,
1193        "list" | "list box" => Role::List,
1194        "list item" => Role::ListItem,
1195        "menu" => Role::Menu,
1196        "menu item" | "tearoff menu item" => Role::MenuItem,
1197        "menu bar" => Role::MenuBar,
1198        "page tab" => Role::Tab,
1199        "page tab list" => Role::TabGroup,
1200        "table" | "tree table" => Role::Table,
1201        "table row" => Role::TableRow,
1202        "table cell" | "table column header" | "table row header" => Role::TableCell,
1203        "tool bar" => Role::Toolbar,
1204        "scroll bar" => Role::ScrollBar,
1205        "slider" => Role::Slider,
1206        "image" | "icon" | "desktop icon" => Role::Image,
1207        "link" => Role::Link,
1208        "panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" => Role::Group,
1209        "progress bar" => Role::ProgressBar,
1210        "tree item" => Role::TreeItem,
1211        "document web" | "document frame" => Role::WebArea,
1212        "heading" => Role::Heading,
1213        "separator" => Role::Separator,
1214        "split pane" => Role::SplitGroup,
1215        "tooltip" | "tool tip" => Role::Tooltip,
1216        "status bar" | "statusbar" => Role::Status,
1217        "landmark" | "navigation" => Role::Navigation,
1218        _ => Role::Unknown,
1219    }
1220}
1221
1222/// Map AT-SPI2 numeric role (AtspiRole enum) to xa11y Role.
1223/// Values from atspi-common Role enum (repr(u32)).
1224fn map_atspi_role_number(role: u32) -> Role {
1225    match role {
1226        2 => Role::Alert,        // Alert
1227        7 => Role::CheckBox,     // CheckBox
1228        8 => Role::CheckBox,     // CheckMenuItem
1229        11 => Role::ComboBox,    // ComboBox
1230        16 => Role::Dialog,      // Dialog
1231        19 => Role::Dialog,      // FileChooser
1232        20 => Role::Group,       // Filler
1233        23 => Role::Window,      // Frame
1234        26 => Role::Image,       // Icon
1235        27 => Role::Image,       // Image
1236        29 => Role::StaticText,  // Label
1237        31 => Role::List,        // List
1238        32 => Role::ListItem,    // ListItem
1239        33 => Role::Menu,        // Menu
1240        34 => Role::MenuBar,     // MenuBar
1241        35 => Role::MenuItem,    // MenuItem
1242        37 => Role::Tab,         // PageTab
1243        38 => Role::TabGroup,    // PageTabList
1244        39 => Role::Group,       // Panel
1245        40 => Role::TextField,   // PasswordText
1246        42 => Role::ProgressBar, // ProgressBar
1247        43 => Role::Button,      // Button (push button)
1248        44 => Role::RadioButton, // RadioButton
1249        45 => Role::RadioButton, // RadioMenuItem
1250        48 => Role::ScrollBar,   // ScrollBar
1251        49 => Role::Group,       // ScrollPane
1252        50 => Role::Separator,   // Separator
1253        51 => Role::Slider,      // Slider
1254        52 => Role::SpinButton,  // SpinButton
1255        53 => Role::SplitGroup,  // SplitPane
1256        55 => Role::Table,       // Table
1257        56 => Role::TableCell,   // TableCell
1258        57 => Role::TableCell,   // TableColumnHeader
1259        58 => Role::TableCell,   // TableRowHeader
1260        61 => Role::TextArea,    // Text
1261        62 => Role::Button,      // ToggleButton
1262        63 => Role::Toolbar,     // ToolBar
1263        65 => Role::Group,       // Tree
1264        66 => Role::Table,       // TreeTable
1265        67 => Role::Unknown,     // Unknown
1266        68 => Role::Group,       // Viewport
1267        69 => Role::Window,      // Window
1268        75 => Role::Application, // Application
1269        79 => Role::TextField,   // Entry
1270        82 => Role::WebArea,     // DocumentFrame
1271        83 => Role::Heading,     // Heading
1272        85 => Role::Group,       // Section
1273        86 => Role::Group,       // RedundantObject
1274        87 => Role::Group,       // Form
1275        88 => Role::Link,        // Link
1276        90 => Role::TableRow,    // TableRow
1277        91 => Role::TreeItem,    // TreeItem
1278        95 => Role::WebArea,     // DocumentWeb
1279        98 => Role::List,        // ListBox
1280        93 => Role::Tooltip,     // Tooltip
1281        97 => Role::Status,      // StatusBar
1282        101 => Role::Alert,      // Notification
1283        116 => Role::StaticText, // Static
1284        129 => Role::Button,     // PushButtonMenu
1285        _ => Role::Unknown,
1286    }
1287}
1288
1289/// Map AT-SPI2 action name to xa11y Action.
1290fn map_atspi_action(action_name: &str) -> Option<Action> {
1291    match action_name.to_lowercase().as_str() {
1292        "click" | "activate" | "press" | "invoke" => Some(Action::Press),
1293        "toggle" | "check" | "uncheck" => Some(Action::Toggle),
1294        "expand" | "open" => Some(Action::Expand),
1295        "collapse" | "close" => Some(Action::Collapse),
1296        "select" => Some(Action::Select),
1297        "menu" | "showmenu" | "popup" | "show menu" => Some(Action::ShowMenu),
1298        "increment" => Some(Action::Increment),
1299        "decrement" => Some(Action::Decrement),
1300        _ => None,
1301    }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    #[test]
1309    fn test_role_mapping() {
1310        assert_eq!(map_atspi_role("push button"), Role::Button);
1311        assert_eq!(map_atspi_role("check box"), Role::CheckBox);
1312        assert_eq!(map_atspi_role("entry"), Role::TextField);
1313        assert_eq!(map_atspi_role("label"), Role::StaticText);
1314        assert_eq!(map_atspi_role("window"), Role::Window);
1315        assert_eq!(map_atspi_role("frame"), Role::Window);
1316        assert_eq!(map_atspi_role("dialog"), Role::Dialog);
1317        assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
1318        assert_eq!(map_atspi_role("slider"), Role::Slider);
1319        assert_eq!(map_atspi_role("panel"), Role::Group);
1320        assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
1321    }
1322
1323    #[test]
1324    fn test_action_mapping() {
1325        assert_eq!(map_atspi_action("click"), Some(Action::Press));
1326        assert_eq!(map_atspi_action("activate"), Some(Action::Press));
1327        assert_eq!(map_atspi_action("toggle"), Some(Action::Toggle));
1328        assert_eq!(map_atspi_action("expand"), Some(Action::Expand));
1329        assert_eq!(map_atspi_action("collapse"), Some(Action::Collapse));
1330        assert_eq!(map_atspi_action("select"), Some(Action::Select));
1331        assert_eq!(map_atspi_action("increment"), Some(Action::Increment));
1332        assert_eq!(map_atspi_action("foobar"), None);
1333    }
1334}