use std::collections::HashMap;
use auv_driver_common::error::DriverResult;
use auv_driver_common::geometry::{CoordinateSpace, Point, Rect};
use auv_driver_common::window::{Window, WindowRef};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{ObjectPath, OwnedObjectPath};
use crate::error::{backend, invalid_input};
pub const WINDOW_REF_PREFIX: &str = "atspi:";
const REGISTRY_DEST: &str = "org.a11y.atspi.Registry";
const ROOT_PATH: &str = "/org/a11y/atspi/accessible/root";
const ACCESSIBLE_IFACE: &str = "org.a11y.atspi.Accessible";
const ACTION_IFACE: &str = "org.a11y.atspi.Action";
const COMPONENT_IFACE: &str = "org.a11y.atspi.Component";
const SELECTION_IFACE: &str = "org.a11y.atspi.Selection";
const STATE_FOCUSED: u32 = 12;
const COORD_TYPE_SCREEN: u32 = 0;
const COORD_TYPE_WINDOW: u32 = 1;
pub const MAX_DEPTH: usize = 40;
pub const MAX_NODES: usize = 2_000;
#[derive(Clone, Debug, PartialEq)]
pub struct Node {
pub depth: usize,
pub path: String,
pub role: String,
pub name: String,
pub description: String,
pub accessible_id: String,
pub value: Option<String>,
pub focused: bool,
pub bounds: Rect,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TreeSnapshot {
pub window_ref: String,
pub nodes: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ActionResult {
pub action_name: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FocusResult {
pub fallback_reason: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjectRef {
pub dest: String,
pub path: String,
}
impl ObjectRef {
pub fn encode(&self) -> String {
format!("{WINDOW_REF_PREFIX}{}{}", self.dest, self.path)
}
pub fn decode(raw: &str) -> DriverResult<Self> {
let rest = raw
.strip_prefix(WINDOW_REF_PREFIX)
.ok_or_else(|| invalid_input(format!("window reference {raw:?} is not an AT-SPI window reference")))?;
let Some(path_start) = rest.find('/') else {
return Err(invalid_input(format!("AT-SPI window reference {raw:?} is missing an object path")));
};
let dest = &rest[..path_start];
let path = &rest[path_start..];
if dest.is_empty() || path.is_empty() {
return Err(invalid_input(format!("AT-SPI window reference {raw:?} is incomplete")));
}
Ok(Self {
dest: dest.to_string(),
path: path.to_string(),
})
}
}
#[derive(Clone, Debug)]
struct Application {
reference: ObjectRef,
name: String,
accessible_id: String,
}
#[derive(Clone, Debug)]
struct Accessible {
reference: ObjectRef,
name: String,
description: String,
accessible_id: String,
role: String,
child_count: i32,
focused: bool,
bounds: Rect,
}
pub fn list_windows() -> DriverResult<Vec<Window>> {
let connection = connect()?;
let root = ObjectRef {
dest: REGISTRY_DEST.to_string(),
path: ROOT_PATH.to_string(),
};
let applications = children(&connection, &root)?
.into_iter()
.filter_map(|reference| application(&connection, reference).transpose())
.collect::<DriverResult<Vec<_>>>()?;
let mut windows = Vec::new();
for app in &applications {
for child in children(&connection, &app.reference)? {
let accessible = accessible(&connection, child)?;
if matches!(accessible.role.as_str(), "window" | "frame" | "dialog") && rect_has_area(accessible.bounds) {
windows.push(window_from_accessible(&app, &accessible, windows.is_empty()));
}
}
}
apply_shell_stage_window_origins(&connection, &applications, &mut windows);
Ok(windows)
}
pub fn snapshot_window(window: &Window) -> DriverResult<TreeSnapshot> {
let root = ObjectRef::decode(&window.reference.id)?;
let connection = connect()?;
let mut nodes = Vec::new();
walk(&connection, &root, 0, "0".to_string(), &mut nodes)?;
if nodes.is_empty() {
return Err(backend("AT-SPI tree snapshot contained no nodes"));
}
Ok(TreeSnapshot {
window_ref: window.reference.id.clone(),
nodes,
})
}
pub fn focus_node(window: &Window, node_path: &str) -> DriverResult<FocusResult> {
let root = ObjectRef::decode(&window.reference.id)?;
let indices = child_indices(node_path)?;
let connection = connect()?;
let references = resolve_path_chain(&connection, &root, &indices)?;
match focus_nearest_accessible(node_path, &connection, &references) {
Ok(result) => Ok(result),
Err(focus_error) => {
let Some(child_index) = indices.last().copied() else {
return Err(focus_error);
};
let parent = references.get(references.len().saturating_sub(2)).ok_or_else(|| backend(focus_error.to_string()))?;
match select_child(&connection, parent, child_index, &focus_error.to_string()) {
Ok(result) => Ok(FocusResult {
fallback_reason: Some(format!("AT-SPI node {node_path} did not accept GrabFocus; used parent {} instead", result.action_name)),
}),
Err(_) => Err(focus_error),
}
}
}
}
pub fn select_node(window: &Window, node_path: &str) -> DriverResult<ActionResult> {
let root = ObjectRef::decode(&window.reference.id)?;
let indices = child_indices(node_path)?;
let connection = connect()?;
let references = resolve_path_chain(&connection, &root, &indices)?;
let reference = references.last().expect("resolve_path_chain always includes root");
let action = select_action(&connection, reference)?;
Ok(ActionResult {
action_name: action.name,
})
}
fn focus_accessible(connection: &Connection, reference: &ObjectRef) -> DriverResult<()> {
component_proxy(connection, reference)?
.call_method("GrabFocus", &())
.map_err(|error| backend(format!("failed to focus AT-SPI node: {error}")))?;
Ok(())
}
fn resolve_path_chain(connection: &Connection, root: &ObjectRef, indices: &[usize]) -> DriverResult<Vec<ObjectRef>> {
let mut current = root.clone();
let mut references = vec![current.clone()];
for index in indices {
let children = children(connection, ¤t)?;
current = children.get(*index).cloned().ok_or_else(|| {
invalid_input(format!("AT-SPI node path references child index {index}, but current node has {} children", children.len()))
})?;
references.push(current.clone());
}
Ok(references)
}
fn focus_nearest_accessible(requested_path: &str, connection: &Connection, references: &[ObjectRef]) -> DriverResult<FocusResult> {
let mut failures = Vec::new();
for (depth, reference) in references.iter().enumerate().rev() {
match focus_accessible(connection, reference) {
Ok(()) if depth + 1 == references.len() => {
return Ok(FocusResult {
fallback_reason: None,
});
}
Ok(()) => {
return Ok(FocusResult {
fallback_reason: Some(format!("AT-SPI node {requested_path} did not accept GrabFocus; focused ancestor depth {depth} instead")),
});
}
Err(error) => failures.push(error.to_string()),
}
}
Err(backend(format!("no node in AT-SPI path {requested_path:?} accepted GrabFocus; failures={failures:?}")))
}
fn child_indices(path: &str) -> DriverResult<Vec<usize>> {
let mut parts = path.split('/');
if parts.next() != Some("0") {
return Err(invalid_input(format!("AT-SPI node path {path:?} must start at root 0")));
}
parts
.map(|part| part.parse::<usize>().map_err(|_| invalid_input(format!("AT-SPI node path {path:?} contains invalid child index {part:?}"))))
.collect()
}
fn connect() -> DriverResult<Connection> {
let session = Connection::session().map_err(|error| backend(format!("failed to connect to session bus: {error}")))?;
let bus = Proxy::new(&session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus")
.map_err(|error| backend(format!("failed to create AT-SPI bus proxy: {error}")))?;
let address: String = bus.call("GetAddress", &()).map_err(|error| backend(format!("failed to get AT-SPI bus address: {error}")))?;
zbus::blocking::connection::Builder::address(address.as_str())
.map_err(|error| backend(format!("failed to configure AT-SPI bus connection: {error}")))?
.build()
.map_err(|error| backend(format!("failed to connect to AT-SPI bus: {error}")))
}
fn application(connection: &Connection, reference: ObjectRef) -> DriverResult<Option<Application>> {
let proxy = accessible_proxy(connection, &reference)?;
let child_count = proxy.get_property::<i32>("ChildCount").unwrap_or_default();
if child_count <= 0 {
return Ok(None);
}
let name = property_string(&proxy, "Name")?;
let accessible_id = property_string(&proxy, "AccessibleId").unwrap_or_default();
drop(proxy);
Ok(Some(Application {
name,
accessible_id,
reference,
}))
}
fn accessible(connection: &Connection, reference: ObjectRef) -> DriverResult<Accessible> {
let proxy = accessible_proxy(connection, &reference)?;
let role: String = proxy.call("GetRoleName", &()).map_err(|error| backend(format!("failed to read AT-SPI role: {error}")))?;
let child_count = proxy.get_property::<i32>("ChildCount").unwrap_or_default();
let name = property_string(&proxy, "Name").unwrap_or_default();
let description = property_string(&proxy, "Description").unwrap_or_default();
let accessible_id = property_string(&proxy, "AccessibleId").unwrap_or_default();
let focused = state_contains(&proxy, STATE_FOCUSED).unwrap_or(false);
drop(proxy);
let bounds = extents(connection, &reference, COORD_TYPE_SCREEN).unwrap_or_default();
Ok(Accessible {
name,
description,
accessible_id,
role,
child_count,
focused,
bounds,
reference,
})
}
fn children(connection: &Connection, reference: &ObjectRef) -> DriverResult<Vec<ObjectRef>> {
let proxy = accessible_proxy(connection, reference)?;
let children: Vec<(String, OwnedObjectPath)> =
proxy.call("GetChildren", &()).map_err(|error| backend(format!("failed to read AT-SPI children: {error}")))?;
Ok(
children
.into_iter()
.map(|(dest, path)| ObjectRef {
dest,
path: path.to_string(),
})
.collect(),
)
}
fn accessible_in_tree(connection: &Connection, reference: ObjectRef) -> DriverResult<Accessible> {
let mut accessible = accessible(connection, reference.clone())?;
accessible.bounds = extents(connection, &reference, COORD_TYPE_WINDOW).unwrap_or(accessible.bounds);
Ok(accessible)
}
fn extents(connection: &Connection, reference: &ObjectRef, coord_type: u32) -> DriverResult<Rect> {
let proxy = component_proxy(connection, reference)?;
let (x, y, width, height): (i32, i32, i32, i32) =
proxy.call("GetExtents", &(coord_type,)).map_err(|error| backend(format!("failed to read AT-SPI extents: {error}")))?;
Ok(Rect::new(f64::from(x), f64::from(y), f64::from(width), f64::from(height)))
}
fn walk(connection: &Connection, reference: &ObjectRef, depth: usize, path: String, nodes: &mut Vec<Node>) -> DriverResult<()> {
if nodes.len() >= MAX_NODES {
return Ok(());
}
let node = accessible_in_tree(connection, reference.clone())?;
let child_count = node.child_count;
nodes.push(Node {
depth,
path: path.clone(),
role: node.role,
name: node.name,
description: node.description,
accessible_id: node.accessible_id,
value: value(connection, reference).ok().filter(|value| !value.is_empty()),
focused: node.focused,
bounds: node.bounds,
});
if depth >= MAX_DEPTH || child_count <= 0 {
return Ok(());
}
for (index, child) in children(connection, reference)?.into_iter().enumerate() {
if nodes.len() >= MAX_NODES {
break;
}
walk(connection, &child, depth + 1, format!("{path}/{index}"), nodes)?;
}
Ok(())
}
fn value(connection: &Connection, reference: &ObjectRef) -> DriverResult<String> {
let proxy = Proxy::new(connection, reference.dest.as_str(), reference.path.as_str(), ACCESSIBLE_IFACE)
.map_err(|error| backend(format!("failed to create AT-SPI Accessible proxy: {error}")))?;
let attributes: HashMap<String, String> =
proxy.call("GetAttributes", &()).map_err(|error| backend(format!("failed to read AT-SPI attributes: {error}")))?;
Ok(attributes.get("value").cloned().unwrap_or_default())
}
fn accessible_proxy<'a>(connection: &'a Connection, reference: &'a ObjectRef) -> DriverResult<Proxy<'a>> {
Proxy::new(connection, reference.dest.as_str(), object_path(reference.path.as_str())?, ACCESSIBLE_IFACE)
.map_err(|error| backend(format!("failed to create AT-SPI Accessible proxy: {error}")))
}
fn component_proxy<'a>(connection: &'a Connection, reference: &'a ObjectRef) -> DriverResult<Proxy<'a>> {
Proxy::new(connection, reference.dest.as_str(), object_path(reference.path.as_str())?, COMPONENT_IFACE)
.map_err(|error| backend(format!("failed to create AT-SPI Component proxy: {error}")))
}
fn action_proxy<'a>(connection: &'a Connection, reference: &'a ObjectRef) -> DriverResult<Proxy<'a>> {
Proxy::new(connection, reference.dest.as_str(), object_path(reference.path.as_str())?, ACTION_IFACE)
.map_err(|error| backend(format!("failed to create AT-SPI Action proxy: {error}")))
}
fn selection_proxy<'a>(connection: &'a Connection, reference: &'a ObjectRef) -> DriverResult<Proxy<'a>> {
Proxy::new(connection, reference.dest.as_str(), object_path(reference.path.as_str())?, SELECTION_IFACE)
.map_err(|error| backend(format!("failed to create AT-SPI Selection proxy: {error}")))
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Action {
index: i32,
name: String,
}
fn select_action(connection: &Connection, reference: &ObjectRef) -> DriverResult<Action> {
let actions = actions(connection, reference)?;
let action =
preferred_action(&actions).ok_or_else(|| backend(format!("AT-SPI node exposes no supported selectable action; actions={actions:?}")))?;
let proxy = action_proxy(connection, reference)?;
let succeeded: bool = proxy
.call("DoAction", &(action.index))
.map_err(|error| backend(format!("failed to perform AT-SPI action {:?}: {error}", action.name)))?;
if !succeeded {
return Err(backend(format!("AT-SPI action {:?} returned false", action.name)));
}
Ok(action.clone())
}
fn actions(connection: &Connection, reference: &ObjectRef) -> DriverResult<Vec<Action>> {
let proxy = action_proxy(connection, reference)?;
let all_actions: zbus::Result<Vec<(String, String, String, String)>> = proxy.call("GetActions", &());
if let Ok(actions) = all_actions {
return Ok(
actions
.into_iter()
.enumerate()
.map(|(index, (name, _, _, _))| Action {
index: i32::try_from(index).expect("AT-SPI action index should fit i32"),
name,
})
.collect(),
);
}
let count = proxy
.call("GetNActions", &())
.or_else(|_| proxy.get_property::<i32>("NActions"))
.map_err(|error| backend(format!("failed to read AT-SPI action count for selectable node: {error}")))?;
let mut actions = Vec::new();
for index in 0..count {
let name: String =
proxy.call("GetName", &(index)).map_err(|error| backend(format!("failed to read AT-SPI action name {index}: {error}")))?;
actions.push(Action { index, name });
}
Ok(actions)
}
fn preferred_action(actions: &[Action]) -> Option<&Action> {
const PREFERRED: &[&str] = &["click", "press", "activate", "select"];
for preferred in PREFERRED {
if let Some(action) = actions.iter().find(|action| action.name.eq_ignore_ascii_case(preferred)) {
return Some(action);
}
}
actions.first()
}
fn select_child(connection: &Connection, parent: &ObjectRef, child_index: usize, action_error: &str) -> DriverResult<ActionResult> {
let child_index = i32::try_from(child_index).map_err(|error| invalid_input(format!("AT-SPI child index is too large: {error}")))?;
let proxy = selection_proxy(connection, parent)?;
let succeeded: bool = proxy
.call("SelectChild", &(child_index))
.map_err(|error| backend(format!("AT-SPI node exposed no action ({action_error}); parent Selection.SelectChild failed: {error}")))?;
if !succeeded {
return Err(backend(format!("AT-SPI node exposed no action ({action_error}); parent Selection.SelectChild returned false")));
}
Ok(ActionResult {
action_name: "Selection.SelectChild".to_string(),
})
}
fn object_path(path: &str) -> DriverResult<ObjectPath<'_>> {
ObjectPath::try_from(path).map_err(|error| invalid_input(format!("invalid AT-SPI object path {path:?}: {error}")))
}
fn property_string(proxy: &Proxy<'_>, name: &str) -> DriverResult<String> {
proxy.get_property::<String>(name).map_err(|error| backend(format!("failed to read AT-SPI property {name}: {error}")))
}
fn state_contains(proxy: &Proxy<'_>, state: u32) -> DriverResult<bool> {
let states: Vec<u32> = proxy.call("GetState", &()).map_err(|error| backend(format!("failed to read AT-SPI state: {error}")))?;
Ok(states.contains(&state))
}
fn window_from_accessible(app: &Application, accessible: &Accessible, is_main: bool) -> Window {
Window {
reference: WindowRef {
id: accessible.reference.encode(),
},
title: non_empty(accessible.name.clone()),
app_name: non_empty(app.name.clone()),
app_bundle_id: non_empty(app.accessible_id.clone()),
process_id: None,
frame: accessible.bounds,
coordinate_space: CoordinateSpace::Screen,
is_main,
is_visible: rect_has_area(accessible.bounds),
}
}
fn apply_shell_stage_window_origins(connection: &Connection, applications: &[Application], windows: &mut [Window]) {
let Ok(stage_rects) = shell_stage_rects(connection, applications) else {
return;
};
if stage_rects.is_empty() {
return;
}
for window in windows {
if !needs_shell_stage_origin(window) {
continue;
}
if let Some(origin) = matching_stage_origin(window.frame, &stage_rects) {
window.frame.origin = origin;
}
}
}
fn shell_stage_rects(connection: &Connection, applications: &[Application]) -> DriverResult<Vec<Rect>> {
let mut rects = Vec::new();
for app in applications.iter().filter(|app| app.name == "gnome-shell" || app.accessible_id == "org.gnome.Shell") {
for child in children(connection, &app.reference)? {
let accessible = accessible(connection, child)?;
if accessible.name != "Main stage" {
continue;
}
let mut nodes = Vec::new();
walk(connection, &accessible.reference, 0, "0".to_string(), &mut nodes)?;
rects.extend(nodes.into_iter().map(|node| node.bounds).filter(|rect| rect_is_stage_candidate(*rect)));
}
}
Ok(rects)
}
fn needs_shell_stage_origin(window: &Window) -> bool {
window.app_name.as_deref() != Some("gnome-shell") && point_is_origin(window.frame.origin) && rect_has_area(window.frame)
}
fn matching_stage_origin(frame: Rect, stage_rects: &[Rect]) -> Option<Point> {
let mut origins = Vec::new();
for rect in stage_rects {
if !same_size(frame, *rect) || point_is_origin(rect.origin) {
continue;
}
if !origins.iter().any(|origin| same_point(*origin, rect.origin)) {
origins.push(rect.origin);
}
}
match origins.as_slice() {
[origin] => Some(*origin),
_ => None,
}
}
fn rect_is_stage_candidate(rect: Rect) -> bool {
rect_has_area(rect)
&& rect.origin.x.is_finite()
&& rect.origin.y.is_finite()
&& rect.size.width.is_finite()
&& rect.size.height.is_finite()
&& rect.origin.x.abs() < 100_000.0
&& rect.origin.y.abs() < 100_000.0
&& rect.size.width < 100_000.0
&& rect.size.height < 100_000.0
}
fn same_size(left: Rect, right: Rect) -> bool {
same_scalar(left.size.width, right.size.width) && same_scalar(left.size.height, right.size.height)
}
fn same_point(left: Point, right: Point) -> bool {
same_scalar(left.x, right.x) && same_scalar(left.y, right.y)
}
fn point_is_origin(point: Point) -> bool {
same_scalar(point.x, 0.0) && same_scalar(point.y, 0.0)
}
fn same_scalar(left: f64, right: f64) -> bool {
(left - right).abs() <= 0.5
}
fn rect_has_area(rect: Rect) -> bool {
rect.size.width > 0.0 && rect.size.height > 0.0
}
fn non_empty(value: String) -> Option<String> {
(!value.trim().is_empty()).then_some(value)
}
#[cfg(test)]
#[path = "atspi_test.rs"]
mod tests;