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::sync::Mutex;
19
20use x11rb::connection::Connection;
21use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat};
22use x11rb::rust_connection::RustConnection;
23use zbus::blocking::Connection as ZbusConnection;
24use zbus::blocking::Proxy;
25use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
26
27use xa11y_core::{Error, Rect, Result, Screenshot, ScreenshotProvider};
28
29/// Choose the Linux screenshot backend based on session environment.
30pub struct LinuxScreenshot {
31    backend: Backend,
32}
33
34// Box the X11 variant's fields — `RustConnection` is large enough that the
35// enum would otherwise be dominated by the X11 case and clippy flags it as
36// `large_enum_variant`. The backend sits behind an `Arc` in `Screenshotter`,
37// so the extra indirection costs at most one allocation per process.
38enum Backend {
39    X11(Box<X11Backend>),
40    Wayland { conn: ZbusConnection },
41}
42
43struct X11Backend {
44    conn: Mutex<RustConnection>,
45    root_width: u16,
46    root_height: u16,
47    root: u32,
48}
49
50impl LinuxScreenshot {
51    pub fn new() -> Result<Self> {
52        let display_set = std::env::var_os("DISPLAY").is_some();
53        let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
54
55        if display_set {
56            let (conn, screen_num) =
57                RustConnection::connect(None).map_err(|e| Error::Platform {
58                    code: -1,
59                    message: format!("X11 connect: {e}"),
60                })?;
61            let screen = conn
62                .setup()
63                .roots
64                .get(screen_num)
65                .ok_or_else(|| Error::Platform {
66                    code: -1,
67                    message: "X server reported no screens".into(),
68                })?;
69            let root = screen.root;
70            let root_width = screen.width_in_pixels;
71            let root_height = screen.height_in_pixels;
72            Ok(Self {
73                backend: Backend::X11(Box::new(X11Backend {
74                    conn: Mutex::new(conn),
75                    root,
76                    root_width,
77                    root_height,
78                })),
79            })
80        } else if wayland {
81            let conn = ZbusConnection::session().map_err(|e| Error::Platform {
82                code: -1,
83                message: format!("session bus connect: {e}"),
84            })?;
85            Ok(Self {
86                backend: Backend::Wayland { conn },
87            })
88        } else {
89            Err(Error::Unsupported {
90                feature: "screenshot (no DISPLAY or WAYLAND_DISPLAY set)".into(),
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.chunks_exact(4) {
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 {
165            width: w as u32,
166            height: h as u32,
167            pixels: rgba,
168            scale: 1.0,
169        })
170    }
171
172    fn capture_wayland(&self, conn: &ZbusConnection, rect: Option<Rect>) -> Result<Screenshot> {
173        let proxy = Proxy::new(
174            conn,
175            "org.freedesktop.portal.Desktop",
176            "/org/freedesktop/portal/desktop",
177            "org.freedesktop.portal.Screenshot",
178        )
179        .map_err(|e| Error::Platform {
180            code: -1,
181            message: format!("portal Screenshot proxy: {e}"),
182        })?;
183
184        let mut options: HashMap<&str, Value> = HashMap::new();
185        options.insert("interactive", Value::Bool(false));
186        options.insert("modal", Value::Bool(false));
187
188        let request_path: OwnedObjectPath =
189            proxy
190                .call("Screenshot", &("", options))
191                .map_err(|e| Error::Platform {
192                    code: -1,
193                    message: format!("portal Screenshot call: {e}"),
194                })?;
195
196        let request = Proxy::new(
197            conn,
198            "org.freedesktop.portal.Desktop",
199            &request_path,
200            "org.freedesktop.portal.Request",
201        )
202        .map_err(|e| Error::Platform {
203            code: -1,
204            message: format!("portal Request proxy: {e}"),
205        })?;
206
207        // Block for Response(response: u, results: a{sv}). First signal wins.
208        let mut signals = request
209            .receive_signal("Response")
210            .map_err(|e| Error::Platform {
211                code: -1,
212                message: format!("receive_signal: {e}"),
213            })?;
214        let msg = signals.next().ok_or_else(|| Error::Platform {
215            code: -1,
216            message: "portal Response signal channel closed".into(),
217        })?;
218        let (response, results): (u32, HashMap<String, OwnedValue>) =
219            msg.body().deserialize().map_err(|e| Error::Platform {
220                code: -1,
221                message: format!("portal Response deserialize: {e}"),
222            })?;
223        if response != 0 {
224            return Err(Error::PermissionDenied {
225                instructions: format!("xdg-desktop-portal Screenshot denied (response={response})"),
226            });
227        }
228        let uri_val = results.get("uri").ok_or_else(|| Error::Platform {
229            code: -1,
230            message: "portal Response missing 'uri' key".into(),
231        })?;
232        let uri: String = uri_val
233            .downcast_ref::<String>()
234            .map_err(|_| Error::Platform {
235                code: -1,
236                message: "portal Response 'uri' is not a string".into(),
237            })?;
238        let path = uri.strip_prefix("file://").ok_or_else(|| Error::Platform {
239            code: -1,
240            message: format!("portal URI not file://: {uri}"),
241        })?;
242        let bytes = std::fs::read(path).map_err(|e| Error::Platform {
243            code: e.raw_os_error().unwrap_or(-1) as i64,
244            message: format!("read portal PNG: {e}"),
245        })?;
246        // Best-effort cleanup of portal tmpfile.
247        let _ = std::fs::remove_file(path);
248
249        let shot = decode_png_to_rgba(&bytes)?;
250        match rect {
251            None => Ok(shot),
252            Some(r) => crop_rgba(shot, r),
253        }
254    }
255}
256
257impl ScreenshotProvider for LinuxScreenshot {
258    fn capture_full(&self) -> Result<Screenshot> {
259        // Captured pixels are physical; stamp the physical-to-logical ratio so
260        // callers can map logical bounds onto them. `capture_*` produce the
261        // raw pixels; the scale is metadata applied here. See `crate::scale`.
262        let scale = crate::scale::screenshot_scale();
263        let mut shot = match &self.backend {
264            Backend::X11(x) => self.capture_x11(&x.conn, x.root, x.root_width, x.root_height, None),
265            Backend::Wayland { conn } => self.capture_wayland(conn, None),
266        }?;
267        shot.scale = scale as f32;
268        Ok(shot)
269    }
270
271    fn capture_region(&self, rect: Rect) -> Result<Screenshot> {
272        // `rect` is logical (matching `Element::bounds`); convert to the
273        // physical pixels the X server / portal image work in.
274        let scale = crate::scale::screenshot_scale();
275        let phys = rect.to_physical(scale);
276        let mut shot = match &self.backend {
277            Backend::X11(x) => {
278                self.capture_x11(&x.conn, x.root, x.root_width, x.root_height, Some(phys))
279            }
280            Backend::Wayland { conn } => self.capture_wayland(conn, Some(phys)),
281        }?;
282        shot.scale = scale as f32;
283        Ok(shot)
284    }
285}
286
287fn decode_png_to_rgba(bytes: &[u8]) -> Result<Screenshot> {
288    let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
289    let mut reader = decoder.read_info().map_err(|e| Error::Platform {
290        code: -1,
291        message: format!("png decode header: {e}"),
292    })?;
293    let info = reader.info().clone();
294    let buf_size = reader.output_buffer_size().ok_or_else(|| Error::Platform {
295        code: -1,
296        message: "png decode: output buffer size overflowed usize".to_string(),
297    })?;
298    let mut buf = vec![0u8; buf_size];
299    let frame = reader.next_frame(&mut buf).map_err(|e| Error::Platform {
300        code: -1,
301        message: format!("png decode frame: {e}"),
302    })?;
303    buf.truncate(frame.buffer_size());
304
305    let rgba = match (info.color_type, info.bit_depth) {
306        (png::ColorType::Rgba, png::BitDepth::Eight) => buf,
307        (png::ColorType::Rgb, png::BitDepth::Eight) => {
308            let mut out = Vec::with_capacity((info.width * info.height * 4) as usize);
309            for px in buf.chunks_exact(3) {
310                out.extend_from_slice(&[px[0], px[1], px[2], 0xFF]);
311            }
312            out
313        }
314        (ct, bd) => {
315            return Err(Error::Platform {
316                code: -1,
317                message: format!("unsupported portal PNG format: {ct:?} @ {bd:?}"),
318            });
319        }
320    };
321
322    Ok(Screenshot {
323        width: info.width,
324        height: info.height,
325        pixels: rgba,
326        scale: 1.0,
327    })
328}
329
330fn crop_rgba(shot: Screenshot, rect: Rect) -> Result<Screenshot> {
331    let Screenshot {
332        width: sw,
333        height: sh,
334        pixels,
335        scale,
336    } = shot;
337    let x = rect.x.max(0) as u32;
338    let y = rect.y.max(0) as u32;
339    if x >= sw || y >= sh {
340        return Err(Error::Platform {
341            code: -1,
342            message: "crop rect outside captured image".into(),
343        });
344    }
345    let w = rect.width.min(sw - x);
346    let h = rect.height.min(sh - y);
347    if w == 0 || h == 0 {
348        return Err(Error::Platform {
349            code: -1,
350            message: "crop rect has zero size".into(),
351        });
352    }
353    let mut out = Vec::with_capacity((w * h * 4) as usize);
354    for row in 0..h {
355        let start = ((y + row) * sw + x) as usize * 4;
356        let end = start + (w as usize) * 4;
357        out.extend_from_slice(&pixels[start..end]);
358    }
359    Ok(Screenshot {
360        width: w,
361        height: h,
362        pixels: out,
363        scale,
364    })
365}