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//! Scale factor is reported as 1.0 on both paths: X11 has no canonical way to
11//! read the user's HiDPI scale from the wire protocol (it's in Xft.dpi / XRDB
12//! or the compositor), and the portal returns already-composited pixels.
13
14use std::collections::HashMap;
15use std::sync::Mutex;
16
17use x11rb::connection::Connection;
18use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat};
19use x11rb::rust_connection::RustConnection;
20use zbus::blocking::Connection as ZbusConnection;
21use zbus::blocking::Proxy;
22use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
23
24use xa11y_core::{Error, Rect, Result, Screenshot, ScreenshotProvider};
25
26/// Choose the Linux screenshot backend based on session environment.
27pub struct LinuxScreenshot {
28    backend: Backend,
29}
30
31// Box the X11 variant's fields — `RustConnection` is large enough that the
32// enum would otherwise be dominated by the X11 case and clippy flags it as
33// `large_enum_variant`. The backend sits behind an `Arc` in `Screenshotter`,
34// so the extra indirection costs at most one allocation per process.
35enum Backend {
36    X11(Box<X11Backend>),
37    Wayland { conn: ZbusConnection },
38}
39
40struct X11Backend {
41    conn: Mutex<RustConnection>,
42    root_width: u16,
43    root_height: u16,
44    root: u32,
45}
46
47impl LinuxScreenshot {
48    pub fn new() -> Result<Self> {
49        let display_set = std::env::var_os("DISPLAY").is_some();
50        let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
51
52        if display_set {
53            let (conn, screen_num) =
54                RustConnection::connect(None).map_err(|e| Error::Platform {
55                    code: -1,
56                    message: format!("X11 connect: {e}"),
57                })?;
58            let screen = conn
59                .setup()
60                .roots
61                .get(screen_num)
62                .ok_or_else(|| Error::Platform {
63                    code: -1,
64                    message: "X server reported no screens".into(),
65                })?;
66            let root = screen.root;
67            let root_width = screen.width_in_pixels;
68            let root_height = screen.height_in_pixels;
69            Ok(Self {
70                backend: Backend::X11(Box::new(X11Backend {
71                    conn: Mutex::new(conn),
72                    root,
73                    root_width,
74                    root_height,
75                })),
76            })
77        } else if wayland {
78            let conn = ZbusConnection::session().map_err(|e| Error::Platform {
79                code: -1,
80                message: format!("session bus connect: {e}"),
81            })?;
82            Ok(Self {
83                backend: Backend::Wayland { conn },
84            })
85        } else {
86            Err(Error::Unsupported {
87                feature: "screenshot (no DISPLAY or WAYLAND_DISPLAY set)".into(),
88            })
89        }
90    }
91
92    fn capture_x11(
93        &self,
94        conn: &Mutex<RustConnection>,
95        root: u32,
96        root_w: u16,
97        root_h: u16,
98        rect: Option<Rect>,
99    ) -> Result<Screenshot> {
100        let (x, y, w, h) = match rect {
101            None => (0_i16, 0_i16, root_w, root_h),
102            Some(r) => {
103                let x = i16::try_from(r.x).map_err(|_| Error::Platform {
104                    code: -1,
105                    message: "rect x out of i16 range".into(),
106                })?;
107                let y = i16::try_from(r.y).map_err(|_| Error::Platform {
108                    code: -1,
109                    message: "rect y out of i16 range".into(),
110                })?;
111                let w = u16::try_from(r.width).map_err(|_| Error::Platform {
112                    code: -1,
113                    message: "rect width out of u16 range".into(),
114                })?;
115                let h = u16::try_from(r.height).map_err(|_| Error::Platform {
116                    code: -1,
117                    message: "rect height out of u16 range".into(),
118                })?;
119                (x, y, w, h)
120            }
121        };
122        if w == 0 || h == 0 {
123            return Err(Error::Platform {
124                code: -1,
125                message: "zero-sized capture rect".into(),
126            });
127        }
128
129        let guard = conn.lock().unwrap_or_else(|e| e.into_inner());
130        let reply = guard
131            .get_image(ImageFormat::Z_PIXMAP, root, x, y, w, h, !0)
132            .map_err(|e| Error::Platform {
133                code: -1,
134                message: format!("GetImage: {e}"),
135            })?
136            .reply()
137            .map_err(|e| Error::Platform {
138                code: -1,
139                message: format!("GetImage reply: {e}"),
140            })?;
141
142        // Most modern X servers return Z_PIXMAP at 32 bpp for 24-bit visuals,
143        // with layout BGRX on little-endian. Detect and convert to RGBA.
144        let bpp = (reply.data.len() / (w as usize * h as usize)) as u32;
145        if bpp != 4 {
146            return Err(Error::Platform {
147                code: -1,
148                message: format!("unsupported X11 pixmap layout: {bpp} bytes/pixel (expected 4)"),
149            });
150        }
151
152        let mut rgba = Vec::with_capacity(reply.data.len());
153        for chunk in reply.data.chunks_exact(4) {
154            // X11 BGRX little-endian → RGBA
155            rgba.push(chunk[2]);
156            rgba.push(chunk[1]);
157            rgba.push(chunk[0]);
158            rgba.push(0xFF);
159        }
160
161        Ok(Screenshot {
162            width: w as u32,
163            height: h as u32,
164            pixels: rgba,
165            scale: 1.0,
166        })
167    }
168
169    fn capture_wayland(&self, conn: &ZbusConnection, rect: Option<Rect>) -> Result<Screenshot> {
170        let proxy = Proxy::new(
171            conn,
172            "org.freedesktop.portal.Desktop",
173            "/org/freedesktop/portal/desktop",
174            "org.freedesktop.portal.Screenshot",
175        )
176        .map_err(|e| Error::Platform {
177            code: -1,
178            message: format!("portal Screenshot proxy: {e}"),
179        })?;
180
181        let mut options: HashMap<&str, Value> = HashMap::new();
182        options.insert("interactive", Value::Bool(false));
183        options.insert("modal", Value::Bool(false));
184
185        let request_path: OwnedObjectPath =
186            proxy
187                .call("Screenshot", &("", options))
188                .map_err(|e| Error::Platform {
189                    code: -1,
190                    message: format!("portal Screenshot call: {e}"),
191                })?;
192
193        let request = Proxy::new(
194            conn,
195            "org.freedesktop.portal.Desktop",
196            &request_path,
197            "org.freedesktop.portal.Request",
198        )
199        .map_err(|e| Error::Platform {
200            code: -1,
201            message: format!("portal Request proxy: {e}"),
202        })?;
203
204        // Block for Response(response: u, results: a{sv}). First signal wins.
205        let mut signals = request
206            .receive_signal("Response")
207            .map_err(|e| Error::Platform {
208                code: -1,
209                message: format!("receive_signal: {e}"),
210            })?;
211        let msg = signals.next().ok_or_else(|| Error::Platform {
212            code: -1,
213            message: "portal Response signal channel closed".into(),
214        })?;
215        let (response, results): (u32, HashMap<String, OwnedValue>) =
216            msg.body().deserialize().map_err(|e| Error::Platform {
217                code: -1,
218                message: format!("portal Response deserialize: {e}"),
219            })?;
220        if response != 0 {
221            return Err(Error::PermissionDenied {
222                instructions: format!("xdg-desktop-portal Screenshot denied (response={response})"),
223            });
224        }
225        let uri_val = results.get("uri").ok_or_else(|| Error::Platform {
226            code: -1,
227            message: "portal Response missing 'uri' key".into(),
228        })?;
229        let uri: String = uri_val
230            .downcast_ref::<String>()
231            .map_err(|_| Error::Platform {
232                code: -1,
233                message: "portal Response 'uri' is not a string".into(),
234            })?;
235        let path = uri.strip_prefix("file://").ok_or_else(|| Error::Platform {
236            code: -1,
237            message: format!("portal URI not file://: {uri}"),
238        })?;
239        let bytes = std::fs::read(path).map_err(|e| Error::Platform {
240            code: e.raw_os_error().unwrap_or(-1) as i64,
241            message: format!("read portal PNG: {e}"),
242        })?;
243        // Best-effort cleanup of portal tmpfile.
244        let _ = std::fs::remove_file(path);
245
246        let shot = decode_png_to_rgba(&bytes)?;
247        match rect {
248            None => Ok(shot),
249            Some(r) => crop_rgba(shot, r),
250        }
251    }
252}
253
254impl ScreenshotProvider for LinuxScreenshot {
255    fn capture_full(&self) -> Result<Screenshot> {
256        match &self.backend {
257            Backend::X11(x) => self.capture_x11(&x.conn, x.root, x.root_width, x.root_height, None),
258            Backend::Wayland { conn } => self.capture_wayland(conn, None),
259        }
260    }
261
262    fn capture_region(&self, rect: Rect) -> Result<Screenshot> {
263        match &self.backend {
264            Backend::X11(x) => {
265                self.capture_x11(&x.conn, x.root, x.root_width, x.root_height, Some(rect))
266            }
267            Backend::Wayland { conn } => self.capture_wayland(conn, Some(rect)),
268        }
269    }
270}
271
272fn decode_png_to_rgba(bytes: &[u8]) -> Result<Screenshot> {
273    let decoder = png::Decoder::new(bytes);
274    let mut reader = decoder.read_info().map_err(|e| Error::Platform {
275        code: -1,
276        message: format!("png decode header: {e}"),
277    })?;
278    let info = reader.info().clone();
279    let mut buf = vec![0u8; reader.output_buffer_size()];
280    let frame = reader.next_frame(&mut buf).map_err(|e| Error::Platform {
281        code: -1,
282        message: format!("png decode frame: {e}"),
283    })?;
284    buf.truncate(frame.buffer_size());
285
286    let rgba = match (info.color_type, info.bit_depth) {
287        (png::ColorType::Rgba, png::BitDepth::Eight) => buf,
288        (png::ColorType::Rgb, png::BitDepth::Eight) => {
289            let mut out = Vec::with_capacity((info.width * info.height * 4) as usize);
290            for px in buf.chunks_exact(3) {
291                out.extend_from_slice(&[px[0], px[1], px[2], 0xFF]);
292            }
293            out
294        }
295        (ct, bd) => {
296            return Err(Error::Platform {
297                code: -1,
298                message: format!("unsupported portal PNG format: {ct:?} @ {bd:?}"),
299            });
300        }
301    };
302
303    Ok(Screenshot {
304        width: info.width,
305        height: info.height,
306        pixels: rgba,
307        scale: 1.0,
308    })
309}
310
311fn crop_rgba(shot: Screenshot, rect: Rect) -> Result<Screenshot> {
312    let Screenshot {
313        width: sw,
314        height: sh,
315        pixels,
316        scale,
317    } = shot;
318    let x = rect.x.max(0) as u32;
319    let y = rect.y.max(0) as u32;
320    if x >= sw || y >= sh {
321        return Err(Error::Platform {
322            code: -1,
323            message: "crop rect outside captured image".into(),
324        });
325    }
326    let w = rect.width.min(sw - x);
327    let h = rect.height.min(sh - y);
328    if w == 0 || h == 0 {
329        return Err(Error::Platform {
330            code: -1,
331            message: "crop rect has zero size".into(),
332        });
333    }
334    let mut out = Vec::with_capacity((w * h * 4) as usize);
335    for row in 0..h {
336        let start = ((y + row) * sw + x) as usize * 4;
337        let end = start + (w as usize) * 4;
338        out.extend_from_slice(&pixels[start..end]);
339    }
340    Ok(Screenshot {
341        width: w,
342        height: h,
343        pixels: out,
344        scale,
345    })
346}