Skip to content

Commit 58004c9

Browse files
committed
feat: implement core AT-SPI support
1 parent e83cf26 commit 58004c9

File tree

15 files changed

+2358
-715
lines changed

15 files changed

+2358
-715
lines changed

Cargo.lock

Lines changed: 665 additions & 601 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Expect differences to the original project’s API and keywords during the previ
7676
- Pattern Catalogue (German): `docs/patterns.md`
7777
- Provider Checklist (draft): `docs/provider_checklist.md`
7878
- Windows UIA Provider – Design: `docs/provider_windows_uia_design.md`
79+
- Linux/X11 implementation plan: `docs/linux_x11_implementation_plan.md`
7980

8081
## Contributing
8182

crates/platform-linux-x11/src/desktop.rs

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -93,34 +93,37 @@ fn monitors_via_randr<C: x11rb::connection::Connection>(conn: &C, root: u32) ->
9393
// Try RANDR 1.5 get_monitors first
9494
if let Ok(ver_cookie) = conn.randr_query_version(1, 5)
9595
&& ver_cookie.reply().is_ok()
96-
&& let Ok(mon_cookie) = conn.randr_get_monitors(root, true)
97-
&& let Ok(reply) = mon_cookie.reply() {
98-
let mut out = Vec::new();
99-
for m in reply.monitors {
100-
let id = format!("{}x{}@{},{}", m.width, m.height, m.x, m.y);
101-
let bounds = Rect::new(m.x.into(), m.y.into(), m.width.into(), m.height.into());
102-
out.push(MonitorInfo { id, name: None, bounds, is_primary: m.primary, scale_factor: None });
103-
}
104-
return Ok(out);
105-
}
96+
&& let Ok(mon_cookie) = conn.randr_get_monitors(root, true)
97+
&& let Ok(reply) = mon_cookie.reply()
98+
{
99+
let mut out = Vec::new();
100+
for m in reply.monitors {
101+
let id = format!("{}x{}@{},{}", m.width, m.height, m.x, m.y);
102+
let bounds = Rect::new(m.x.into(), m.y.into(), m.width.into(), m.height.into());
103+
out.push(MonitorInfo { id, name: None, bounds, is_primary: m.primary, scale_factor: None });
104+
}
105+
return Ok(out);
106+
}
106107

