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