use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use rayon::prelude::*;
use xa11y_core::{
ElementData, ElementParts, Error, Provider, Rect, Result, Role, ShellSurfaceKind, StateParts,
StateSet, Subscription, Toggled,
};
use zbus::blocking::{Connection, Proxy};
use crate::window::WindowManager;
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
fn state_attr_to_string(name: &str, s: &StateSet) -> Option<String> {
match name {
"enabled" => Some(s.enabled.to_string()),
"visible" => Some(s.visible.to_string()),
"focused" => Some(s.focused.to_string()),
"active" => Some(s.active.to_string()),
"focusable" => Some(s.focusable.to_string()),
"selected" => Some(s.selected.to_string()),
"editable" => Some(s.editable.to_string()),
"modal" => Some(s.modal.to_string()),
"required" => Some(s.required.to_string()),
"busy" => Some(s.busy.to_string()),
"expanded" => s.expanded.map(|b| b.to_string()),
"checked" => s.checked.map(|c| {
match c {
Toggled::On => "on",
Toggled::Off => "off",
Toggled::Mixed => "mixed",
}
.to_string()
}),
_ => None,
}
}
pub struct LinuxProvider {
a11y_bus: Connection,
handle_cache: Mutex<HashMap<u64, AccessibleRef>>,
action_indices: Mutex<HashMap<u64, HashMap<String, i32>>>,
window_manager: WindowManager,
}
#[derive(Debug, Clone)]
pub(crate) struct AccessibleRef {
pub(crate) bus_name: String,
pub(crate) path: String,
}
impl LinuxProvider {
pub fn new() -> Result<Self> {
let a11y_bus = Self::connect_a11y_bus()?;
Ok(Self {
a11y_bus,
handle_cache: Mutex::new(HashMap::new()),
action_indices: Mutex::new(HashMap::new()),
window_manager: WindowManager::new(),
})
}
pub(crate) fn connect_a11y_bus() -> Result<Connection> {
if let Ok(session) = Connection::session() {
let proxy = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to create a11y bus proxy: {}", e),
})?;
if let Ok(addr_reply) = proxy.call_method("GetAddress", &()) {
if let Ok(address) = addr_reply.body().deserialize::<String>() {
if let Ok(addr) = zbus::Address::try_from(address.as_str()) {
if let Ok(Ok(conn)) =
zbus::blocking::connection::Builder::address(addr).map(|b| b.build())
{
return Ok(conn);
}
}
}
}
return Ok(session);
}
Connection::session().map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to connect to D-Bus session bus: {}", e),
})
}
fn make_proxy(&self, bus_name: &str, path: &str, interface: &str) -> Result<Proxy<'_>> {
zbus::blocking::proxy::Builder::<Proxy>::new(&self.a11y_bus)
.destination(bus_name.to_owned())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to set proxy destination: {}", e),
})?
.path(path.to_owned())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to set proxy path: {}", e),
})?
.interface(interface.to_owned())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to set proxy interface: {}", e),
})?
.cache_properties(zbus::proxy::CacheProperties::No)
.build()
.map_err(|e| Error::Platform {
code: -1,
message: format!("Failed to create proxy: {}", e),
})
}
fn has_interface(&self, aref: &AccessibleRef, iface: &str) -> bool {
let proxy = match self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible") {
Ok(p) => p,
Err(_) => return false,
};
let reply = match proxy.call_method("GetInterfaces", &()) {
Ok(r) => r,
Err(_) => return false,
};
let interfaces: Vec<String> = match reply.body().deserialize() {
Ok(v) => v,
Err(_) => return false,
};
interfaces.iter().any(|i| i.contains(iface))
}
fn get_role_number(&self, aref: &AccessibleRef) -> Result<u32> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetRole", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRole failed: {}", e),
})?;
reply
.body()
.deserialize::<u32>()
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRole deserialize failed: {}", e),
})
}
fn get_role_name(&self, aref: &AccessibleRef) -> Result<String> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetRoleName", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRoleName failed: {}", e),
})?;
reply
.body()
.deserialize::<String>()
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRoleName deserialize failed: {}", e),
})
}
fn check_chromium_a11y_enabled(
&self,
parent: &AccessibleRef,
role_hint: Option<Role>,
) -> Result<()> {
let app_root = AccessibleRef {
bus_name: parent.bus_name.clone(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let toolkit = match self
.make_proxy(
&app_root.bus_name,
&app_root.path,
"org.a11y.atspi.Application",
)
.ok()
.and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
{
Some(t) => t,
None => return Ok(()),
};
if !toolkit.eq_ignore_ascii_case("Chromium") {
return Ok(());
}
let role = role_hint.unwrap_or_else(|| self.resolve_role(parent));
if role != Role::Window {
return Ok(());
}
let app_name = self.get_name(&app_root).unwrap_or_default();
Err(Error::AccessibilityNotEnabled {
app: app_name,
instructions: "Chromium/Electron app exposes an empty accessibility tree on Linux. \
Relaunch with `--force-renderer-accessibility` (or set the env var \
`ACCESSIBILITY_ENABLED=1`) so the renderer accessibility bridge is initialised."
.to_string(),
})
}
fn get_name(&self, aref: &AccessibleRef) -> Result<String> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
proxy
.get_property::<String>("Name")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Get Name property failed: {}", e),
})
}
fn get_description(&self, aref: &AccessibleRef) -> Result<String> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
proxy
.get_property::<String>("Description")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Get Description property failed: {}", e),
})
}
fn get_atspi_children(&self, aref: &AccessibleRef) -> Result<Vec<AccessibleRef>> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetChildren", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetChildren failed: {}", e),
})?;
let children: Vec<(String, zbus::zvariant::OwnedObjectPath)> =
reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("GetChildren deserialize failed: {}", e),
})?;
Ok(children
.into_iter()
.map(|(bus_name, path)| AccessibleRef {
bus_name,
path: path.to_string(),
})
.collect())
}
fn get_state(&self, aref: &AccessibleRef) -> Result<Vec<u32>> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = proxy
.call_method("GetState", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetState failed: {}", e),
})?;
reply
.body()
.deserialize::<Vec<u32>>()
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetState deserialize failed: {}", e),
})
}
fn scan_role_number(&self, aref: &AccessibleRef) -> Result<Option<u32>> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = match proxy.call_method("GetRole", &()) {
Ok(reply) => reply,
Err(e) if is_peer_gone(&e) => return Ok(None),
Err(e) => {
return Err(Error::Platform {
code: -1,
message: format!("GetRole failed: {}", e),
})
}
};
reply
.body()
.deserialize::<u32>()
.map(Some)
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetRole deserialize failed: {}", e),
})
}
#[allow(
clippy::option_option,
reason = "The two Nones mean different things \
— peer gone versus member absent — and collapsing them would erase \
exactly the distinction this function exists to make."
)]
fn scan_attributes(
&self,
aref: &AccessibleRef,
) -> Result<Option<Option<HashMap<String, String>>>> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Accessible")?;
let reply = match proxy.call_method("GetAttributes", &()) {
Ok(reply) => reply,
Err(e) if is_peer_gone(&e) => return Ok(None),
Err(e) if is_absent_member(&e) => return Ok(Some(None)),
Err(e) => {
return Err(Error::Platform {
code: -1,
message: format!("GetAttributes failed: {}", e),
})
}
};
reply
.body()
.deserialize::<HashMap<String, String>>()
.map(|a| Some(Some(a)))
.map_err(|e| Error::Platform {
code: -1,
message: format!("GetAttributes deserialize failed: {}", e),
})
}
fn is_multi_line(&self, aref: &AccessibleRef) -> bool {
let state_bits = self.get_state(aref).unwrap_or_default();
let bits: u64 = if state_bits.len() >= 2 {
(state_bits[0] as u64) | ((state_bits[1] as u64) << 32)
} else if state_bits.len() == 1 {
state_bits[0] as u64
} else {
0
};
const MULTI_LINE: u64 = 1 << 17;
(bits & MULTI_LINE) != 0
}
fn refine_text_role(&self, coarse: Role, aref: &AccessibleRef) -> Role {
match coarse {
Role::TextArea | Role::TextField => {
if self.is_multi_line(aref) {
Role::TextArea
} else {
Role::TextField
}
}
other => other,
}
}
fn get_extents(&self, aref: &AccessibleRef) -> Option<Rect> {
if !self.has_interface(aref, "Component") {
return None;
}
let proxy = self
.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Component")
.ok()?;
let reply = proxy.call_method("GetExtents", &(0u32,)).ok()?;
let (x, y, w, h): (i32, i32, i32, i32) = reply.body().deserialize().ok()?;
if w <= 0 && h <= 0 {
return None;
}
Some(
Rect {
x,
y,
width: w.max(0) as u32,
height: h.max(0) as u32,
}
.to_logical(crate::scale::coordinate_scale()),
)
}
fn get_actions(&self, aref: &AccessibleRef) -> (Vec<String>, HashMap<String, i32>) {
let mut actions = Vec::new();
let mut indices = HashMap::new();
if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action") {
let n_actions = proxy
.get_property::<i32>("NActions")
.or_else(|_| proxy.get_property::<u32>("NActions").map(|n| n as i32))
.unwrap_or(0);
for i in 0..n_actions {
if let Ok(reply) = proxy.call_method("GetName", &(i,)) {
if let Ok(name) = reply.body().deserialize::<String>() {
if let Some(action_name) = map_atspi_action_name(&name) {
if !actions.contains(&action_name) {
indices.insert(action_name.clone(), i);
actions.push(action_name);
}
}
}
}
}
}
(actions, indices)
}
fn is_gtk_toolkit(&self, aref: &AccessibleRef) -> bool {
let app_root = AccessibleRef {
bus_name: aref.bus_name.clone(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
self.make_proxy(
&app_root.bus_name,
&app_root.path,
"org.a11y.atspi.Application",
)
.ok()
.and_then(|proxy| proxy.get_property::<String>("ToolkitName").ok())
.map(|t| t.eq_ignore_ascii_case("GTK"))
.unwrap_or(false)
}
fn find_gtk_press_fallback(
&self,
outer: &AccessibleRef,
outer_name: &str,
) -> Option<(AccessibleRef, i32)> {
let mut queue: VecDeque<(AccessibleRef, u32)> = VecDeque::new();
queue.push_back((outer.clone(), 0));
let mut visited: usize = 0;
let mut shallowest_depth: Option<u32> = None;
let mut hits: Vec<(AccessibleRef, i32)> = Vec::new();
while let Some((node, depth)) = queue.pop_front() {
if let Some(best) = shallowest_depth {
if depth > best {
continue;
}
}
if visited > GTK_FALLBACK_MAX_NODES {
break;
}
let role_name = if depth == 0 {
String::new()
} else {
visited += 1;
self.get_role_name(&node).unwrap_or_default().to_lowercase()
};
if depth > 0 && is_actionable_atspi_role(&role_name) {
if let Some(idx) = self.gtk_fallback_pick(&node, outer_name) {
match shallowest_depth {
Some(d) if depth < d => {
shallowest_depth = Some(depth);
hits.clear();
hits.push((node.clone(), idx));
}
Some(d) if depth == d => hits.push((node.clone(), idx)),
Some(_) => {}
None => {
shallowest_depth = Some(depth);
hits.push((node.clone(), idx));
}
}
}
}
let stop_descending = depth >= GTK_FALLBACK_MAX_DEPTH
|| (depth > 0 && is_never_descend_atspi_role(&role_name));
if stop_descending {
continue;
}
if let Ok(children) = self.get_atspi_children(&node) {
for c in children {
queue.push_back((c, depth + 1));
}
}
}
if hits.len() == 1 {
Some(hits.into_iter().next().unwrap())
} else {
None
}
}
fn gtk_fallback_pick(&self, aref: &AccessibleRef, outer_name: &str) -> Option<i32> {
let (_, index_map) = self.get_actions(aref);
let idx = *index_map.get("press")?;
if !self.is_showing_visible_sensitive(aref) {
return None;
}
if !outer_name.is_empty() {
let inner_name = self.get_name(aref).unwrap_or_default();
if !inner_name.is_empty() && inner_name != outer_name {
return None;
}
}
Some(idx)
}
fn is_showing_visible_sensitive(&self, aref: &AccessibleRef) -> bool {
let raw = self.get_state(aref).unwrap_or_default();
let bits: u64 = if raw.len() >= 2 {
(raw[0] as u64) | ((raw[1] as u64) << 32)
} else if raw.len() == 1 {
raw[0] as u64
} else {
0
};
const SENSITIVE: u64 = 1 << 24;
const SHOWING: u64 = 1 << 25;
const VISIBLE: u64 = 1 << 30;
(bits & (SHOWING | VISIBLE | SENSITIVE)) == (SHOWING | VISIBLE | SENSITIVE)
}
fn get_value(&self, aref: &AccessibleRef) -> Option<String> {
let text_value = self.get_text_content(aref);
if text_value.is_some() {
return text_value;
}
if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Value") {
if let Ok(val) = proxy.get_property::<f64>("CurrentValue") {
return Some(val.to_string());
}
}
None
}
fn get_text_content(&self, aref: &AccessibleRef) -> Option<String> {
let proxy = self
.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Text")
.ok()?;
let char_count: i32 = proxy.get_property("CharacterCount").ok()?;
if char_count > 0 {
let reply = proxy.call_method("GetText", &(0i32, char_count)).ok()?;
let text: String = reply.body().deserialize().ok()?;
if !text.is_empty() {
return Some(text);
}
}
None
}
fn cache_element(&self, aref: AccessibleRef) -> u64 {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
self.handle_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(handle, aref);
handle
}
fn get_cached(&self, handle: u64) -> Result<AccessibleRef> {
self.handle_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&handle)
.cloned()
.ok_or(Error::ElementStale {
selector: format!("handle:{}", handle),
})
}
fn build_element_data(&self, aref: &AccessibleRef, pid: Option<u32>) -> ElementData {
let role_name = self.get_role_name(aref).unwrap_or_default();
let role_num = self.get_role_number(aref).unwrap_or(0);
let coarse_role = {
let by_name = if !role_name.is_empty() {
map_atspi_role(&role_name)
} else {
Role::Unknown
};
let coarse = if by_name != Role::Unknown {
by_name
} else {
map_atspi_role_number(role_num)
};
self.refine_text_role(coarse, aref)
};
let prefetched_menu_actions = if coarse_role == Role::Menu {
Some(self.get_actions(aref))
} else {
None
};
let role = refine_menu_role(
coarse_role,
prefetched_menu_actions
.as_ref()
.map(|(actions, _)| actions.as_slice())
.unwrap_or(&[]),
);
let (
((mut name, value), description),
(
((states, state_bits), bounds),
((actions, action_index_map), (numeric_value, min_value, max_value)),
),
) = rayon::join(
|| {
rayon::join(
|| {
let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
let value = if role_has_value(role) {
self.get_value(aref)
} else {
None
};
(name, value)
},
|| self.get_description(aref).ok().filter(|s| !s.is_empty()),
)
},
|| {
rayon::join(
|| {
rayon::join(
|| self.parse_states_with_bits(aref, role),
|| {
if role != Role::Application {
self.get_extents(aref)
} else {
None
}
},
)
},
|| {
rayon::join(
|| {
if let Some(actions) = prefetched_menu_actions.as_ref() {
actions.clone()
} else if role_has_actions(role) {
self.get_actions(aref)
} else {
(vec![], HashMap::new())
}
},
|| {
if matches!(
role,
Role::Slider
| Role::ProgressBar
| Role::ScrollBar
| Role::SpinButton
) {
if let Ok(proxy) = self.make_proxy(
&aref.bus_name,
&aref.path,
"org.a11y.atspi.Value",
) {
(
proxy.get_property::<f64>("CurrentValue").ok(),
proxy.get_property::<f64>("MinimumValue").ok(),
proxy.get_property::<f64>("MaximumValue").ok(),
)
} else {
(None, None, None)
}
} else {
(None, None, None)
}
},
)
},
)
},
);
if name.is_none() && role == Role::StaticText {
if let Some(ref v) = value {
name = Some(v.clone());
}
}
let raw = {
let raw_role = if role_name.is_empty() {
format!("role_num:{}", role_num)
} else {
role_name
};
let mut raw = HashMap::new();
raw.insert("atspi_role".into(), serde_json::Value::String(raw_role));
raw.insert(
"bus_name".into(),
serde_json::Value::String(aref.bus_name.clone()),
);
raw.insert(
"object_path".into(),
serde_json::Value::String(aref.path.clone()),
);
if let Some(ref n) = name {
raw.insert("atspi_name".into(), serde_json::Value::String(n.clone()));
}
if let Some(ref v) = value {
raw.insert("atspi_value".into(), serde_json::Value::String(v.clone()));
}
if let Some(ref d) = description {
raw.insert(
"atspi_description".into(),
serde_json::Value::String(d.clone()),
);
}
if (state_bits & (1 << 6)) != 0 {
raw.insert("atspi_defunct".into(), serde_json::Value::Bool(true));
}
raw
};
let name = xa11y_core::text::strip_bidi_opt(name);
let value = xa11y_core::text::strip_bidi_opt(value);
let description = xa11y_core::text::strip_bidi_opt(description);
let handle = self.cache_element(aref.clone());
if !action_index_map.is_empty() {
self.action_indices
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(handle, action_index_map);
}
let mut data: ElementData = ElementParts {
role,
name,
value,
description,
bounds,
actions,
states,
numeric_value,
min_value,
max_value,
stable_id: Some(aref.path.clone()),
pid,
raw,
handle,
}
.into();
if matches!(role, Role::Window | Role::Dialog) && self.is_top_level_window_ref(aref) {
let facts = self.window_manager.facts(&data);
if facts.bounds.is_some() {
data.bounds = facts.bounds;
}
for action in &facts.actions {
if !data.actions.iter().any(|existing| existing == action) {
data.actions.push((*action).to_string());
}
}
if facts.minimized.is_some() {
data.states.minimized = facts.minimized;
}
if facts.maximized.is_some() {
data.states.maximized = facts.maximized;
}
if facts.fullscreen.is_some() {
data.states.fullscreen = facts.fullscreen;
}
if let Some(backend) = facts.backend {
data.raw.insert(
"window_backend".to_string(),
serde_json::Value::String(backend.to_string()),
);
}
if let Some(version) = facts.protocol_version {
data.raw.insert(
"window_protocol_version".to_string(),
serde_json::Value::Number(version.into()),
);
}
let capabilities = [
"activate",
"minimize",
"maximize",
"enter_fullscreen",
"restore",
"close",
"move_to",
"resize_to",
]
.into_iter()
.map(|action| {
let status = if facts.backend.is_none() {
"unknown"
} else if facts.actions.contains(&action) {
"supported"
} else {
"unsupported"
};
(
action.to_string(),
serde_json::Value::String(status.to_string()),
)
})
.collect();
data.raw.insert(
"window_capabilities".to_string(),
serde_json::Value::Object(capabilities),
);
}
data
}
fn get_atspi_parent(&self, aref: &AccessibleRef) -> Result<Option<AccessibleRef>> {
let proxy = self.make_proxy(
&aref.bus_name,
&aref.path,
"org.freedesktop.DBus.Properties",
)?;
let reply = proxy
.call_method("Get", &("org.a11y.atspi.Accessible", "Parent"))
.map_err(|e| Error::Platform {
code: -1,
message: format!("Get Parent property failed: {}", e),
})?;
let variant: zbus::zvariant::OwnedValue =
reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("Parent deserialize variant failed: {}", e),
})?;
let (bus, path): (String, zbus::zvariant::OwnedObjectPath) =
zbus::zvariant::Value::from(variant).try_into().map_err(
|e: zbus::zvariant::Error| Error::Platform {
code: -1,
message: format!("Parent deserialize struct failed: {}", e),
},
)?;
let path_str = path.as_str();
if path_str == "/org/a11y/atspi/null" || bus.is_empty() || path_str.is_empty() {
return Ok(None);
}
if path_str == "/org/a11y/atspi/accessible/root" {
return Ok(None);
}
Ok(Some(AccessibleRef {
bus_name: bus,
path: path_str.to_string(),
}))
}
fn state_bits(&self, aref: &AccessibleRef) -> (u64, bool) {
match self.get_state(aref) {
Ok(state_bits) if state_bits.len() >= 2 => (
(state_bits[0] as u64) | ((state_bits[1] as u64) << 32),
true,
),
Ok(state_bits) if state_bits.len() == 1 => (state_bits[0] as u64, true),
Ok(_) => (0, true),
Err(_) => (0, false),
}
}
fn parse_states(&self, aref: &AccessibleRef, role: Role) -> StateSet {
self.parse_states_with_bits(aref, role).0
}
fn parse_states_with_bits(&self, aref: &AccessibleRef, role: Role) -> (StateSet, u64) {
let (bits, state_read_ok) = self.state_bits(aref);
const ACTIVE: u64 = 1 << 1;
const BUSY: u64 = 1 << 3;
const CHECKED: u64 = 1 << 4;
const EDITABLE: u64 = 1 << 7;
const ENABLED: u64 = 1 << 8;
const EXPANDABLE: u64 = 1 << 9;
const EXPANDED: u64 = 1 << 10;
const FOCUSABLE: u64 = 1 << 11;
const FOCUSED: u64 = 1 << 12;
const ICONIFIED: u64 = 1 << 15;
const MODAL: u64 = 1 << 16;
const SELECTED: u64 = 1 << 23;
const SENSITIVE: u64 = 1 << 24;
const SHOWING: u64 = 1 << 25;
const VISIBLE: u64 = 1 << 30;
const INDETERMINATE: u64 = 1 << 32;
const REQUIRED: u64 = 1 << 33;
let enabled = (bits & ENABLED) != 0 || (bits & SENSITIVE) != 0;
let visible = (bits & VISIBLE) != 0 || (bits & SHOWING) != 0;
let checked = match role {
Role::CheckBox | Role::RadioButton | Role::MenuItem | Role::Switch => {
if (bits & INDETERMINATE) != 0 {
Some(Toggled::Mixed)
} else if (bits & CHECKED) != 0 {
Some(Toggled::On)
} else {
Some(Toggled::Off)
}
}
_ => None,
};
let expanded = if (bits & EXPANDABLE) != 0 {
Some((bits & EXPANDED) != 0)
} else {
None
};
let states = StateParts {
enabled,
visible,
focused: (bits & FOCUSED) != 0,
active: (bits & ACTIVE) != 0,
checked,
selected: (bits & SELECTED) != 0,
expanded,
editable: (bits & EDITABLE) != 0,
focusable: (bits & FOCUSABLE) != 0,
modal: (bits & MODAL) != 0,
required: (bits & REQUIRED) != 0,
busy: (bits & BUSY) != 0,
minimized: if matches!(role, Role::Window | Role::Dialog) {
if state_read_ok {
Some((bits & ICONIFIED) != 0)
} else {
None
}
} else {
None
},
maximized: None,
fullscreen: None,
}
.into();
(states, bits)
}
pub(crate) fn find_apps_by_pid(&self, pid: u32) -> Result<Vec<AccessibleRef>> {
let mut matches = Vec::new();
let registry = AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let children = self.get_atspi_children(®istry)?;
for child in &children {
if child.path == "/org/a11y/atspi/null" {
continue;
}
if let Some(app_pid) = self.get_dbus_pid(&child.bus_name) {
if app_pid == pid {
matches.push(child.clone());
continue;
}
}
if let Ok(proxy) =
self.make_proxy(&child.bus_name, &child.path, "org.a11y.atspi.Application")
{
if let Ok(app_pid) = proxy.get_property::<i32>("Id") {
if app_pid as u32 == pid {
matches.push(child.clone());
}
}
}
}
Ok(matches)
}
fn get_dbus_pid(&self, bus_name: &str) -> Option<u32> {
let proxy = self
.make_proxy(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
)
.ok()?;
let reply = proxy
.call_method("GetConnectionUnixProcessID", &(bus_name,))
.ok()?;
let pid: u32 = reply.body().deserialize().ok()?;
if pid > 0 {
Some(pid)
} else {
None
}
}
fn do_atspi_action_by_index(&self, aref: &AccessibleRef, index: i32) -> Result<()> {
let proxy = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Action")?;
let reply = proxy
.call_method("DoAction", &(index,))
.map_err(|e| Error::Platform {
code: -1,
message: format!("DoAction({}) failed: {}", index, e),
})?;
let performed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("DoAction({}) reply decode failed: {}", index, e),
})?;
if !performed {
return Err(Error::Platform {
code: -1,
message: format!(
"DoAction({}) returned false; the accessibility provider declined the action",
index
),
});
}
Ok(())
}
fn get_action_index(&self, handle: u64, action: &str) -> Result<i32> {
self.action_indices
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&handle)
.and_then(|map| map.get(action).copied())
.ok_or_else(|| Error::ActionNotSupported {
action: action.to_string(),
role: Role::Unknown, })
}
fn get_app_pid(&self, aref: &AccessibleRef) -> Option<u32> {
if let Some(pid) = self.get_dbus_pid(&aref.bus_name) {
return Some(pid);
}
if let Ok(proxy) = self.make_proxy(&aref.bus_name, &aref.path, "org.a11y.atspi.Application")
{
if let Ok(pid) = proxy.get_property::<i32>("Id") {
if pid > 0 && std::path::Path::new(&format!("/proc/{pid}")).exists() {
return Some(pid as u32);
}
}
}
None
}
fn resolve_role(&self, aref: &AccessibleRef) -> Role {
let role_name = self.get_role_name(aref).unwrap_or_default();
let by_name = if !role_name.is_empty() {
map_atspi_role(&role_name)
} else {
Role::Unknown
};
let coarse = if by_name != Role::Unknown {
by_name
} else {
let role_num = self.get_role_number(aref).unwrap_or(0);
map_atspi_role_number(role_num)
};
let role = self.refine_text_role(coarse, aref);
if role == Role::Menu {
let (actions, _) = self.get_actions(aref);
refine_menu_role(role, &actions)
} else {
role
}
}
fn parent_role_is_application(&self, parent: &AccessibleRef) -> Result<bool> {
let role_name = self.get_role_name(parent)?;
let role = if role_name.is_empty() {
map_atspi_role_number(self.get_role_number(parent)?)
} else {
map_atspi_role(&role_name)
};
Ok(role == Role::Application)
}
fn ensure_top_level_window_target(&self, element: &ElementData, action: &str) -> Result<()> {
let target = self.get_cached(element.handle)?;
let Some(parent) = self.get_atspi_parent(&target)? else {
return Ok(());
};
if self.parent_role_is_application(&parent)? {
Ok(())
} else {
Err(Error::ActionNotSupported {
action: action.to_string(),
role: element.role,
})
}
}
fn is_top_level_window_ref(&self, target: &AccessibleRef) -> bool {
match self.get_atspi_parent(target) {
Ok(None) => true,
Ok(Some(parent)) => self.parent_role_is_application(&parent).unwrap_or(false),
Err(_) => false,
}
}
fn matches_ref(
&self,
aref: &AccessibleRef,
simple: &xa11y_core::selector::SimpleSelector,
pid: Option<u32>,
) -> bool {
let needs_role = simple.role.is_some()
|| simple
.filters
.iter()
.any(|f| matches!(f.attr.as_str(), "role" | "checked"));
let role = if needs_role {
Some(self.resolve_role(aref))
} else {
None
};
if let Some(ref role_match) = simple.role {
match role_match {
xa11y_core::selector::RoleMatch::Normalized(expected) => {
if role != Some(*expected) {
return false;
}
}
xa11y_core::selector::RoleMatch::Platform(platform_role) => {
let raw_role = self.get_role_name(aref).unwrap_or_default();
if raw_role != *platform_role {
return false;
}
}
}
}
let mut state_set: Option<StateSet> = None;
for filter in &simple.filters {
let attr = filter.attr.as_str();
let resolved: Option<Option<String>> = match attr {
"role" => Some(role.map(|r| r.to_snake_case().to_string())),
"name" => {
let name = self.get_name(aref).ok().filter(|s| !s.is_empty());
let resolved = if name.is_none() && role == Some(Role::StaticText) {
self.get_value(aref)
} else {
name
};
Some(resolved)
}
"value" => Some(self.get_value(aref)),
"description" => Some(self.get_description(aref).ok().filter(|s| !s.is_empty())),
"enabled" | "visible" | "focused" | "active" | "focusable" | "selected"
| "editable" | "modal" | "required" | "busy" | "expanded" | "checked" => {
let s = state_set.get_or_insert_with(|| {
self.parse_states(aref, role.unwrap_or(Role::Unknown))
});
Some(state_attr_to_string(attr, s))
}
_ => None, };
match resolved {
Some(value) => {
if !xa11y_core::selector::match_op(&filter.op, &filter.value, value.as_deref())
{
return false;
}
}
None => {
let data = self.build_element_data(aref, pid);
return xa11y_core::selector::matches_simple(&data, simple);
}
}
}
true
}
fn collect_matching_refs_group(
&self,
parent: &AccessibleRef,
clauses: &[&xa11y_core::selector::SimpleSelector],
depth: u32,
max_depth: u32,
limit: Option<usize>,
pid: Option<u32>,
) -> Result<Vec<(usize, AccessibleRef)>> {
if depth > max_depth {
return Ok(vec![]);
}
let children = self.get_atspi_children(parent)?;
let parent_is_registry = parent.bus_name == "org.a11y.atspi.Registry";
let mut to_search: Vec<AccessibleRef> = Vec::new();
for child in children {
if child.path == "/org/a11y/atspi/null"
|| child.bus_name.is_empty()
|| child.path.is_empty()
{
continue;
}
if !parent_is_registry {
let child_role = self.get_role_name(&child).unwrap_or_default();
if child_role == "application" {
let grandchildren = self.get_atspi_children(&child).unwrap_or_default();
for gc in grandchildren {
if gc.path == "/org/a11y/atspi/null"
|| gc.bus_name.is_empty()
|| gc.path.is_empty()
{
continue;
}
let gc_role = self.get_role_name(&gc).unwrap_or_default();
if gc_role == "application" {
continue;
}
to_search.push(gc);
}
continue;
}
}
to_search.push(child);
}
if to_search.is_empty() {
self.check_chromium_a11y_enabled(parent, None)?;
}
type ChildBatch = (Vec<(usize, AccessibleRef)>, Option<Error>);
let per_child: Vec<ChildBatch> = to_search
.par_iter()
.map(|child| {
let mut child_results: Vec<(usize, AccessibleRef)> = Vec::new();
for (idx, simple) in clauses.iter().enumerate() {
if self.matches_ref(child, simple, pid) {
child_results.push((idx, child.clone()));
}
}
match self.collect_matching_refs_group(
child,
clauses,
depth + 1,
max_depth,
limit,
pid,
) {
Ok(sub) => {
child_results.extend(sub);
(child_results, None)
}
Err(e @ Error::AccessibilityNotEnabled { .. }) => (Vec::new(), Some(e)),
Err(_) => (child_results, None),
}
})
.collect();
let mut results = Vec::new();
for (batch, maybe_err) in per_child {
if let Some(err) = maybe_err {
return Err(err);
}
for r in batch {
results.push(r);
if let Some(limit) = limit {
if results.len() >= limit {
return Ok(results);
}
}
}
}
Ok(results)
}
}
impl Provider for LinuxProvider {
fn get_children(&self, element: Option<&ElementData>) -> Result<Vec<ElementData>> {
match element {
None => {
self.list_apps()
}
Some(element_data) => {
let aref = self.get_cached(element_data.handle)?;
let children = self.get_atspi_children(&aref)?;
let pid = element_data.pid;
let mut to_build: Vec<AccessibleRef> = Vec::new();
for child_ref in &children {
if child_ref.path == "/org/a11y/atspi/null"
|| child_ref.bus_name.is_empty()
|| child_ref.path.is_empty()
{
continue;
}
let child_role = self.get_role_name(child_ref).unwrap_or_default();
if child_role == "application" {
let grandchildren = self.get_atspi_children(child_ref)?;
for gc_ref in grandchildren {
if gc_ref.path == "/org/a11y/atspi/null"
|| gc_ref.bus_name.is_empty()
|| gc_ref.path.is_empty()
{
continue;
}
let gc_role = self.get_role_name(&gc_ref).unwrap_or_default();
if gc_role == "application" {
continue;
}
to_build.push(gc_ref);
}
continue;
}
to_build.push(child_ref.clone());
}
if to_build.is_empty() {
self.check_chromium_a11y_enabled(&aref, Some(element_data.role))?;
}
let results: Vec<ElementData> = to_build
.par_iter()
.map(|r| self.build_element_data(r, pid))
.collect();
Ok(results)
}
}
}
fn find_elements_group(
&self,
root: &ElementData,
group: &xa11y_core::selector::SelectorGroup,
limit: Option<usize>,
max_depth: Option<u32>,
) -> Result<Vec<ElementData>> {
if group.clauses.is_empty() {
return Ok(vec![]);
}
if group.clauses.iter().any(|c| c.segments.is_empty()) {
return Ok(vec![]);
}
let max_depth_val = max_depth.unwrap_or(xa11y_core::MAX_TREE_DEPTH);
let firsts: Vec<&xa11y_core::selector::SimpleSelector> = group
.clauses
.iter()
.map(|c| &c.segments[0].simple)
.collect();
let start_ref = self.get_cached(root.handle)?;
let phase1_limit = if group.clauses.len() == 1 {
let clause = &group.clauses[0];
let first = firsts[0];
let outer = if clause.segments.len() == 1 {
limit
} else {
None
};
match (outer, first.nth) {
(Some(l), Some(n)) => Some(l.max(n)),
(_, Some(n)) => Some(n),
(l, None) => l,
}
} else {
None
};
let pid_from_root = root.pid;
let phase1: Vec<(usize, AccessibleRef)> = self.collect_matching_refs_group(
&start_ref,
&firsts,
0,
max_depth_val,
phase1_limit,
pid_from_root,
)?;
let mut by_clause: Vec<Vec<(usize, AccessibleRef)>> =
(0..group.clauses.len()).map(|_| Vec::new()).collect();
for (walk_pos, (clause_idx, aref)) in phase1.into_iter().enumerate() {
by_clause[clause_idx].push((walk_pos, aref));
}
let mut merged: Vec<(usize, ElementData)> = Vec::new();
for (clause_idx, hits) in by_clause.into_iter().enumerate() {
if hits.is_empty() {
continue;
}
let clause = &group.clauses[clause_idx];
let phase1_data: Vec<(usize, ElementData)> = hits
.par_iter()
.map(|(pos, aref)| {
(*pos, self.build_element_data(aref, pid_from_root))
})
.collect();
if clause.segments.len() == 1 {
let mut candidates = phase1_data;
if let Some(nth) = clause.segments[0].simple.nth {
if nth <= candidates.len() {
let kept = candidates.remove(nth - 1);
candidates.clear();
candidates.push(kept);
} else {
candidates.clear();
}
}
merged.extend(candidates);
continue;
}
for (anchor_pos, head) in phase1_data {
let narrowed = self.narrow_multi_segment(
vec![head],
&clause.segments[1..],
max_depth_val,
None,
)?;
for n in narrowed {
merged.push((anchor_pos, n));
}
}
}
merged.sort_by_key(|(pos, _)| *pos);
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut out: Vec<ElementData> = Vec::with_capacity(merged.len());
for (_, data) in merged {
if let Ok(aref) = self.get_cached(data.handle) {
if !seen.insert((aref.bus_name.clone(), aref.path.clone())) {
continue;
}
}
out.push(data);
}
if let Some(l) = limit {
out.truncate(l);
}
Ok(out)
}
fn get_parent(&self, element: &ElementData) -> Result<Option<ElementData>> {
let aref = self.get_cached(element.handle)?;
match self.get_atspi_parent(&aref)? {
Some(parent_ref) => {
let data = self.build_element_data(&parent_ref, element.pid);
Ok(Some(data))
}
None => Ok(None),
}
}
fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
let mut surfaces = Vec::new();
'apps: for app in self.list_apps()? {
let app_ref = self.get_cached(app.handle)?;
let top_levels = match self.get_atspi_children(&app_ref) {
Ok(t) => t,
Err(_) => continue,
};
for top in top_levels
.iter()
.filter(|t| t.path != "/org/a11y/atspi/null")
{
let Some(role_number) = self
.scan_role_number(top)
.map_err(|e| classify_failure(&app, top, e))?
else {
continue 'apps;
};
let Some(attributes) = self
.scan_attributes(top)
.map_err(|e| classify_failure(&app, top, e))?
else {
continue 'apps;
};
let Some(attributes) = attributes else {
continue;
};
if is_dock_frame(role_number, &attributes) {
surfaces.push((
ShellSurfaceKind::Panel,
self.build_element_data(top, app.pid),
));
}
}
}
Ok(surfaces)
}
fn splits_app_across_entries(&self) -> bool {
true
}
fn app_roots(&self, app: &ElementData) -> Result<Vec<ElementData>> {
let Some(pid) = app.pid else {
return Ok(vec![app.clone()]);
};
let mut roots: Vec<_> = self
.list_apps()?
.into_iter()
.filter(|candidate| candidate.pid == Some(pid))
.collect();
if roots.is_empty() {
roots.push(app.clone());
}
Ok(roots)
}
fn list_apps(&self) -> Result<Vec<ElementData>> {
let registry = AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let children = self.get_atspi_children(®istry)?;
let valid: Vec<(&AccessibleRef, String)> = children
.iter()
.filter(|c| c.path != "/org/a11y/atspi/null")
.filter_map(|c| {
let name = self.get_name(c).unwrap_or_default();
if name.is_empty() {
None
} else {
Some((c, name))
}
})
.collect();
let results: Vec<ElementData> = valid
.par_iter()
.map(|(child, app_name)| {
let pid = self.get_app_pid(child);
let mut data = self.build_element_data(child, pid);
data.name = Some(app_name.clone());
data
})
.collect();
Ok(results)
}
fn app_by_pid(&self, pid: u32) -> Result<ElementData> {
let registry = AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let children = self.get_atspi_children(®istry)?;
let mut total = 0usize;
let mut unresolved = 0usize;
for child in children.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
total += 1;
match self.get_app_pid(child) {
Some(p) if p == pid => {
let mut data = self.build_element_data(child, Some(p));
let name = self.get_name(child).unwrap_or_default();
if !name.is_empty() {
data.name = Some(name);
}
return Ok(data);
}
Some(_) => {}
None => unresolved += 1,
}
}
Err(
Error::selector_not_matched(format!("application[pid={pid}]")).diagnose(
xa11y_core::Diagnosis::new().last_observed(format!(
"{total} AT-SPI registry entries examined, {unresolved} without a \
resolvable pid"
)),
),
)
}
fn focused_app(&self) -> Result<ElementData> {
const ACTIVE: u64 = 1 << 1;
let registry = AccessibleRef {
bus_name: "org.a11y.atspi.Registry".to_string(),
path: "/org/a11y/atspi/accessible/root".to_string(),
};
let apps = self.get_atspi_children(®istry)?;
for app in apps.iter().filter(|c| c.path != "/org/a11y/atspi/null") {
let windows = match self.get_atspi_children(app) {
Ok(w) => w,
Err(_) => continue,
};
let has_active_window = windows
.iter()
.filter(|w| w.path != "/org/a11y/atspi/null")
.any(|w| self.state_bits(w).0 & ACTIVE != 0);
if has_active_window {
let pid = self.get_app_pid(app);
let mut data = self.build_element_data(app, pid);
let name = self.get_name(app).unwrap_or_default();
if !name.is_empty() {
data.name = Some(name);
}
return Ok(data);
}
}
Err(Error::selector_not_matched("focused application"))
}
fn press(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(index) = self.get_action_index(element.handle, "press") {
return self.do_atspi_action_by_index(&target, index);
}
if element.role == Role::Button && self.is_gtk_toolkit(&target) {
let outer_name = self.get_name(&target).unwrap_or_default();
if let Some((inner, index)) = self.find_gtk_press_fallback(&target, &outer_name) {
return self.do_atspi_action_by_index(&inner, index);
}
}
Err(Error::ActionNotSupported {
action: "press".to_string(),
role: element.role,
})
}
fn focus(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self
.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Component proxy while focusing failed: {}", e),
})?;
let reply = grab_focus_result(
proxy.call_method("GrabFocus", &()),
"focusing",
"focus",
element.role,
)?;
let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("Component.GrabFocus reply while focusing failed: {}", e),
})?;
if grabbed {
return Ok(());
}
Err(Error::ActionNotSupported {
action: "focus".to_string(),
role: element.role,
})
}
fn blur(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Some(parent_ref) = self.get_atspi_parent(&target)? {
if parent_ref.path != "/org/a11y/atspi/null" {
let p = self.make_proxy(
&parent_ref.bus_name,
&parent_ref.path,
"org.a11y.atspi.Component",
)?;
p.call_method("GrabFocus", &())
.map_err(|e| Error::Platform {
code: -1,
message: format!("Component.GrabFocus on parent failed: {}", e),
})?;
return Ok(());
}
}
Err(Error::ActionNotSupported {
action: "blur".to_string(),
role: element.role,
})
}
fn toggle(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(index) = self.get_action_index(element.handle, "toggle") {
return self.do_atspi_action_by_index(&target, index);
}
if matches!(
element.role,
Role::CheckBox | Role::RadioButton | Role::Switch
) {
if let Ok(index) = self.get_action_index(element.handle, "press") {
return self.do_atspi_action_by_index(&target, index);
}
}
Err(Error::ActionNotSupported {
action: "toggle".to_string(),
role: element.role,
})
}
fn select(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "select")
.map_err(|_| Error::ActionNotSupported {
action: "select".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn expand(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "expand")
.map_err(|_| Error::ActionNotSupported {
action: "expand".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn collapse(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "collapse")
.map_err(|_| Error::ActionNotSupported {
action: "collapse".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn show_menu(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let index = self
.get_action_index(element.handle, "show_menu")
.map_err(|_| Error::ActionNotSupported {
action: "show_menu".to_string(),
role: element.role,
})?;
self.do_atspi_action_by_index(&target, index)
}
fn increment(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(index) = self.get_action_index(element.handle, "increment") {
return self.do_atspi_action_by_index(&target, index);
}
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
let current: f64 = proxy
.get_property("CurrentValue")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.CurrentValue failed: {}", e),
})?;
let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
let step = if step <= 0.0 { 1.0 } else { step };
proxy
.set_property("CurrentValue", current + step)
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.SetCurrentValue failed: {}", e),
})
}
fn decrement(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
if let Ok(index) = self.get_action_index(element.handle, "decrement") {
return self.do_atspi_action_by_index(&target, index);
}
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
let current: f64 = proxy
.get_property("CurrentValue")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.CurrentValue failed: {}", e),
})?;
let step: f64 = proxy.get_property("MinimumIncrement").unwrap_or(1.0);
let step = if step <= 0.0 { 1.0 } else { step };
proxy
.set_property("CurrentValue", current - step)
.map_err(|e| Error::Platform {
code: -1,
message: format!("Value.SetCurrentValue failed: {}", e),
})
}
fn scroll_into_view(&self, element: &ElementData) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
proxy
.call_method("ScrollTo", &(0u32,))
.map_err(|e| Error::Platform {
code: -1,
message: format!("ScrollTo failed: {}", e),
})?;
Ok(())
}
fn activate(&self, element: &ElementData) -> Result<()> {
self.ensure_top_level_window_target(element, "activate")?;
if self.window_manager.uses_native() {
return self.window_manager.activate(element);
}
let target = self.get_cached(element.handle)?;
let proxy = self
.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")
.map_err(|e| Error::Platform {
code: -1,
message: format!("Component proxy while activating failed: {}", e),
})?;
let reply = grab_focus_result(
proxy.call_method("GrabFocus", &()),
"activating",
"activate",
element.role,
)?;
let grabbed: bool = reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("Component.GrabFocus reply while activating failed: {}", e),
})?;
if grabbed {
return Ok(());
}
Err(Error::ActionNotSupported {
action: "activate".to_string(),
role: element.role,
})
}
fn minimize(&self, element: &ElementData) -> Result<()> {
self.ensure_top_level_window_target(element, "minimize")?;
self.window_manager.minimize(element)
}
fn maximize(&self, element: &ElementData) -> Result<()> {
self.ensure_top_level_window_target(element, "maximize")?;
self.window_manager.maximize(element)
}
fn enter_fullscreen(&self, element: &ElementData) -> Result<()> {
self.ensure_top_level_window_target(element, "enter_fullscreen")?;
self.window_manager.enter_fullscreen(element)
}
fn restore(&self, element: &ElementData) -> Result<()> {
self.ensure_top_level_window_target(element, "restore")?;
self.window_manager.restore(element)
}
fn close(&self, element: &ElementData) -> Result<()> {
self.ensure_top_level_window_target(element, "close")?;
self.window_manager.close(element)
}
fn move_to(&self, element: &ElementData, x: i32, y: i32) -> Result<()> {
self.ensure_top_level_window_target(element, "move_to")?;
if self.window_manager.uses_native() {
return self.window_manager.move_to(element, x, y);
}
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
let scale = crate::scale::coordinate_scale();
let point = xa11y_core::Point::new(x, y).to_physical(scale);
let reply = proxy
.call_method("SetPosition", &(point.x, point.y, 0u32))
.map_err(|e| {
if is_absent_member(&e) {
Error::ActionNotSupported {
action: "move_to".to_string(),
role: element.role,
}
} else {
Error::Platform {
code: -1,
message: format!("Component.SetPosition while moving window failed: {e}"),
}
}
})?;
let moved: bool = reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("Component.SetPosition reply while moving window failed: {e}"),
})?;
if moved {
Ok(())
} else {
Err(Error::ActionNotSupported {
action: "move_to".to_string(),
role: element.role,
})
}
}
fn resize_to(&self, element: &ElementData, w: u32, h: u32) -> Result<()> {
self.ensure_top_level_window_target(element, "resize_to")?;
if self.window_manager.uses_native() {
return self.window_manager.resize_to(element, w, h);
}
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Component")?;
let physical = Rect {
x: 0,
y: 0,
width: w,
height: h,
}
.to_physical(crate::scale::coordinate_scale());
let width = i32::try_from(physical.width).map_err(|_| Error::InvalidActionData {
message: format!("resize width {w} is too large for AT-SPI"),
})?;
let height = i32::try_from(physical.height).map_err(|_| Error::InvalidActionData {
message: format!("resize height {h} is too large for AT-SPI"),
})?;
let reply = proxy
.call_method("SetSize", &(width, height))
.map_err(|e| {
if is_absent_member(&e) {
Error::ActionNotSupported {
action: "resize_to".to_string(),
role: element.role,
}
} else {
Error::Platform {
code: -1,
message: format!("Component.SetSize while resizing window failed: {e}"),
}
}
})?;
let resized: bool = reply.body().deserialize().map_err(|e| Error::Platform {
code: -1,
message: format!("Component.SetSize reply while resizing window failed: {e}"),
})?;
if resized {
Ok(())
} else {
Err(Error::ActionNotSupported {
action: "resize_to".to_string(),
role: element.role,
})
}
}
fn set_value(&self, element: &ElementData, value: &str) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self
.make_proxy(
&target.bus_name,
&target.path,
"org.a11y.atspi.EditableText",
)
.map_err(|_| Error::TextValueNotSupported)?;
if proxy.call_method("SetTextContents", &(value,)).is_ok() {
return Ok(());
}
let classify_editable_text_error = |op: &str, e: zbus::Error| -> Error {
let msg = e.to_string();
if msg.contains("UnknownMethod") || msg.contains("UnknownInterface") {
Error::TextValueNotSupported
} else {
Error::Platform {
code: -1,
message: format!("EditableText.{} failed: {}", op, msg),
}
}
};
if let Err(e) = proxy.call_method("DeleteText", &(0i32, i32::MAX)) {
return Err(classify_editable_text_error("DeleteText", e));
}
if let Err(e) = proxy.call_method("InsertText", &(0i32, value, value.len() as i32)) {
return Err(classify_editable_text_error("InsertText", e));
}
Ok(())
}
fn set_numeric_value(&self, element: &ElementData, value: f64) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Value")?;
proxy
.set_property("CurrentValue", value)
.map_err(|e| Error::Platform {
code: -1,
message: format!("SetValue failed: {}", e),
})
}
fn type_text(&self, element: &ElementData, text: &str) -> Result<()> {
let target = self.get_cached(element.handle)?;
let text_proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text");
let insert_pos = text_proxy
.as_ref()
.ok()
.and_then(|p| p.get_property::<i32>("CaretOffset").ok())
.unwrap_or(-1);
let proxy = self
.make_proxy(
&target.bus_name,
&target.path,
"org.a11y.atspi.EditableText",
)
.map_err(|_| Error::TextValueNotSupported)?;
let pos = if insert_pos >= 0 {
insert_pos
} else {
i32::MAX
};
proxy
.call_method("InsertText", &(pos, text, text.len() as i32))
.map_err(|e| Error::Platform {
code: -1,
message: format!("EditableText.InsertText failed: {}", e),
})?;
Ok(())
}
fn set_text_selection(&self, element: &ElementData, start: u32, end: u32) -> Result<()> {
let target = self.get_cached(element.handle)?;
let proxy = self.make_proxy(&target.bus_name, &target.path, "org.a11y.atspi.Text")?;
proxy
.call_method("SetSelection", &(0i32, start as i32, end as i32))
.map_err(|e| Error::Platform {
code: -1,
message: format!("Text.SetSelection failed: {}", e),
})?;
Ok(())
}
fn perform_action(&self, element: &ElementData, action: &str) -> Result<()> {
match action {
"press" => self.press(element),
"focus" => self.focus(element),
"blur" => self.blur(element),
"toggle" => self.toggle(element),
"select" => self.select(element),
"expand" => self.expand(element),
"collapse" => self.collapse(element),
"show_menu" => self.show_menu(element),
"increment" => self.increment(element),
"decrement" => self.decrement(element),
"scroll_into_view" => self.scroll_into_view(element),
"activate" => self.activate(element),
"minimize" => self.minimize(element),
"maximize" => self.maximize(element),
"enter_fullscreen" => self.enter_fullscreen(element),
"restore" => self.restore(element),
"close" => self.close(element),
"move_to" => Err(Error::InvalidActionData {
message: "perform_action(\"move_to\") requires coordinates; call move_to(x, y)"
.to_string(),
}),
"resize_to" => Err(Error::InvalidActionData {
message: "perform_action(\"resize_to\") requires dimensions; call \
resize_to(width, height)"
.to_string(),
}),
_ => Err(Error::ActionNotSupported {
action: action.to_string(),
role: element.role,
}),
}
}
fn subscribe(&self, element: &ElementData) -> Result<Subscription> {
let pid = element.pid.ok_or(Error::Platform {
code: -1,
message: "Element has no PID for subscribe".to_string(),
})?;
let app_name = element.name.clone().unwrap_or_default();
crate::events::subscribe_for_pid(self, pid, app_name)
}
}
fn role_has_value(role: Role) -> bool {
!matches!(
role,
Role::Application
| Role::Window
| Role::Dialog
| Role::Group
| Role::MenuBar
| Role::Toolbar
| Role::TabGroup
| Role::SplitGroup
| Role::Table
| Role::TableRow
| Role::Separator
)
}
fn role_has_actions(role: Role) -> bool {
matches!(
role,
Role::Button
| Role::CheckBox
| Role::RadioButton
| Role::MenuItem
| Role::Link
| Role::ComboBox
| Role::TextField
| Role::TextArea
| Role::SpinButton
| Role::Tab
| Role::TreeItem
| Role::ListItem
| Role::ScrollBar
| Role::Slider
| Role::Menu
| Role::Image
| Role::Unknown
)
}
fn grab_focus_result(
call: std::result::Result<zbus::Message, zbus::Error>,
verb: &str,
action: &str,
role: Role,
) -> Result<zbus::Message> {
call.map_err(|e| match e {
zbus::Error::MethodError(name, _, _) => {
grab_focus_error(name.as_str().to_string(), verb, action, role)
}
other => Error::Platform {
code: -1,
message: format!("Component.GrabFocus while {verb} failed: {other}"),
},
})
}
fn grab_focus_error(name: String, verb: &str, action: &str, role: Role) -> Error {
if name == "org.freedesktop.DBus.Error.NotSupported" {
Error::ActionNotSupported {
action: action.to_string(),
role,
}
} else {
Error::Platform {
code: -1,
message: format!("Component.GrabFocus while {verb} failed: {name}"),
}
}
}
pub(crate) fn map_atspi_role(role_name: &str) -> Role {
match role_name.to_lowercase().as_str() {
"application" => Role::Application,
"window" | "frame" => Role::Window,
"dialog" | "file chooser" => Role::Dialog,
"alert" | "notification" | "info bar" => Role::Alert,
"push button" | "push button menu" => Role::Button,
"toggle button" => Role::Switch,
"check box" | "check menu item" => Role::CheckBox,
"radio button" | "radio menu item" => Role::RadioButton,
"entry" | "password text" => Role::TextField,
"spin button" | "spinbutton" => Role::SpinButton,
"text" | "textbox" | "embedded" | "terminal" => Role::TextArea,
"label" | "static" | "caption" => Role::StaticText,
"combo box" | "combobox" => Role::ComboBox,
"list" | "list box" | "listbox" => Role::List,
"list item" => Role::ListItem,
"menu" | "popup menu" => Role::Menu,
"menu item" | "tearoff menu item" => Role::MenuItem,
"menu bar" => Role::MenuBar,
"page tab" => Role::Tab,
"page tab list" => Role::TabGroup,
"table" | "tree table" => Role::Table,
"table row" => Role::TableRow,
"table cell"
| "table column header"
| "table row header"
| "column header"
| "row header" => Role::TableCell,
"tool bar" => Role::Toolbar,
"scroll bar" => Role::ScrollBar,
"slider" => Role::Slider,
"image" | "icon" | "desktop icon" => Role::Image,
"link" => Role::Link,
"panel" | "section" | "form" | "filler" | "viewport" | "scroll pane" | "paragraph"
| "header" | "footer" | "grouping" | "block quote" | "redundant object" | "tree"
| "comment" | "title bar" | "calendar" => Role::Group,
"progress bar" | "level bar" => Role::ProgressBar,
"tree item" => Role::TreeItem,
"document web"
| "document frame"
| "document spreadsheet"
| "document presentation"
| "document text"
| "document email" => Role::WebArea,
"heading" => Role::Heading,
"separator" => Role::Separator,
"split pane" => Role::SplitGroup,
"tooltip" | "tool tip" => Role::Tooltip,
"status bar" | "statusbar" => Role::Status,
"landmark" | "navigation" => Role::Navigation,
_ => xa11y_core::unknown_role(role_name),
}
}
const ATSPI_ROLE_FRAME: u32 = 23;
pub(crate) fn map_atspi_role_number(role: u32) -> Role {
match role {
2 => Role::Alert, 5 => Role::Group, 7 => Role::CheckBox, 8 => Role::CheckBox, 10 => Role::TableCell, 11 => Role::ComboBox, 13 => Role::Image, 16 => Role::Dialog, 19 => Role::Dialog, 20 => Role::Group, 23 => Role::Window, 26 => Role::Image, 27 => Role::Image, 29 => Role::StaticText, 31 => Role::List, 32 => Role::ListItem, 33 => Role::Menu, 34 => Role::MenuBar, 35 => Role::MenuItem, 37 => Role::Tab, 38 => Role::TabGroup, 39 => Role::Group, 40 => Role::TextField, 41 => Role::Menu, 42 => Role::ProgressBar, 43 => Role::Button, 44 => Role::RadioButton, 45 => Role::RadioButton, 47 => Role::TableCell, 48 => Role::ScrollBar, 49 => Role::Group, 50 => Role::Separator, 51 => Role::Slider, 52 => Role::SpinButton, 53 => Role::SplitGroup, 54 => Role::Status, 55 => Role::Table, 56 => Role::TableCell, 57 => Role::TableCell, 58 => Role::TableCell, 59 => Role::MenuItem, 60 => Role::TextArea, 61 => Role::TextArea, 62 => Role::Switch, 63 => Role::Toolbar, 64 => Role::Tooltip, 65 => Role::Group, 66 => Role::Table, 67 => Role::Unknown, 68 => Role::Group, 69 => Role::Window, 71 => Role::Group, 72 => Role::Group, 73 => Role::Group, 75 => Role::Application, 78 => Role::TextArea, 79 => Role::TextField, 81 => Role::StaticText, 82 => Role::WebArea, 83 => Role::Heading, 85 => Role::Group, 86 => Role::Group, 87 => Role::Group, 88 => Role::Link, 90 => Role::TableRow, 91 => Role::TreeItem, 92 => Role::WebArea, 93 => Role::WebArea, 94 => Role::WebArea, 95 => Role::WebArea, 96 => Role::WebArea, 97 => Role::Group, 98 => Role::List, 99 => Role::Group, 101 => Role::Alert, 102 => Role::Alert, 103 => Role::ProgressBar, 104 => Role::Group, 105 => Role::Group, 110 => Role::Navigation, 116 => Role::StaticText, 129 => Role::Button, _ => xa11y_core::unknown_role(&format!("AT-SPI role number {role}")),
}
}
const GTK_FALLBACK_MAX_DEPTH: u32 = 3;
const GTK_FALLBACK_MAX_NODES: usize = 200;
fn is_actionable_atspi_role(role: &str) -> bool {
matches!(
role,
"push button"
| "toggle button"
| "check box"
| "radio button"
| "menu item"
| "check menu item"
| "radio menu item"
| "link"
| "page tab"
| "tab"
| "list item"
| "tree item"
)
}
fn is_never_descend_atspi_role(role: &str) -> bool {
matches!(
role,
"label" | "separator" | "image" | "icon" | "static" | "caption"
)
}
fn is_dock_frame(role_number: u32, attributes: &HashMap<String, String>) -> bool {
role_number == ATSPI_ROLE_FRAME && attributes.get("window-type").is_some_and(|t| t == "dock")
}
const ABSENT_MEMBER_ERRORS: [&str; 3] = [
"org.freedesktop.DBus.Error.UnknownMethod",
"org.freedesktop.DBus.Error.UnknownInterface",
"org.freedesktop.DBus.Error.UnknownObject",
];
const PEER_GONE_ERRORS: [&str; 2] = [
"org.freedesktop.DBus.Error.ServiceUnknown",
"org.freedesktop.DBus.Error.NameHasNoOwner",
];
fn is_peer_gone(err: &zbus::Error) -> bool {
match err {
zbus::Error::MethodError(name, ..) => PEER_GONE_ERRORS.contains(&name.as_str()),
_ => false,
}
}
fn is_absent_member(err: &zbus::Error) -> bool {
match err {
zbus::Error::MethodError(name, ..) => ABSENT_MEMBER_ERRORS.contains(&name.as_str()),
_ => false,
}
}
fn classify_failure(app: &ElementData, top: &AccessibleRef, err: Error) -> Error {
let name = app.name.as_deref().unwrap_or("<unnamed>");
let pid = app.pid.map(|p| format!(" pid={p}")).unwrap_or_default();
Error::Platform {
code: match &err {
Error::Platform { code, .. } => *code,
_ => -1,
},
message: format!(
"could not classify top-level {} of application \"{name}\"{pid} as a shell \
surface: {err}",
top.path
),
}
}
fn map_atspi_action_name(action_name: &str) -> Option<String> {
let lower = action_name.to_lowercase();
let collapsed: String = lower.chars().filter(|c| !matches!(c, '_' | ' ')).collect();
let canonical = match collapsed.as_str() {
"click" | "activate" | "press" | "invoke" | "dodefault" => "press",
"toggle" | "check" | "uncheck" => "toggle",
"expand" | "open" => "expand",
"collapse" | "close" => "collapse",
"select" => "select",
"menu" | "showmenu" | "showcontextmenu" | "contextmenu" | "popup" => "show_menu",
"increment" => "increment",
"decrement" => "decrement",
_ => return None,
};
Some(canonical.to_string())
}
fn refine_menu_role(role: Role, actions: &[String]) -> Role {
if role == Role::Menu && actions.iter().any(|action| action == "press") {
Role::MenuItem
} else {
role
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_role_mapping() {
assert_eq!(map_atspi_role("push button"), Role::Button);
assert_eq!(map_atspi_role("toggle button"), Role::Switch);
assert_eq!(map_atspi_role("check box"), Role::CheckBox);
assert_eq!(map_atspi_role("entry"), Role::TextField);
assert_eq!(map_atspi_role("label"), Role::StaticText);
assert_eq!(map_atspi_role("window"), Role::Window);
assert_eq!(map_atspi_role("frame"), Role::Window);
assert_eq!(map_atspi_role("dialog"), Role::Dialog);
assert_eq!(map_atspi_role("combo box"), Role::ComboBox);
assert_eq!(map_atspi_role("slider"), Role::Slider);
assert_eq!(map_atspi_role("panel"), Role::Group);
assert_eq!(map_atspi_role("unknown_thing"), Role::Unknown);
}
#[test]
fn menu_with_native_activation_is_a_menu_item() {
assert_eq!(
refine_menu_role(Role::Menu, &["press".to_string()]),
Role::MenuItem
);
}
#[test]
fn non_activatable_menu_stays_a_container() {
assert_eq!(refine_menu_role(Role::Menu, &[]), Role::Menu);
assert_eq!(
refine_menu_role(Role::Menu, &["show_menu".to_string()]),
Role::Menu
);
}
#[test]
fn activation_does_not_change_other_roles() {
assert_eq!(
refine_menu_role(Role::MenuBar, &["press".to_string()]),
Role::MenuBar
);
assert_eq!(
refine_menu_role(Role::MenuItem, &["press".to_string()]),
Role::MenuItem
);
}
#[test]
fn test_numeric_role_mapping() {
assert_eq!(map_atspi_role_number(62), Role::Switch);
assert_eq!(map_atspi_role_number(43), Role::Button); assert_eq!(map_atspi_role_number(7), Role::CheckBox);
assert_eq!(map_atspi_role_number(67), Role::Unknown); }
#[test]
fn grab_focus_notsupported_is_action_not_supported() {
let declined = grab_focus_error(
"org.freedesktop.DBus.Error.NotSupported".into(),
"focusing",
"focus",
Role::Button,
);
match declined {
Error::ActionNotSupported { action, role } => {
assert_eq!(action, "focus");
assert_eq!(role, Role::Button);
}
other => panic!("expected ActionNotSupported, got {other:?}"),
}
let broken = grab_focus_error(
"org.freedesktop.DBus.Error.UnknownMethod".into(),
"activating",
"activate",
Role::Window,
);
match broken {
Error::Platform { code, .. } => assert_eq!(code, -1),
other => panic!("expected Platform error, got {other:?}"),
}
}
const ATSPI_ROLE_ENUM: &[(u32, &str)] = &[
(0, "invalid"),
(1, "accelerator label"),
(2, "alert"),
(3, "animation"),
(4, "arrow"),
(5, "calendar"),
(6, "canvas"),
(7, "check box"),
(8, "check menu item"),
(9, "color chooser"),
(10, "column header"),
(11, "combo box"),
(12, "date editor"),
(13, "desktop icon"),
(14, "desktop frame"),
(15, "dial"),
(16, "dialog"),
(17, "directory pane"),
(18, "drawing area"),
(19, "file chooser"),
(20, "filler"),
(21, "focus traversable"),
(22, "font chooser"),
(23, "frame"),
(24, "glass pane"),
(25, "html container"),
(26, "icon"),
(27, "image"),
(28, "internal frame"),
(29, "label"),
(30, "layered pane"),
(31, "list"),
(32, "list item"),
(33, "menu"),
(34, "menu bar"),
(35, "menu item"),
(36, "option pane"),
(37, "page tab"),
(38, "page tab list"),
(39, "panel"),
(40, "password text"),
(41, "popup menu"),
(42, "progress bar"),
(43, "push button"),
(44, "radio button"),
(45, "radio menu item"),
(46, "root pane"),
(47, "row header"),
(48, "scroll bar"),
(49, "scroll pane"),
(50, "separator"),
(51, "slider"),
(52, "spin button"),
(53, "split pane"),
(54, "status bar"),
(55, "table"),
(56, "table cell"),
(57, "table column header"),
(58, "table row header"),
(59, "tearoff menu item"),
(60, "terminal"),
(61, "text"),
(62, "toggle button"),
(63, "tool bar"),
(64, "tool tip"),
(65, "tree"),
(66, "tree table"),
(67, "unknown"),
(68, "viewport"),
(69, "window"),
(70, "extended"),
(71, "header"),
(72, "footer"),
(73, "paragraph"),
(74, "ruler"),
(75, "application"),
(76, "autocomplete"),
(77, "editbar"),
(78, "embedded"),
(79, "entry"),
(80, "chart"),
(81, "caption"),
(82, "document frame"),
(83, "heading"),
(84, "page"),
(85, "section"),
(86, "redundant object"),
(87, "form"),
(88, "link"),
(89, "input method window"),
(90, "table row"),
(91, "tree item"),
(92, "document spreadsheet"),
(93, "document presentation"),
(94, "document text"),
(95, "document web"),
(96, "document email"),
(97, "comment"),
(98, "list box"),
(99, "grouping"),
(100, "image map"),
(101, "notification"),
(102, "info bar"),
(103, "level bar"),
(104, "title bar"),
(105, "block quote"),
(106, "audio"),
(107, "video"),
(108, "definition"),
(109, "article"),
(110, "landmark"),
(111, "log"),
(112, "marquee"),
(113, "math"),
(114, "rating"),
(115, "timer"),
(116, "static"),
(117, "math fraction"),
(118, "math root"),
(119, "subscript"),
(120, "superscript"),
(121, "description list"),
(122, "description term"),
(123, "description value"),
(124, "footnote"),
(125, "content deletion"),
(126, "content insertion"),
(127, "mark"),
(128, "suggestion"),
(129, "push button menu"),
];
#[test]
fn numeric_and_name_role_maps_agree() {
for &(num, name) in ATSPI_ROLE_ENUM {
let by_num = map_atspi_role_number(num);
let by_name = map_atspi_role(name);
assert_eq!(
by_num, by_name,
"AT-SPI role {num} ({name:?}) diverges: numeric map gives \
{by_num:?}, name map gives {by_name:?}"
);
}
}
#[test]
fn test_action_name_mapping() {
assert_eq!(map_atspi_action_name("click"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("activate"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("press"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("invoke"), Some("press".to_string()));
assert_eq!(
map_atspi_action_name("doDefault"),
Some("press".to_string())
);
assert_eq!(
map_atspi_action_name("do_default"),
Some("press".to_string())
);
assert_eq!(map_atspi_action_name("toggle"), Some("toggle".to_string()));
assert_eq!(map_atspi_action_name("check"), Some("toggle".to_string()));
assert_eq!(map_atspi_action_name("uncheck"), Some("toggle".to_string()));
assert_eq!(map_atspi_action_name("expand"), Some("expand".to_string()));
assert_eq!(map_atspi_action_name("open"), Some("expand".to_string()));
assert_eq!(
map_atspi_action_name("collapse"),
Some("collapse".to_string())
);
assert_eq!(map_atspi_action_name("close"), Some("collapse".to_string()));
assert_eq!(map_atspi_action_name("select"), Some("select".to_string()));
assert_eq!(map_atspi_action_name("menu"), Some("show_menu".to_string()));
assert_eq!(
map_atspi_action_name("showmenu"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("popup"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("show menu"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("showContextMenu"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("show_context_menu"),
Some("show_menu".to_string())
);
assert_eq!(
map_atspi_action_name("increment"),
Some("increment".to_string())
);
assert_eq!(
map_atspi_action_name("decrement"),
Some("decrement".to_string())
);
assert_eq!(map_atspi_action_name("foobar"), None);
}
#[test]
fn test_action_name_aliases_roundtrip() {
let atspi_names = [
"click",
"activate",
"press",
"invoke",
"doDefault",
"do_default",
"toggle",
"check",
"uncheck",
"expand",
"open",
"collapse",
"close",
"select",
"menu",
"showmenu",
"showContextMenu",
"show_context_menu",
"popup",
"show menu",
"increment",
"decrement",
];
for name in atspi_names {
let canonical = map_atspi_action_name(name).unwrap_or_else(|| {
panic!("AT-SPI2 name {:?} should map to a canonical name", name)
});
let back = map_atspi_action_name(&canonical)
.unwrap_or_else(|| panic!("canonical {:?} should map back to itself", canonical));
assert_eq!(
canonical, back,
"AT-SPI2 {:?} -> {:?} -> {:?} (expected {:?})",
name, canonical, back, canonical
);
}
}
#[test]
fn test_action_name_case_insensitive() {
assert_eq!(map_atspi_action_name("Click"), Some("press".to_string()));
assert_eq!(map_atspi_action_name("TOGGLE"), Some("toggle".to_string()));
assert_eq!(
map_atspi_action_name("Increment"),
Some("increment".to_string())
);
}
#[test]
fn test_gtk_fallback_actionable_roles() {
for role in [
"push button",
"toggle button",
"check box",
"radio button",
"menu item",
"link",
"tab",
"list item",
"tree item",
] {
assert!(
is_actionable_atspi_role(role),
"{role:?} should be actionable"
);
}
}
#[test]
fn test_gtk_fallback_non_actionable_roles() {
for role in [
"label",
"panel",
"filler",
"section",
"group",
"image",
"separator",
"static",
"frame",
"window",
] {
assert!(
!is_actionable_atspi_role(role),
"{role:?} must not be treated as actionable"
);
}
}
#[test]
fn test_gtk_fallback_never_descend_roles() {
for role in ["label", "separator", "image", "icon", "static", "caption"] {
assert!(
is_never_descend_atspi_role(role),
"{role:?} must block BFS descent"
);
}
for role in ["panel", "filler", "section", "group", "frame"] {
assert!(
!is_never_descend_atspi_role(role),
"container role {role:?} must remain descendable"
);
}
}
fn attrs(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn test_dock_frame_detection() {
assert!(is_dock_frame(
ATSPI_ROLE_FRAME,
&attrs(&[("toolkit", "GTK"), ("window-type", "dock")])
));
assert!(!is_dock_frame(
ATSPI_ROLE_FRAME,
&attrs(&[("window-type", "normal")])
));
assert!(!is_dock_frame(
ATSPI_ROLE_FRAME,
&attrs(&[("toolkit", "GTK")])
));
assert!(!is_dock_frame(ATSPI_ROLE_FRAME, &HashMap::new()));
}
#[test]
fn test_frame_role_number_is_the_atspi_frame_role() {
let (number, name) = ATSPI_ROLE_ENUM
.iter()
.find(|(_, name)| *name == "frame")
.copied()
.expect("the AtspiRole table must carry `frame`");
assert_eq!(number, ATSPI_ROLE_FRAME, "{name} moved");
}
#[test]
fn test_dock_frame_requires_frame_role() {
let dock = attrs(&[("window-type", "dock")]);
for (number, name) in ATSPI_ROLE_ENUM
.iter()
.filter(|(number, _)| *number != ATSPI_ROLE_FRAME)
{
assert!(
!is_dock_frame(*number, &dock),
"role {number} ({name:?}) must not be reported as a shell surface"
);
}
}
fn method_error(name: &str) -> zbus::Error {
let call = zbus::Message::method_call("/org/a11y/atspi/accessible/7", "GetAttributes")
.expect("method-call builder")
.serial(std::num::NonZeroU32::new(1).expect("nonzero"))
.build(&())
.expect("build method call");
let reply = zbus::Message::error(&call.header(), name)
.expect("error builder")
.serial(std::num::NonZeroU32::new(2).expect("nonzero"))
.build(&"boom")
.expect("build error reply");
zbus::Error::from(reply)
}
#[test]
fn an_unimplemented_member_reads_as_absence() {
for name in ABSENT_MEMBER_ERRORS {
assert!(
is_absent_member(&method_error(name)),
"{name} must read as absence"
);
}
}
#[test]
fn a_departed_peer_is_absence_not_failure() {
for name in [
"org.freedesktop.DBus.Error.ServiceUnknown",
"org.freedesktop.DBus.Error.NameHasNoOwner",
] {
assert!(
is_peer_gone(&method_error(name)),
"{name} means the peer left"
);
assert!(!is_absent_member(&method_error(name)), "{name}");
}
}
#[test]
fn a_hung_or_broken_peer_is_still_a_failure() {
for name in [
"org.freedesktop.DBus.Error.NoReply",
"org.freedesktop.DBus.Error.Timeout",
"org.freedesktop.DBus.Error.Disconnected",
"org.freedesktop.DBus.Error.AccessDenied",
"org.freedesktop.DBus.Error.UnknownMethod",
] {
assert!(
!is_peer_gone(&method_error(name)),
"{name} must not be read as the peer having left"
);
}
assert!(!is_peer_gone(&zbus::Error::InvalidReply));
}
#[test]
fn other_dbus_failures_are_not_absence() {
for name in [
"org.freedesktop.DBus.Error.ServiceUnknown",
"org.freedesktop.DBus.Error.NoReply",
"org.freedesktop.DBus.Error.Timeout",
"org.freedesktop.DBus.Error.Disconnected",
"org.freedesktop.DBus.Error.AccessDenied",
"org.freedesktop.DBus.Error.UnknownProperty",
] {
assert!(
!is_absent_member(&method_error(name)),
"{name} must stay a failure"
);
}
assert!(!is_absent_member(&zbus::Error::InvalidReply));
}
#[test]
fn a_classification_failure_names_the_app_and_the_top_level() {
let mut app = ElementData::for_role(Role::Application);
app.name = Some("xfce4-panel".to_string());
app.pid = Some(4242);
let top = AccessibleRef {
bus_name: ":1.42".to_string(),
path: "/org/a11y/atspi/accessible/7".to_string(),
};
let err = classify_failure(
&app,
&top,
Error::Platform {
code: -1,
message: "GetRole failed: connection closed".to_string(),
},
);
let Error::Platform { code, message } = err else {
panic!("a D-Bus failure must stay a platform error");
};
assert_eq!(code, -1);
assert!(message.contains("xfce4-panel"), "{message}");
assert!(message.contains("pid=4242"), "{message}");
assert!(
message.contains("/org/a11y/atspi/accessible/7"),
"{message}"
);
assert!(message.contains("GetRole failed"), "{message}");
}
#[test]
fn a_classification_failure_survives_an_unnamed_app() {
let app = ElementData::for_role(Role::Application);
let top = AccessibleRef {
bus_name: ":1.9".to_string(),
path: "/org/a11y/atspi/accessible/3".to_string(),
};
let err = classify_failure(
&app,
&top,
Error::Platform {
code: -1,
message: "GetAttributes failed".to_string(),
},
);
let msg = err.to_string();
assert!(msg.contains("<unnamed>"), "{msg}");
assert!(msg.contains("/org/a11y/atspi/accessible/3"), "{msg}");
}
}