use super::{A11yRole, A11yState, AccessibilityBridge};
use crate::compat::{lock, HashMap, Mutex, String, ToString};
use crate::core::ObjectId;
#[cfg(not(feature = "linux-a11y"))]
const FEATURE_DISABLED: &str =
"[Linux AT-SPI] linux-a11y feature not enabled — using in-memory store only";
#[cfg(feature = "linux-a11y")]
const ATSPI_REGISTRY_BUS_NAME: &str = "org.a11y.atspi.Registry";
#[cfg(feature = "linux-a11y")]
const ATSPI_REGISTRY_OBJECT_PATH: &str = "/org/a11y/atspi/registry";
#[cfg(feature = "linux-a11y")]
const ATSPI_REGISTRY_INTERFACE: &str = "org.a11y.atspi.Registry";
#[cfg(feature = "linux-a11y")]
const ATSPI_ACCESSIBLE_INTERFACE: &str = "org.a11y.atspi.Accessible";
#[cfg(feature = "linux-a11y")]
fn object_path_for(id: ObjectId) -> Option<zbus::zvariant::ObjectPath<'static>> {
zbus::zvariant::ObjectPath::try_from(format!("/org/a11y/atspi/accessible/{id}")).ok()
}
pub fn atspi_role_id(role: &A11yRole) -> u32 {
match role {
A11yRole::Unknown => 0, A11yRole::Button => 43, A11yRole::Label => 29, A11yRole::TextField => 81, A11yRole::CheckBox => 7, A11yRole::RadioButton => 44, A11yRole::Slider => 51, A11yRole::ProgressBar => 42, A11yRole::List => 31, A11yRole::Table => 63, A11yRole::Image => 26, A11yRole::Link => 30, A11yRole::Heading => 14, A11yRole::Paragraph => 35, A11yRole::Group => 16, A11yRole::Window => 69, A11yRole::Dialog => 17, A11yRole::Menu => 33, A11yRole::MenuItem => 34, A11yRole::Tab => 37, A11yRole::Switch => 59, A11yRole::Alert => 2, A11yRole::ComboBox => 9, A11yRole::SpinButton => 55, A11yRole::StatusBar => 61, A11yRole::ToolTip => 65, A11yRole::Tree => 68, }
}
pub fn atspi_state_bits(state: &A11yState) -> (u32, u32) {
let mut low: u32 = 0;
let mut high: u32 = 0;
if state.enabled {
low |= 1 << 8; }
if state.focused {
low |= 1 << 11; }
if state.selected {
low |= 1 << 25; }
match state.checked {
Some(true) => low |= 1 << 28, Some(false) => {}
None => {}
}
if state.expanded {
low |= 1 << 30; }
if state.mixed {
high |= 1 << 21; }
(low, high)
}
#[cfg(feature = "linux-a11y")]
#[derive(Clone)]
pub struct AtspiAccessible {
state: std::sync::Arc<Mutex<A11yState>>,
path: String,
}
#[cfg(feature = "linux-a11y")]
impl AtspiAccessible {
pub fn new(state: std::sync::Arc<Mutex<A11yState>>, path: String) -> Self {
Self { state, path }
}
}
#[cfg(feature = "linux-a11y")]
#[zbus::interface(name = "org.a11y.atspi.Accessible")]
impl AtspiAccessible {
fn get_role(&self) -> u32 {
let guard = lock(&self.state);
atspi_role_id(&guard.role)
}
fn get_name(&self) -> String {
lock(&self.state).label.clone()
}
fn get_description(&self) -> String {
lock(&self.state).description.clone()
}
fn get_state(&self) -> (u32, u32) {
atspi_state_bits(&lock(&self.state))
}
fn get_child_count(&self) -> u32 {
lock(&self.state).children.len() as u32
}
fn get_parent(&self) -> (String, zbus::zvariant::OwnedObjectPath) {
let root = zbus::zvariant::OwnedObjectPath::try_from("/")
.unwrap_or_else(|_| zbus::zvariant::OwnedObjectPath::try_from("/").expect("root path"));
(self.path.clone(), root)
}
fn get_interfaces(&self) -> Vec<String> {
vec![ATSPI_ACCESSIBLE_INTERFACE.to_string()]
}
}
pub struct LinuxAccessibilityBridge {
names: Mutex<HashMap<ObjectId, String>>,
nodes: Mutex<HashMap<ObjectId, std::sync::Arc<Mutex<A11yState>>>>,
#[cfg(feature = "linux-a11y")]
dbus_connection: Mutex<Option<zbus::Connection>>,
#[cfg(not(feature = "linux-a11y"))]
dbus_connection: Mutex<Option<()>>,
}
impl LinuxAccessibilityBridge {
pub fn new() -> Self {
let conn = Self::try_connect();
if conn.is_some() {
log::info!("[Linux AT-SPI] Bridge initialized with D-Bus connection to a11y bus");
} else {
log::info!(
"[Linux AT-SPI] Bridge initialized in local-only mode \
(no D-Bus connection)"
);
}
Self {
names: Mutex::new(HashMap::new()),
nodes: Mutex::new(HashMap::new()),
dbus_connection: Mutex::new(conn),
}
}
pub fn atspi_object_export_supported(&self) -> bool {
#[cfg(feature = "linux-a11y")]
{
lock(&self.dbus_connection).is_some()
}
#[cfg(not(feature = "linux-a11y"))]
{
false
}
}
pub fn exported_object_count(&self) -> usize {
lock(&self.nodes).len()
}
#[cfg(feature = "linux-a11y")]
fn try_connect() -> Option<zbus::Connection> {
if let Ok(ref bus_addr) = std::env::var("AT_SPI_BUS") {
log::info!("[Linux AT-SPI] Connecting to a11y bus at AT_SPI_BUS={bus_addr}");
let builder = zbus::connection::Builder::address(bus_addr.as_str());
match builder.and_then(|b| pollster::block_on(b.build())) {
Ok(conn) => {
let blocking = zbus::blocking::Connection::from(conn.clone());
Self::register_for_events(&blocking);
log::info!("[Linux AT-SPI] Connected via AT_SPI_BUS");
return Some(conn);
}
Err(e) => {
log::warn!("[Linux AT-SPI] AT_SPI_BUS connection failed: {e}");
}
}
}
log::info!("[Linux AT-SPI] No AT_SPI_BUS set, trying session bus");
match zbus::blocking::Connection::session() {
Ok(conn) => {
let inner = conn.into_inner();
Self::register_for_events(&zbus::blocking::Connection::from(inner.clone()));
log::info!("[Linux AT-SPI] Connected to session bus");
Some(inner)
}
Err(e) => {
log::warn!("[Linux AT-SPI] Session bus connection failed: {e}");
None
}
}
}
#[cfg(not(feature = "linux-a11y"))]
fn try_connect() -> Option<()> {
log::info!("{FEATURE_DISABLED}");
None
}
#[cfg(feature = "linux-a11y")]
fn register_for_events(conn: &zbus::blocking::Connection) {
let proxy = match zbus::blocking::Proxy::new(
conn,
ATSPI_REGISTRY_BUS_NAME,
ATSPI_REGISTRY_OBJECT_PATH,
ATSPI_REGISTRY_INTERFACE,
) {
Ok(p) => p,
Err(e) => {
log::warn!("[Linux AT-SPI] Failed to create registry proxy: {e}");
return;
}
};
let event_types = &[
"Focus:",
"Object:PropertyChange:accessible-name",
"Object:PropertyChange:accessible-value",
"Object:StateChanged:enabled",
"Object:StateChanged:sensitive",
"Object:StateChanged:focused",
];
for event_type in event_types {
match proxy.call_method("RegisterEvent", &event_type) {
Ok(_) => {
log::info!("[Linux AT-SPI] Registered for event: {event_type}");
}
Err(e) => {
log::warn!("[Linux AT-SPI] RegisterEvent({event_type}) failed: {e}");
}
}
}
}
#[cfg(feature = "linux-a11y")]
fn export_object(
conn: &zbus::Connection,
id: ObjectId,
state: &std::sync::Arc<Mutex<A11yState>>,
) -> bool {
let Some(path) = object_path_for(id) else {
log::warn!("[Linux AT-SPI] Refusing to export an unrepresentable object path for {id}");
return false;
};
let service = AtspiAccessible::new(state.clone(), path.to_string());
let blocking = zbus::blocking::Connection::from(conn.clone());
let object_server = blocking.object_server();
match object_server.at(path, service) {
Ok(_added) => true,
Err(e) => {
log::warn!("[Linux AT-SPI] Failed to export object for id={id}: {e}");
false
}
}
}
#[cfg(feature = "linux-a11y")]
fn unexport_object(conn: &zbus::Connection, id: ObjectId) {
let Some(path) = object_path_for(id) else {
return;
};
let blocking = zbus::blocking::Connection::from(conn.clone());
let object_server = blocking.object_server();
match object_server.remove::<AtspiAccessible, _>(path) {
Ok(_destroyed) => {
log::debug!("[Linux AT-SPI] Unexported object for id={id}");
}
Err(e) => {
log::warn!("[Linux AT-SPI] Failed to unexport object for id={id}: {e}");
}
}
}
#[cfg(feature = "linux-a11y")]
fn emit_atspi_event(
conn: &zbus::blocking::Connection,
event_type: &str,
source_path: &zbus::zvariant::ObjectPath<'_>,
detail1: i32,
detail2: i32,
) {
use zbus::zvariant::Value;
let proxy = match zbus::blocking::Proxy::new(
conn,
ATSPI_REGISTRY_BUS_NAME,
ATSPI_REGISTRY_OBJECT_PATH,
ATSPI_REGISTRY_INTERFACE,
) {
Ok(p) => p,
Err(e) => {
log::warn!("[Linux AT-SPI] Failed to create proxy for event emission: {e}");
return;
}
};
let any_data = Value::new("");
let event = (event_type, source_path, detail1, detail2, any_data);
match proxy.call_method("NotifyEvent", &event) {
Ok(_) => {
log::debug!(
"[Linux AT-SPI] Event emitted: {event_type} \
path={source_path} detail1={detail1} detail2={detail2}"
);
}
Err(e) => {
log::warn!("[Linux AT-SPI] NotifyEvent({event_type}) failed: {e}");
}
}
}
#[cfg(not(feature = "linux-a11y"))]
fn dispatch_event(
dbus_connection: &Mutex<Option<()>>,
nodes: &Mutex<HashMap<ObjectId, std::sync::Arc<Mutex<A11yState>>>>,
event_type: &str,
id: ObjectId,
detail1: i32,
detail2: i32,
) {
let _ = (dbus_connection, nodes, event_type, id, detail1, detail2);
}
#[cfg(feature = "linux-a11y")]
fn dispatch_event(
dbus_connection: &Mutex<Option<zbus::Connection>>,
nodes: &Mutex<HashMap<ObjectId, std::sync::Arc<Mutex<A11yState>>>>,
event_type: &str,
id: ObjectId,
detail1: i32,
detail2: i32,
) {
if !lock(nodes).contains_key(&id) {
log::debug!(
"[Linux AT-SPI] Suppressing {event_type} for id={id}: no exported object to be its \
source (D09-A11Y-01)"
);
return;
}
let Some(source_path) = object_path_for(id) else {
log::warn!("[Linux AT-SPI] Refusing to emit {event_type} for unrepresentable id={id}");
return;
};
let conn_guard = lock(dbus_connection);
if let Some(ref conn) = *conn_guard {
Self::emit_atspi_event(
&zbus::blocking::Connection::from(conn.clone()),
event_type,
&source_path,
detail1,
detail2,
);
}
}
}
crate::impl_default_via_new!(LinuxAccessibilityBridge);
impl AccessibilityBridge for LinuxAccessibilityBridge {
fn set_accessibility_name(&self, id: ObjectId, name: &str) {
let mut names = lock(&self.names);
names.insert(id, name.to_string());
}
fn accessibility_name(&self, id: ObjectId) -> Option<String> {
lock(&self.names).get(&id).cloned()
}
fn submit_node_state(&self, id: ObjectId, state: &A11yState) {
self.set_accessibility_name(id, &state.label);
let cell = std::sync::Arc::new(Mutex::new(state.clone()));
lock(&self.nodes).insert(id, cell.clone());
#[cfg(feature = "linux-a11y")]
{
let guard = lock(&self.dbus_connection);
if let Some(ref conn) = *guard {
Self::export_object(conn, id, &cell);
} else {
log::debug!(
"[Linux AT-SPI] No bus connection: node {id} stored in-memory only \
(atspi_object_export_supported() == false)"
);
}
}
#[cfg(not(feature = "linux-a11y"))]
{
let _ = &cell;
}
}
fn node_state(&self, id: ObjectId) -> Option<A11yState> {
lock(&self.nodes).get(&id).map(|cell| lock(cell).clone())
}
fn unregister_node(&self, id: ObjectId) {
lock(&self.names).remove(&id);
let removed = lock(&self.nodes).remove(&id);
#[cfg(feature = "linux-a11y")]
{
if removed.is_some() {
let guard = lock(&self.dbus_connection);
if let Some(ref conn) = *guard {
Self::unexport_object(conn, id);
}
}
}
#[cfg(not(feature = "linux-a11y"))]
{
let _ = removed;
}
}
fn node_count(&self) -> usize {
lock(&self.nodes).len()
}
fn notify_name_changed(&self, id: ObjectId) {
log::info!("[Linux AT-SPI] notify_name_changed: id={id:?}");
Self::dispatch_event(
&self.dbus_connection,
&self.nodes,
"Object:PropertyChange:accessible-name",
id,
0,
0,
);
}
fn notify_value_changed(&self, id: ObjectId) {
log::info!("[Linux AT-SPI] notify_value_changed: id={id:?}");
Self::dispatch_event(
&self.dbus_connection,
&self.nodes,
"Object:PropertyChange:accessible-value",
id,
0,
0,
);
}
fn notify_state_changed(&self, id: ObjectId) {
log::info!("[Linux AT-SPI] notify_state_changed: id={id:?}");
Self::dispatch_event(&self.dbus_connection, &self.nodes, "Object:StateChanged", id, 0, 0);
}
fn notify_focus_changed(&self, id: ObjectId) {
log::info!("[Linux AT-SPI] notify_focus_changed: id={id:?}");
Self::dispatch_event(
&self.dbus_connection,
&self.nodes,
"Focus:",
id,
1, 0,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::accessibility::A11yRole;
fn state(role: A11yRole, label: &str) -> A11yState {
A11yState { role, label: label.to_string(), ..A11yState::default() }
}
#[test]
fn test_name_store_and_retrieve() {
let bridge = LinuxAccessibilityBridge::new();
let id = 42u64;
assert!(bridge.accessibility_name(id).is_none());
bridge.set_accessibility_name(id, "Hello Button");
assert_eq!(bridge.accessibility_name(id).as_deref(), Some("Hello Button"));
bridge.set_accessibility_name(id, "Updated Name");
assert_eq!(bridge.accessibility_name(id).as_deref(), Some("Updated Name"));
}
#[test]
fn test_different_ids_independent() {
let bridge = LinuxAccessibilityBridge::new();
bridge.set_accessibility_name(1, "One");
bridge.set_accessibility_name(2, "Two");
assert_eq!(bridge.accessibility_name(1).as_deref(), Some("One"));
assert_eq!(bridge.accessibility_name(2).as_deref(), Some("Two"));
}
#[test]
fn test_notifications_do_not_panic() {
let bridge = LinuxAccessibilityBridge::new();
let id = 7u64;
bridge.submit_node_state(id, &state(A11yRole::Button, "Test"));
bridge.notify_name_changed(id);
bridge.notify_value_changed(id);
bridge.notify_state_changed(id);
bridge.notify_focus_changed(id);
}
#[test]
fn test_default_equals_new() {
let _default = LinuxAccessibilityBridge::default();
let _new = LinuxAccessibilityBridge::new();
}
#[test]
fn test_send_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<LinuxAccessibilityBridge>();
assert_sync::<LinuxAccessibilityBridge>();
}
#[test]
fn submitted_state_is_stored_in_full_not_only_the_label() {
let bridge = LinuxAccessibilityBridge::new();
let full = A11yState {
role: A11yRole::CheckBox,
label: "Subscribe".to_string(),
description: "Newsletter opt-in".to_string(),
enabled: false,
focused: true,
selected: true,
expanded: true,
value: "on".to_string(),
checked: Some(true),
mixed: true,
children: vec![3, 4],
};
bridge.submit_node_state(5, &full);
let stored = bridge.node_state(5).expect("the node is readable after submit");
assert_eq!(stored, full, "every field must round-trip through the bridge boundary");
assert_eq!(bridge.accessibility_name(5).as_deref(), Some("Subscribe"));
}
#[test]
fn unmount_removes_entries_so_counts_return_to_baseline() {
let bridge = LinuxAccessibilityBridge::new();
assert_eq!(bridge.node_count(), 0, "a fresh bridge records no nodes");
for id in 1..=4u64 {
bridge.submit_node_state(id, &state(A11yRole::Label, "row"));
}
assert_eq!(bridge.node_count(), 4, "distinct ids accumulate one entry each");
assert!(
bridge.accessibility_name(1).is_some(),
"an earlier id is still present before unmount"
);
for id in 1..=4u64 {
bridge.unregister_node(id);
}
assert_eq!(bridge.node_count(), 0, "every unmounted id is gone");
assert!(bridge.accessibility_name(1).is_none(), "and its name is gone too");
assert!(bridge.node_state(1).is_none(), "and its state is gone too");
assert_eq!(bridge.exported_object_count(), 0, "and no object is left exported");
}
#[test]
fn object_export_capability_is_reported_honestly() {
let bridge = LinuxAccessibilityBridge::new();
if bridge.atspi_object_export_supported() {
bridge.submit_node_state(70, &state(A11yRole::Button, "ok"));
assert_eq!(bridge.exported_object_count(), 1, "a live bus exports the node");
bridge.unregister_node(70);
assert_eq!(bridge.exported_object_count(), 0, "unmount unexports it");
} else {
assert_eq!(bridge.exported_object_count(), 0, "no bus means no exported objects");
}
}
#[test]
fn atspi_role_mapping_matches_the_protocol_constants() {
assert_eq!(atspi_role_id(&A11yRole::Button), 43, "ATSPI_ROLE_PUSH_BUTTON");
assert_eq!(atspi_role_id(&A11yRole::CheckBox), 7, "ATSPI_ROLE_CHECK_BOX");
assert_eq!(atspi_role_id(&A11yRole::TextField), 81, "ATSPI_ROLE_TEXT");
assert_eq!(atspi_role_id(&A11yRole::Unknown), 0, "ATSPI_ROLE_INVALID");
assert_ne!(atspi_role_id(&A11yRole::Tree), atspi_role_id(&A11yRole::Link));
}
#[test]
fn atspi_state_bits_reflect_published_fields() {
let enabled = A11yState { enabled: true, ..A11yState::default() };
assert_eq!(atspi_state_bits(&enabled), (1 << 8, 0));
let focused_selected = A11yState { focused: true, selected: true, ..A11yState::default() };
assert_eq!(atspi_state_bits(&focused_selected), ((1 << 11) | (1 << 25), 0));
let checked = A11yState { checked: Some(true), ..A11yState::default() };
assert_eq!(atspi_state_bits(&checked).0 & (1 << 28), 1 << 28);
let uncheckable = A11yState { checked: None, ..A11yState::default() };
assert_eq!(
atspi_state_bits(&uncheckable).0 & (1 << 28),
0,
"a control with no checked state must not claim to be checked"
);
let mixed = A11yState { checked: Some(true), mixed: true, ..A11yState::default() };
assert_eq!(atspi_state_bits(&mixed).1 & (1 << 21), 1 << 21);
}
}