107108
// Fallback: RANDR <=1.4 via screen resources / crtcs
108109
if let Ok(res_cookie) = conn.randr_get_screen_resources_current(root)
109-
&& let Ok(res) = res_cookie.reply() {
110-
let mut out = Vec::new();
111-
for crtc in res.crtcs {
112-
if let Ok(info_cookie) = conn.randr_get_crtc_info(crtc, 0)
113-
&& let Ok(info) = info_cookie.reply() {
114-
if info.width == 0 || info.height == 0 {
115-
continue;
116-
}
117-
let bounds = Rect::new(info.x.into(), info.y.into(), info.width.into(), info.height.into());
118-
let id = format!("CRTC-{}:{}x{}@{},{}", crtc, info.width, info.height, info.x, info.y);
119-
out.push(MonitorInfo { id, name: None, bounds, is_primary: false, scale_factor: None });
120-
}
110+
&& let Ok(res) = res_cookie.reply()
111+
{
112+
let mut out = Vec::new();
113+
for crtc in res.crtcs {
114+
if let Ok(info_cookie) = conn.randr_get_crtc_info(crtc, 0)
115+
&& let Ok(info) = info_cookie.reply()
116+
{
117+
if info.width == 0 || info.height == 0 {
118+
continue;
119+
}
120+
let bounds = Rect::new(info.x.into(), info.y.into(), info.width.into(), info.height.into());
121+
let id = format!("CRTC-{}:{}x{}@{},{}", crtc, info.width, info.height, info.x, info.y);
122+
out.push(MonitorInfo { id, name: None, bounds, is_primary: false, scale_factor: None });
121123
}
122-
return Ok(out);
123124
}
125+
return Ok(out);
126+
}
124127

125128
Err("RANDR unavailable".into())
126129
}

crates/platform-linux-x11/src/highlight.rs

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -136,27 +136,27 @@ impl OverlayThread {
136136

137137
if idx >= segments.len()
138138
&& let Ok(win) = conn.generate_id()
139-
&& conn
140-
.create_window(
141-
screen.root_depth,
142-
win,
143-
root,
144-
rect.x,
145-
rect.y,
146-
rect.width,
147-
rect.height,
148-
0,
149-
x::WindowClass::INPUT_OUTPUT,
150-
screen.root_visual,
151-
&x::CreateWindowAux::new()
152-
.background_pixel(red_pixel)
153-
.border_pixel(0)
154-
.override_redirect(1),
155-
)
156-
.is_ok()
157-
{
158-
segments.push(win);
159-
}
139+
&& conn
140+
.create_window(
141+
screen.root_depth,
142+
win,
143+
root,
144+
rect.x,
145+
rect.y,
146+
rect.width,
147+
rect.height,
148+
0,
149+
x::WindowClass::INPUT_OUTPUT,
150+
screen.root_visual,
151+
&x::CreateWindowAux::new()
152+
.background_pixel(red_pixel)
153+
.border_pixel(0)
154+
.override_redirect(1),
155+
)
156+
.is_ok()
157+
{
158+
segments.push(win);
159+
}
160160

161161
if let Some(&win) = segments.get(idx) {
162162
let _ = conn.change_window_attributes(
@@ -195,13 +195,14 @@ impl OverlayThread {
195195
}
196196
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
197197
if let Some(t) = deadline
198-
&& Instant::now() >= t {
199-
for w in &segments {
200-
let _ = conn.unmap_window(*w);
201-
}
202-
let _ = conn.flush();
203-
deadline = None;
198+
&& Instant::now() >= t
199+
{
200+
for w in &segments {
201+
let _ = conn.unmap_window(*w);
204202
}
203+
let _ = conn.flush();
204+
deadline = None;
205+
}
205206
// no messages; continue pumping
206207
}
207208
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,

crates/platform-linux-x11/src/screenshot.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,8 @@ impl ScreenshotProvider for LinuxScreenshot {
2020
(0, 0, geom.width, geom.height)
2121
};
2222

23-
let reply = guard
24-
.conn
25-
.get_image(ImageFormat::Z_PIXMAP, root, x, y, w, h, !0)
26-
.map_err(to_pf)?
27-
.reply()
28-
.map_err(to_pf)?;
23+
let reply =
24+
guard.conn.get_image(ImageFormat::Z_PIXMAP, root, x, y, w, h, !0).map_err(to_pf)?.reply().map_err(to_pf)?;
2925

3026
// Heuristic: Use BGRA8 (many X11 servers deliver BGRX/BGRA in ZPixmap 32bpp)
3127
let depth = reply.depth;

crates/provider-atspi/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,8 @@ rust-version.workspace = true
1616
platynui-core = { path = "../core" }
1717
once_cell = "1.21"
1818
inventory = "0.3"
19+
atspi-common = { version = "0.13", default-features = false }
20+
atspi-connection = { version = "0.13", default-features = false }
21+
atspi-proxies = "0.13"
22+
zbus = "5.5"
23+
futures-lite = "2"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
use atspi_connection::AccessibilityConnection;
2+
use futures_lite::future::block_on;
3+
use platynui_core::provider::{ProviderError, ProviderErrorKind};
4+
use zbus::Address;
5+
6+
const A11Y_BUS_ENV: &str = "AT_SPI_BUS_ADDRESS";
7+
8+
pub fn connect_a11y_bus() -> Result<AccessibilityConnection, ProviderError> {
9+
if let Ok(address) = std::env::var(A11Y_BUS_ENV) {
10+
return connect_address(&address);
11+
}
12+
13+
block_on(AccessibilityConnection::new()).map_err(|err| {
14+
ProviderError::new(ProviderErrorKind::InitializationFailed, format!("a11y connection failed: {err}"))
15+
})
16+
}
17+
18+
fn connect_address(address: &str) -> Result<AccessibilityConnection, ProviderError> {
19+
let addr: Address = address.parse().map_err(|err| {
20+
ProviderError::new(ProviderErrorKind::InitializationFailed, format!("a11y bus address invalid: {err}"))
21+
})?;
22+
block_on(AccessibilityConnection::from_address(addr)).map_err(|err| {
23+
ProviderError::new(ProviderErrorKind::InitializationFailed, format!("a11y bus connect failed: {err}"))
24+
})
25+
}

crates/provider-atspi/src/lib.rs

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
1-
//! AT-SPI2 UiTree provider for Unix desktops (stub).
1+
//! AT-SPI2 UiTree provider for Unix desktops.
22
//!
3-
//! This crate currently exposes a minimal provider factory so tests and
4-
//! consumers can construct a `Runtime` with an AT-SPI2 provider via
5-
//! `Runtime::new_with_factories(&[&ATSPI_FACTORY])`. The actual D-Bus backed
6-
//! implementation will be added incrementally.
3+
//! Provides a blocking D-Bus integration to query the accessibility tree on
4+
//! Linux/X11 systems. Event streaming and full WindowSurface integration will
5+
//! follow in later phases.
76
8-
use once_cell::sync::Lazy;
9-
use platynui_core::provider::{ProviderDescriptor, ProviderError, ProviderKind, UiTreeProvider, UiTreeProviderFactory};
7+
mod connection;
8+
mod node;
9+
10+
use crate::connection::connect_a11y_bus;
11+
use crate::node::AtspiNode;
12+
use atspi_connection::AccessibilityConnection;
13+
use atspi_proxies::accessible::AccessibleProxy;
14+
use futures_lite::future::block_on;
15+
use once_cell::sync::{Lazy, OnceCell};
16+
use platynui_core::provider::{
17+
ProviderDescriptor, ProviderError, ProviderErrorKind, ProviderKind, UiTreeProvider, UiTreeProviderFactory,
18+
};
1019
use platynui_core::ui::{TechnologyId, UiNode};
1120
use std::sync::Arc;
21+
use zbus::proxy::CacheProperties;
1222

1323
pub const PROVIDER_ID: &str = "atspi";
1424
pub const PROVIDER_NAME: &str = "AT-SPI2";
1525
pub static TECHNOLOGY: Lazy<TechnologyId> = Lazy::new(|| TechnologyId::from("AT-SPI2"));
1626

17-
// Bus discovery and the actual AT-SPI integration will be added in Phase 2.
18-
// No feature gate is used because the functionality is part of the core Linux
19-
// path; the module will land once we introduce the required dependencies.
27+
const REGISTRY_BUS: &str = "org.a11y.atspi.Registry";
28+
const ROOT_PATH: &str = "/org/a11y/atspi/accessible/root";
2029

2130
pub struct AtspiFactory;
2231

@@ -33,32 +42,69 @@ impl UiTreeProviderFactory for AtspiFactory {
3342
}
3443
}
3544

36-
struct AtspiProvider {
45+
pub struct AtspiProvider {
3746
descriptor: &'static ProviderDescriptor,
47+
conn: OnceCell<Arc<AccessibilityConnection>>,
3848
}
3949

4050
impl AtspiProvider {
4151
fn new() -> Self {
4252
static DESCRIPTOR: Lazy<ProviderDescriptor> = Lazy::new(|| {
4353
ProviderDescriptor::new(PROVIDER_ID, PROVIDER_NAME, TechnologyId::from("AT-SPI2"), ProviderKind::Native)
4454
});
45-
Self { descriptor: &DESCRIPTOR }
55+
Self { descriptor: &DESCRIPTOR, conn: OnceCell::new() }
56+
}
57+
58+
fn connection(&self) -> Result<Arc<AccessibilityConnection>, ProviderError> {
59+
self.conn
60+
.get_or_try_init(|| connect_a11y_bus().map(Arc::new))
61+
.map(Arc::clone)
62+
.map_err(|err| ProviderError::new(ProviderErrorKind::TreeUnavailable, err.to_string()))
4663
}
4764
}
4865

4966
impl UiTreeProvider for AtspiProvider {
5067
fn descriptor(&self) -> &ProviderDescriptor {
5168
self.descriptor
5269
}
70+
5371
fn get_nodes(
5472
&self,
55-
_parent: Arc<dyn UiNode>,
73+
parent: Arc<dyn UiNode>,
5674
) -> Result<Box<dyn Iterator<Item = Arc<dyn UiNode>> + Send>, ProviderError> {
57-
Ok(Box::new(std::iter::empty()))
75+
let conn = self.connection()?;
76+
let proxy = block_on(
77+
AccessibleProxy::builder(conn.connection())
78+
.cache_properties(CacheProperties::No)
79+
.destination(REGISTRY_BUS)
80+
.map_err(|err| {
81+
ProviderError::new(ProviderErrorKind::CommunicationFailure, format!("registry destination: {err}"))
82+
})?
83+
.path(ROOT_PATH)
84+
.map_err(|err| {
85+
ProviderError::new(ProviderErrorKind::CommunicationFailure, format!("registry path: {err}"))
86+
})?
87+
.build(),
88+
)
89+
.map_err(|err| ProviderError::new(ProviderErrorKind::CommunicationFailure, format!("registry proxy: {err}")))?;
90+
91+
let children = block_on(proxy.get_children()).map_err(|err| {
92+
ProviderError::new(ProviderErrorKind::CommunicationFailure, format!("registry children: {err}"))
93+
})?;
94+
95+
let parent = Arc::clone(&parent);
96+
let conn = conn.clone();
97+
Ok(Box::new(children.into_iter().filter_map(move |child| {
98+
if AtspiNode::is_null_object(&child) {
99+
return None;
100+
}
101+
let node = AtspiNode::new(conn.clone(), child, Some(&parent));
102+
Some(node as Arc<dyn UiNode>)
103+
})))
58104
}
59105
}
60106

61107
pub static ATSPI_FACTORY: AtspiFactory = AtspiFactory;
62108

63-
// Auto-register the AT-SPI provider when linked
109+
// Auto-register the AT-SPI provider when linked.
64110
platynui_core::register_provider!(&ATSPI_FACTORY);

0 commit comments

Comments
 (0)