Skip to main content

xa11y_linux/
screenshot.rs

1//! Linux screen capture. X11 path uses `GetImage` on the root window; Wayland
2//! path uses `org.freedesktop.portal.Screenshot` (PNG file URI returned by
3//! xdg-desktop-portal, decoded back into RGBA).
4//!
5//! Wayland portal capture **prompts the user** for consent the first time per
6//! session (subsequent calls may be auto-approved depending on the portal
7//! implementation) and captures the full screen only — regions are cropped
8//! client-side from the full capture.
9//!
10//! Captured pixels are physical on both paths. `Screenshot::scale` carries the
11//! physical-to-logical ratio detected by [`crate::scale`]: an integer factor on
12//! a pure-X11 integer-scaled session (read from `Xft.dpi`), and `1.0`
13//! elsewhere (Wayland, fractional, or unknown). `capture_region` receives a
14//! **logical** rectangle (matching `Element::bounds`) and converts it to
15//! physical before reading pixels.
16
17use std::collections::HashMap;
18use std::io::Read as _;
19use std::sync::Mutex;
20
21use x11rb::connection::Connection;
22use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat};
23use x11rb::rust_connection::RustConnection;
24use zbus::blocking::{Connection as ZbusConnection, MessageIterator, Proxy};
25use zbus::message::Type as MessageType;
26use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
27use zbus::MatchRule;
28
29use xa11y_core::{CaptureMapping, Error, Point, Rect, Result, Screenshot, ScreenshotProvider};
30
31use crate::session::{select_backend, DesktopBackend};
32
33/// Choose the Linux screenshot backend based on session environment.
34pub struct LinuxScreenshot {
35    backend: Backend,
36}
37
38// Box the X11 variant's fields — `RustConnection` is large enough that the
39// enum would otherwise be dominated by the X11 case and clippy flags it as
40// `large_enum_variant`. The backend sits behind an `Arc` in `Screenshotter`,
41// so the extra indirection costs at most one allocation per process.
42enum Backend {
43    X11(Box<X11Backend>),
44    Wayland { conn: ZbusConnection },
45}
46
47struct X11Backend {
48    conn: Mutex<RustConnection>,
49    root_width: u16,
50    root_height: u16,
51    root: u32,
52}
53
54impl LinuxScreenshot {
55    pub fn new() -> Result<Self> {
56        match select_backend("XA11Y_LINUX_SCREENSHOT_BACKEND", "screenshot capture")? {
57            DesktopBackend::X11 => {
58                let (conn, screen_num) =
59                    RustConnection::connect(None).map_err(|e| Error::Platform {
60                        code: -1,
61                        message: format!("X11 connect: {e}"),
62                    })?;
63                let screen = conn
64                    .setup()
65                    .roots
66                    .get(screen_num)
67                    .ok_or_else(|| Error::Platform {
68                        code: -1,
69                        message: "X server reported no screens".into(),
70                    })?;
71                let root = screen.root;
72                let root_width = screen.width_in_pixels;
73                let root_height = screen.height_in_pixels;
74                Ok(Self {
75                    backend: Backend::X11(Box::new(X11Backend {
76                        conn: Mutex::new(conn),
77                        root,
78                        root_width,
79                        root_height,
80                    })),
81                })
82            }
83            DesktopBackend::Wayland => {
84                let conn = ZbusConnection::session().map_err(|e| Error::Platform {
85                    code: -1,
86                    message: format!("session bus connect for Wayland screenshot portal: {e}"),
87                })?;
88                Ok(Self {
89                    backend: Backend::Wayland { conn },
90                })
91            }
92        }
93    }
94
95    fn capture_x11(
96        &self,
97        conn: &Mutex<RustConnection>,
98        root: u32,
99        root_w: u16,
100        root_h: u16,
101        rect: Option<Rect>,
102    ) -> Result<Screenshot> {
103        let (x, y, w, h) = match rect {
104            None => (0_i16, 0_i16, root_w, root_h),
105            Some(r) => {
106                let x = i16::try_from(r.x).map_err(|_| Error::Platform {
107                    code: -1,
108                    message: "rect x out of i16 range".into(),
109                })?;
110                let y = i16::try_from(r.y).map_err(|_| Error::Platform {
111                    code: -1,
112                    message: "rect y out of i16 range".into(),
113                })?;
114                let w = u16::try_from(r.width).map_err(|_| Error::Platform {
115                    code: -1,
116                    message: "rect width out of u16 range".into(),
117                })?;
118                let h = u16::try_from(r.height).map_err(|_| Error::Platform {
119                    code: -1,
120                    message: "rect height out of u16 range".into(),
121                })?;
122                (x, y, w, h)
123            }
124        };
125        if w == 0 || h == 0 {
126            return Err(Error::Platform {
127                code: -1,
128                message: "zero-sized capture rect".into(),
129            });
130        }
131
132        let guard = conn.lock().unwrap_or_else(|e| e.into_inner());
133        let reply = guard
134            .get_image(ImageFormat::Z_PIXMAP, root, x, y, w, h, !0)
135            .map_err(|e| Error::Platform {
136                code: -1,
137                message: format!("GetImage: {e}"),
138            })?
139            .reply()
140            .map_err(|e| Error::Platform {
141                code: -1,
142                message: format!("GetImage reply: {e}"),
143            })?;
144
145        // Most modern X servers return Z_PIXMAP at 32 bpp for 24-bit visuals,
146        // with layout BGRX on little-endian. Detect and convert to RGBA.
147        let bpp = (reply.data.len() / (w as usize * h as usize)) as u32;
148        if bpp != 4 {
149            return Err(Error::Platform {
150                code: -1,
151                message: format!("unsupported X11 pixmap layout: {bpp} bytes/pixel (expected 4)"),
152            });
153        }
154
155        let mut rgba = Vec::with_capacity(reply.data.len());
156        for chunk in reply.data.as_chunks::<4>().0 {
157            // X11 BGRX little-endian → RGBA
158            rgba.push(chunk[2]);
159            rgba.push(chunk[1]);
160            rgba.push(chunk[0]);
161            rgba.push(0xFF);
162        }
163
164        Ok(Screenshot::new(w as u32, h as u32, rgba, 1.0))
165    }
166
167    fn capture_wayland(&self, conn: &ZbusConnection, rect: Option<Rect>) -> Result<Screenshot> {
168        let proxy = Proxy::new(
169            conn,
170            "org.freedesktop.portal.Desktop",
171            "/org/freedesktop/portal/desktop",
172            "org.freedesktop.portal.Screenshot",
173        )
174        .map_err(|e| Error::Platform {
175            code: -1,
176            message: format!("portal Screenshot proxy: {e}"),
177        })?;
178
179        let mut options: HashMap<&str, Value> = HashMap::new();
180        options.insert("interactive", Value::Bool(false));
181        options.insert("modal", Value::Bool(false));
182        let handle_token = portal_handle_token()?;
183        options.insert("handle_token", Value::from(handle_token.as_str()));
184
185        // Portal responses can arrive before the method call returns. Register
186        // the signal match first, then select the response whose path matches
187        // the handle returned by Screenshot. `handle_token` also makes every
188        // request path unique; without it, repeated captures can collide in
189        // portal backends that export one object per request.
190        let response_rule = MatchRule::builder()
191            .msg_type(MessageType::Signal)
192            .interface("org.freedesktop.portal.Request")
193            .map_err(|e| Error::Platform {
194                code: -1,
195                message: format!("portal Response match interface: {e}"),
196            })?
197            .member("Response")
198            .map_err(|e| Error::Platform {
199                code: -1,
200                message: format!("portal Response match member: {e}"),
201            })?
202            .build();
203        let mut responses =
204            MessageIterator::for_match_rule(response_rule, conn, Some(8)).map_err(|e| {
205                Error::Platform {
206                    code: -1,
207                    message: format!("subscribe to portal Response: {e}"),
208                }
209            })?;
210
211        let request_path: OwnedObjectPath =
212            proxy
213                .call("Screenshot", &("", options))
214                .map_err(|e| Error::Platform {
215                    code: -1,
216                    message: format!("portal Screenshot call: {e}"),
217                })?;
218
219        // Block for Response(response: u, results: a{sv}) on this request.
220        let msg = loop {
221            let msg = responses
222                .next()
223                .ok_or_else(|| Error::Platform {
224                    code: -1,
225                    message: "portal Response signal channel closed".into(),
226                })?
227                .map_err(|e| Error::Platform {
228                    code: -1,
229                    message: format!("receive portal Response: {e}"),
230                })?;
231            if msg
232                .header()
233                .path()
234                .is_some_and(|path| path.as_str() == request_path.as_str())
235            {
236                break msg;
237            }
238        };
239        let (response, results): (u32, HashMap<String, OwnedValue>) =
240            msg.body().deserialize().map_err(|e| Error::Platform {
241                code: -1,
242                message: format!("portal Response deserialize: {e}"),
243            })?;
244        if response != 0 {
245            return Err(Error::PermissionDenied {
246                instructions: format!("xdg-desktop-portal Screenshot denied (response={response})"),
247            });
248        }
249        let uri_val = results.get("uri").ok_or_else(|| Error::Platform {
250            code: -1,
251            message: "portal Response missing 'uri' key".into(),
252        })?;
253        let uri: String = uri_val
254            .downcast_ref::<String>()
255            .map_err(|_| Error::Platform {
256                code: -1,
257                message: "portal Response 'uri' is not a string".into(),
258            })?;
259        let path = uri.strip_prefix("file://").ok_or_else(|| Error::Platform {
260            code: -1,
261            message: format!("portal URI not file://: {uri}"),
262        })?;
263        let bytes = std::fs::read(path).map_err(|e| Error::Platform {
264            code: e.raw_os_error().unwrap_or(-1) as i64,
265            message: format!("read portal PNG: {e}"),
266        })?;
267        // Best-effort cleanup of portal tmpfile.
268        let _ = std::fs::remove_file(path);
269
270        let shot = decode_png_to_rgba(&bytes)?;
271        match rect {
272            None => Ok(shot),
273            Some(r) => crop_rgba(shot, r),
274        }
275    }
276}
277
278/// Generate a valid, per-call portal request token. Portal request paths are
279/// public on the session bus, so use kernel entropy rather than a predictable
280/// process-local counter as recommended by the portal protocol.
281fn portal_handle_token() -> Result<String> {
282    let mut entropy = [0_u8; 16];
283    std::fs::File::open("/dev/urandom")
284        .and_then(|mut file| file.read_exact(&mut entropy))
285        .map_err(|e| Error::Platform {
286            code: e.raw_os_error().unwrap_or(-1) as i64,
287            message: format!("generate portal handle token: {e}"),
288        })?;
289
290    const HEX: &[u8; 16] = b"0123456789abcdef";
291    let mut token = String::with_capacity(6 + entropy.len() * 2);
292    token.push_str("xa11y_");
293    for byte in entropy {
294        token.push(HEX[(byte >> 4) as usize] as char);
295        token.push(HEX[(byte & 0x0f) as usize] as char);
296    }
297    Ok(token)
298}
299
300impl ScreenshotProvider for LinuxScreenshot {
301    fn capture_full(&self) -> Result<(Screenshot, Point)> {
302        // Captured pixels are physical; stamp the physical-to-logical ratio so
303        // callers can map logical bounds onto them. `capture_*` produce the
304        // raw pixels; the scale is metadata applied here. See `crate::scale`.
305        let scale = crate::scale::screenshot_scale();
306        let mut shot = match &self.backend {
307            Backend::X11(x) => self.capture_x11(&x.conn, x.root, x.root_width, x.root_height, None),
308            Backend::Wayland { conn } => self.capture_wayland(conn, None),
309        }?;
310        shot.scale = scale as f32;
311        let (width, height, shot_scale) = (shot.width, shot.height, shot.scale);
312        shot = shot.with_mapping(CaptureMapping::single(
313            Point::new(0, 0),
314            width,
315            height,
316            shot_scale,
317        ));
318        // Both paths start at the coordinate-space origin, so unlike Windows
319        // and macOS there is no offset to subtract: X11 reads the root window,
320        // whose top-left is (0, 0) by definition, and the portal hands back the
321        // whole screen. This is asserted rather than assumed — a backend that
322        // grew a per-output capture would have to report that output's origin
323        // here, per `ScreenshotProvider::capture_full`.
324        Ok((shot, Point::new(0, 0)))
325    }
326
327    fn capture_region(&self, rect: Rect) -> Result<Screenshot> {
328        // `rect` is logical (matching `Element::bounds`); convert to the
329        // physical pixels the X server / portal image work in.
330        let scale = crate::scale::screenshot_scale();
331        let phys = rect.to_physical(scale);
332        let mut shot = match &self.backend {
333            Backend::X11(x) => {
334                self.capture_x11(&x.conn, x.root, x.root_width, x.root_height, Some(phys))
335            }
336            Backend::Wayland { conn } => self.capture_wayland(conn, Some(phys)),
337        }?;
338        shot.scale = scale as f32;
339        let (width, height, shot_scale) = (shot.width, shot.height, shot.scale);
340        shot = shot.with_mapping(CaptureMapping::single(
341            Point::new(rect.x, rect.y),
342            width,
343            height,
344            shot_scale,
345        ));
346        Ok(shot)
347    }
348}
349
350fn decode_png_to_rgba(bytes: &[u8]) -> Result<Screenshot> {
351    let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
352    let mut reader = decoder.read_info().map_err(|e| Error::Platform {
353        code: -1,
354        message: format!("png decode header: {e}"),
355    })?;
356    let info = reader.info().clone();
357    let buf_size = reader.output_buffer_size().ok_or_else(|| Error::Platform {
358        code: -1,
359        message: "png decode: output buffer size overflowed usize".to_string(),
360    })?;
361    let mut buf = vec![0u8; buf_size];
362    let frame = reader.next_frame(&mut buf).map_err(|e| Error::Platform {
363        code: -1,
364        message: format!("png decode frame: {e}"),
365    })?;
366    buf.truncate(frame.buffer_size());
367
368    let rgba = match (info.color_type, info.bit_depth) {
369        (png::ColorType::Rgba, png::BitDepth::Eight) => buf,
370        (png::ColorType::Rgb, png::BitDepth::Eight) => {
371            let mut out = Vec::with_capacity((info.width * info.height * 4) as usize);
372            for px in buf.as_chunks::<3>().0 {
373                out.extend_from_slice(&[px[0], px[1], px[2], 0xFF]);
374            }
375            out
376        }
377        (ct, bd) => {
378            return Err(Error::Platform {
379                code: -1,
380                message: format!("unsupported portal PNG format: {ct:?} @ {bd:?}"),
381            });
382        }
383    };
384
385    Ok(Screenshot::new(info.width, info.height, rgba, 1.0))
386}
387
388fn crop_rgba(shot: Screenshot, rect: Rect) -> Result<Screenshot> {
389    let (sw, sh, pixels, scale) = (shot.width, shot.height, shot.pixels, shot.scale);
390    let x = rect.x.max(0) as u32;
391    let y = rect.y.max(0) as u32;
392    if x >= sw || y >= sh {
393        return Err(Error::Platform {
394            code: -1,
395            message: "crop rect outside captured image".into(),
396        });
397    }
398    let w = rect.width.min(sw - x);
399    let h = rect.height.min(sh - y);
400    if w == 0 || h == 0 {
401        return Err(Error::Platform {
402            code: -1,
403            message: "crop rect has zero size".into(),
404        });
405    }
406    let mut out = Vec::with_capacity((w * h * 4) as usize);
407    for row in 0..h {
408        let start = ((y + row) * sw + x) as usize * 4;
409        let end = start + (w as usize) * 4;
410        out.extend_from_slice(&pixels[start..end]);
411    }
412    Ok(Screenshot::new(w, h, out, scale))
413}