Skip to main content

zyris_screen/
lib.rs

1//! The reference implementation of the Zyris `screen_capture` capability.
2//!
3//! [`zyris_caps::ScreenCapture`] is the contract — `list_displays` and `screenshot`. This crate is
4//! one answer to it: [`HostScreenCapture`] captures the monitors of the machine the node runs on,
5//! through [`xcap`] or, in a wlroots Wayland session, `libwayshot`. [`HostDisplays`] offers the
6//! same enumeration without the capture, for a capability that needs the monitor layout and does
7//! not take pictures — `input`, in `zyris-input`, is the one that does.
8//!
9//! ```no_run
10//! use zyris::{Node, NodeKind};
11//! use zyris_caps::ScreenCaptureServer;
12//! use zyris_screen::HostScreenCapture;
13//!
14//! let node = Node::builder()
15//!     .name("workstation")
16//!     .kind(NodeKind::Service)
17//!     .capability(ScreenCaptureServer(HostScreenCapture::default()))
18//!     .build()
19//!     .unwrap();
20//! ```
21//!
22//! # Screen capture on Wayland
23//!
24//! A Wayland compositor does not let a client read the screen, so there are two ways in and
25//! [`ScreenBackend::detect`] picks between them at construction.
26//!
27//! On a wlroots-based compositor — Hyprland, Sway, river, Wayfire — `ScreenBackend::Wayland`
28//! talks `wl_output` and `zwlr_screencopy` directly, the same pair `grim` uses. This is not the
29//! same as letting `xcap` do it: `xcap` enumerates monitors over XRandR even in a Wayland session
30//! and then captures in Wayland's coordinate space, and the two only agree while Xwayland can
31//! express the compositor's layout. Put a monitor above the origin and its `y` goes negative,
32//! which X11 cannot represent, so Xwayland flattens the arrangement into a row and every captured
33//! rectangle lands on the wrong output.
34//!
35//! Everywhere else — GNOME, KDE, and any compositor without `zwlr_screencopy` — the probe fails
36//! and [`ScreenBackend::Xcap`] takes over, reaching the screen through
37//! `org.freedesktop.portal.Screenshot`. That interface has to actually be offered: a session whose
38//! portal backend does not register it has no way to capture at all.
39//!
40//! # Building this crate on Linux
41//!
42//! `xcap` links against the display stack, so a Linux build needs development packages a headless
43//! build of the rest of Zyris does not:
44//!
45//! ```text
46//! # Debian/Ubuntu
47//! apt-get install pkg-config libclang-dev libxcb1-dev libxrandr-dev \
48//!     libdbus-1-dev libpipewire-0.3-dev libwayland-dev libegl-dev
49//! ```
50//!
51//! Windows and macOS need nothing beyond a Rust toolchain, which is also why the docs.rs build is
52//! pinned to a Windows target: the doc builder runs Linux and has none of the packages above.
53
54mod xcap_backend;
55
56#[cfg(target_os = "linux")]
57mod wayland_backend;
58
59use image::codecs::jpeg::JpegEncoder;
60use image::codecs::png::PngEncoder;
61use image::{ExtendedColorType, ImageEncoder, RgbaImage};
62use zyris::{Blob, Datum, ErrorCode, WireError};
63use zyris_caps::{Display, Displays, ImageFormat, Region, ScreenCapture};
64
65/// Wholesale, and deliberately: a caller that holds a `Monitor` or an `RgbaImage` from this crate
66/// has to be able to name the crate it came from, and pinning `xcap` a second time downstream is
67/// how two incompatible copies end up linked in.
68///
69/// This re-export shadows the extern-prelude `image` for the rest of *this* module, so the `use
70/// image::…` lines above resolve through `xcap::image` while the backend modules resolve to the
71/// direct dependency. That is only harmless because the two are the same crate — one `image` in
72/// `Cargo.lock`, because `xcap` and this crate both want 0.25. If they ever diverge the symptom is
73/// a type mismatch between this file and `xcap_backend.rs` over `RgbaImage`, and the fix is to name
74/// `::image` explicitly here rather than to drop the re-export.
75pub use xcap::{self, image};
76
77/// Which platform API the capture goes through.
78///
79/// There is a real choice to make on Linux, and it is not the obvious one. `xcap` enumerates
80/// monitors over XRandR even in a Wayland session, then captures over `zwlr_screencopy` in
81/// Wayland's coordinate space. Those two spaces agree only when Xwayland can express the
82/// compositor's layout — a monitor above the origin gives it a negative `y`, which X11 has no way
83/// to represent, so Xwayland flattens the whole arrangement into a row and every captured
84/// rectangle lands on the wrong output. `ScreenBackend::Wayland` talks to `wl_output` directly
85/// and never involves X11.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ScreenBackend {
88    /// `xcap`: XRandR on X11, Quartz on macOS, GDI on Windows.
89    Xcap,
90    /// `libwayshot`: `wl_output` plus `zwlr_screencopy`, one whole output at a time.
91    ///
92    /// Linux only, which is why the prose above and in [`ScreenBackend::detect`] names this
93    /// variant in a code span rather than linking it: the documentation is built for Windows,
94    /// where there is nothing here to link to.
95    #[cfg(target_os = "linux")]
96    Wayland,
97}
98
99impl ScreenBackend {
100    /// Pick a backend for this session.
101    ///
102    /// In a Wayland session this probes for a usable `zwlr_screencopy` connection and only then
103    /// commits to `ScreenBackend::Wayland`. A compositor without that protocol — GNOME, KDE —
104    /// falls back to `xcap`, which reaches those through their screenshot portal.
105    pub fn detect() -> ScreenBackend {
106        #[cfg(target_os = "linux")]
107        if std::env::var_os("WAYLAND_DISPLAY").is_some() && wayland_backend::is_available() {
108            return ScreenBackend::Wayland;
109        }
110        ScreenBackend::Xcap
111    }
112}
113
114/// [`ScreenCapture`] for the machine the node runs on.
115///
116/// ```no_run
117/// # use zyris_screen::HostScreenCapture;
118/// # use zyris_caps::{ImageFormat, ScreenCaptureServer};
119/// let screen = HostScreenCapture::default()
120///     .with_format(ImageFormat::Jpeg)
121///     .with_max_width(1280)
122///     .with_jpeg_quality(70);
123/// let server = ScreenCaptureServer(screen);
124/// ```
125///
126/// The defaults set a ceiling a caller cannot exceed by forgetting to pass one; per-call `format`
127/// and `max_width` still win where they are given.
128pub struct HostScreenCapture {
129    backend: ScreenBackend,
130    default_format: ImageFormat,
131    default_max_width: Option<u32>,
132    jpeg_quality: u8,
133    budget: Option<usize>,
134}
135
136impl Default for HostScreenCapture {
137    fn default() -> Self {
138        HostScreenCapture {
139            backend: ScreenBackend::detect(),
140            default_format: ImageFormat::Png,
141            default_max_width: None,
142            jpeg_quality: 80,
143            budget: Some(zyris::proto::INLINE_BLOB_MAX),
144        }
145    }
146}
147
148impl HostScreenCapture {
149    /// Override the backend [`ScreenBackend::detect`] chose.
150    pub fn with_backend(mut self, backend: ScreenBackend) -> Self {
151        self.backend = backend;
152        self
153    }
154
155    pub fn backend(&self) -> ScreenBackend {
156        self.backend
157    }
158
159    pub fn with_format(mut self, format: ImageFormat) -> Self {
160        self.default_format = format;
161        self
162    }
163
164    pub fn with_max_width(mut self, max_width: u32) -> Self {
165        self.default_max_width = Some(max_width);
166        self
167    }
168
169    pub fn with_jpeg_quality(mut self, quality: u8) -> Self {
170        self.jpeg_quality = quality.clamp(1, 100);
171        self
172    }
173
174    /// How many encoded bytes a screenshot may come to before it is shrunk to fit.
175    ///
176    /// Defaults to [`zyris::proto::INLINE_BLOB_MAX`]. This is a floor on usefulness, not a
177    /// preference: a caller that names a `max_width` gets it applied first, and the fit pass only
178    /// engages if the result would still be too large to survive the trip.
179    pub fn with_budget(mut self, bytes: usize) -> Self {
180        self.budget = Some(bytes);
181        self
182    }
183
184    /// Send whatever the encoder produced, however large. For a node on a local socket whose
185    /// caller would rather have every pixel than a reply that definitely arrives.
186    pub fn without_budget(mut self) -> Self {
187        self.budget = None;
188        self
189    }
190}
191
192pub(crate) fn internal(err: impl std::fmt::Display) -> WireError {
193    WireError::new(ErrorCode::Internal, err.to_string())
194}
195
196/// Both backends are blocking and hold platform connections that are not worth keeping across an
197/// await, so every call opens, captures, and encodes inside one closure and hands back bytes.
198async fn blocking<T, F>(f: F) -> zyris::Result<T>
199where
200    F: FnOnce() -> zyris::Result<T> + Send + 'static,
201    T: Send + 'static,
202{
203    match tokio::task::spawn_blocking(f).await {
204        Ok(result) => result,
205        Err(join) => Err(internal(format!("screen capture task failed: {join}"))),
206    }
207}
208
209fn displays(backend: ScreenBackend) -> zyris::Result<Vec<Display>> {
210    match backend {
211        ScreenBackend::Xcap => xcap_backend::displays(),
212        #[cfg(target_os = "linux")]
213        ScreenBackend::Wayland => wayland_backend::displays(),
214    }
215}
216
217/// The monitor layout of this machine, for a capability that needs it but does not capture.
218///
219/// Not compiled: `EnigoInput` lives in `zyris-input`, which this crate deliberately does not
220/// depend on — the whole point of `Displays` sitting in `zyris-caps` is that neither
221/// implementation has to.
222///
223/// ```ignore
224/// # use zyris_input::EnigoInput;
225/// # use zyris_screen::HostDisplays;
226/// # fn main() -> zyris::Result<()> {
227/// let input = EnigoInput::new(HostDisplays::default())?;
228/// # Ok(())
229/// # }
230/// ```
231///
232/// Use the same backend the node's [`HostScreenCapture`] uses. The two enumerate through different
233/// APIs in a Wayland session and, per [`ScreenBackend`], do not always agree on where a monitor is.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct HostDisplays(pub ScreenBackend);
236
237impl Default for HostDisplays {
238    fn default() -> Self {
239        HostDisplays(ScreenBackend::detect())
240    }
241}
242
243impl Displays for HostDisplays {
244    fn displays(&self) -> zyris::Result<Vec<Display>> {
245        displays(self.0)
246    }
247}
248
249/// Capture a whole display and return it alongside the id it was resolved to.
250fn capture(backend: ScreenBackend, display: Option<&str>) -> zyris::Result<(String, RgbaImage)> {
251    match backend {
252        ScreenBackend::Xcap => xcap_backend::capture(display),
253        #[cfg(target_os = "linux")]
254        ScreenBackend::Wayland => wayland_backend::capture(display),
255    }
256}
257
258/// Crop here rather than asking the platform for a sub-rectangle.
259///
260/// `xcap::Monitor::capture_region` adds the monitor's origin to the coordinates on X11 but passes
261/// them through as virtual-desktop coordinates on Wayland, so the same `Region` would name two
262/// different rectangles. Cropping the full capture costs a copy and means one rectangle
263/// everywhere.
264fn crop(full: RgbaImage, region: Option<Region>) -> zyris::Result<RgbaImage> {
265    let Some(region) = region else {
266        return Ok(full);
267    };
268    if region.x < 0 || region.y < 0 {
269        return Err(WireError::invalid_params(
270            "region x and y are display-local and cannot be negative",
271        ));
272    }
273    let (x, y) = (region.x as u32, region.y as u32);
274    if region.width == 0
275        || region.height == 0
276        || x.saturating_add(region.width) > full.width()
277        || y.saturating_add(region.height) > full.height()
278    {
279        return Err(WireError::invalid_params(format!(
280            "region {}x{} at ({x}, {y}) does not fit the {}x{} display",
281            region.width,
282            region.height,
283            full.width(),
284            full.height()
285        )));
286    }
287    Ok(image::imageops::crop_imm(&full, x, y, region.width, region.height).to_image())
288}
289
290fn downscale(image: RgbaImage, max_width: Option<u32>) -> RgbaImage {
291    let Some(max_width) = max_width.filter(|w| *w > 0) else {
292        return image;
293    };
294    if image.width() <= max_width {
295        return image;
296    }
297    let height = (u64::from(image.height()) * u64::from(max_width) / u64::from(image.width()))
298        .max(1) as u32;
299    image::imageops::resize(&image, max_width, height, image::imageops::FilterType::Triangle)
300}
301
302fn encode(image: &RgbaImage, format: ImageFormat, jpeg_quality: u8) -> zyris::Result<Vec<u8>> {
303    let mut out = Vec::new();
304    match format {
305        ImageFormat::Png => PngEncoder::new(&mut out)
306            .write_image(
307                image.as_raw(),
308                image.width(),
309                image.height(),
310                ExtendedColorType::Rgba8,
311            )
312            .map_err(internal)?,
313        // JPEG has no alpha channel, and a screenshot's is opaque anyway. Dropping it by hand
314        // rather than through `DynamicImage` matters here: the auto-fit pass re-encodes the same
315        // image several times, and this allocates three quarters of a frame instead of copying a
316        // whole one first.
317        ImageFormat::Jpeg => {
318            let mut rgb = Vec::with_capacity(image.len() / 4 * 3);
319            for pixel in image.pixels() {
320                rgb.extend_from_slice(&pixel.0[..3]);
321            }
322            JpegEncoder::new_with_quality(&mut out, jpeg_quality)
323                .write_image(
324                    &rgb,
325                    image.width(),
326                    image.height(),
327                    ExtendedColorType::Rgb8,
328                )
329                .map_err(internal)?
330        }
331    }
332    Ok(out)
333}
334
335/// Never shrink below this. A thumbnail nobody can read is not a better answer than an oversized
336/// screenshot, and it guarantees the loop ends.
337const MIN_AUTO_WIDTH: u32 = 480;
338
339/// Passes after the first. Two is almost always enough — the square-root estimate below lands
340/// close — and the cap bounds the worst case for content that will not compress at any size.
341const FIT_PASSES: usize = 3;
342
343/// Encode, and if the result overruns `budget`, shrink and encode again until it fits.
344///
345/// Returns the bytes alongside the dimensions they were produced at, because the caller has to
346/// tell whoever asked: an agent that screenshots and then clicks needs to know the image it is
347/// looking at is not 1:1 with the display.
348fn fit(
349    mut image: RgbaImage,
350    format: ImageFormat,
351    jpeg_quality: u8,
352    budget: Option<usize>,
353) -> zyris::Result<(Vec<u8>, u32, u32)> {
354    let mut bytes = encode(&image, format, jpeg_quality)?;
355    let Some(budget) = budget else {
356        return Ok((bytes, image.width(), image.height()));
357    };
358
359    for _ in 0..FIT_PASSES {
360        if bytes.len() <= budget || image.width() <= MIN_AUTO_WIDTH {
361            break;
362        }
363        // Encoded size tracks pixel count, so the linear scale to aim for is the square root of
364        // the overshoot. The 0.95 is slack: landing just over the budget costs a whole extra pass,
365        // landing just under costs a few pixels.
366        let ratio = (budget as f64 / bytes.len() as f64).sqrt() * 0.95;
367        let target = (f64::from(image.width()) * ratio) as u32;
368        let target = target.clamp(MIN_AUTO_WIDTH, image.width() - 1);
369        image = downscale(image, Some(target));
370        bytes = encode(&image, format, jpeg_quality)?;
371    }
372
373    Ok((bytes, image.width(), image.height()))
374}
375
376/// What the caller is actually looking at, in terms of the display it came from.
377///
378/// This rides in `Datum::Image::description`, which for a model is the difference between a
379/// screenshot it can act on and a picture it can only describe — after an auto-fit pass, image
380/// coordinates are no longer display coordinates, and nothing else in the response says so.
381fn describe_capture(
382    id: &str,
383    display_size: (u32, u32),
384    region: Option<Region>,
385    sent: (u32, u32),
386) -> String {
387    let (dw, dh) = display_size;
388    let (sw, sh) = sent;
389    let mut text = format!("display {id}, {dw}x{dh}");
390
391    let (origin_x, origin_y, captured_w, captured_h) = match region {
392        Some(r) => (r.x, r.y, r.width, r.height),
393        None => (0, 0, dw, dh),
394    };
395    if region.is_some() {
396        text.push_str(&format!(
397            "; region {captured_w}x{captured_h} at ({origin_x}, {origin_y})"
398        ));
399    }
400
401    if (sw, sh) != (captured_w, captured_h) {
402        let scale = f64::from(captured_w) / f64::from(sw.max(1));
403        text.push_str(&format!(
404            "; scaled down to {sw}x{sh} — multiply image coordinates by {scale:.3}"
405        ));
406    }
407    if origin_x != 0 || origin_y != 0 {
408        text.push_str(&format!(", then add ({origin_x}, {origin_y})"));
409    }
410    if (sw, sh) != (captured_w, captured_h) || origin_x != 0 || origin_y != 0 {
411        text.push_str(" to get display coordinates");
412    }
413    text
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    /// Incompressible by construction: a gradient would PNG down to nothing and prove nothing
421    /// about the fit loop. The generator is a plain LCG so the test is deterministic.
422    fn noise(width: u32, height: u32) -> RgbaImage {
423        let mut seed = 0x2545_F491_4F6C_DD1Du64;
424        RgbaImage::from_fn(width, height, |_, _| {
425            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
426            let b = (seed >> 33) as u32;
427            image::Rgba([b as u8, (b >> 8) as u8, (b >> 16) as u8, 255])
428        })
429    }
430
431    #[test]
432    fn a_small_image_is_left_alone() {
433        let image = noise(64, 48);
434        let (bytes, w, h) = fit(image, ImageFormat::Png, 80, Some(64 * 1024)).unwrap();
435        assert_eq!((w, h), (64, 48));
436        assert!(bytes.len() <= 64 * 1024);
437    }
438
439    #[test]
440    fn an_oversized_image_is_shrunk_until_it_fits() {
441        // Noise is the worst case a real screen can present — a 1600x1200 frame of it PNGs to
442        // several megabytes — so the budget has to be one that is actually reachable above
443        // `MIN_AUTO_WIDTH`. Anything below that is the floor test's job.
444        let budget = 1024 * 1024;
445        let (bytes, w, h) = fit(noise(1600, 1200), ImageFormat::Png, 80, Some(budget)).unwrap();
446        assert!(bytes.len() <= budget, "{} bytes over {budget}", bytes.len());
447        assert!(
448            (MIN_AUTO_WIDTH..1600).contains(&w),
449            "expected a downscale that converged, got {w}x{h}"
450        );
451        // Aspect ratio survives.
452        assert_eq!(h, (u64::from(w) * 1200 / 1600) as u32);
453    }
454
455    #[test]
456    fn jpeg_fits_where_png_would_bottom_out() {
457        let budget = 128 * 1024;
458        let (bytes, w, _) = fit(noise(1600, 1200), ImageFormat::Jpeg, 80, Some(budget)).unwrap();
459        assert!(bytes.len() <= budget, "{} bytes over {budget}", bytes.len());
460        assert!(w >= MIN_AUTO_WIDTH);
461    }
462
463    #[test]
464    fn a_4k_capture_fits_the_default_budget_at_full_resolution() {
465        // A mostly-flat desktop screenshot: compressible, unlike the noise fixtures above, and the
466        // case a real 4K display presents. If this stops fitting, the budget was lowered or the
467        // encoder changed — either way a full-resolution 4K capture no longer rides inline.
468        let mut image = RgbaImage::from_pixel(3840, 2160, image::Rgba([245, 245, 245, 255]));
469        for (x, y, pixel) in image.enumerate_pixels_mut() {
470            if (x / 16 + y / 16) % 2 == 0 {
471                *pixel = image::Rgba([200 + (x % 56) as u8, 200, 200, 255]);
472            }
473        }
474        let budget = zyris::proto::INLINE_BLOB_MAX;
475        let (bytes, w, h) = fit(image, ImageFormat::Png, 80, Some(budget)).unwrap();
476        assert_eq!((w, h), (3840, 2160));
477        assert!(bytes.len() <= budget, "{} bytes over {budget}", bytes.len());
478    }
479
480    #[test]
481    fn shrinking_stops_at_the_minimum_width() {
482        // No budget this small is reachable, so the loop must give up rather than spin or
483        // produce a one-pixel image.
484        let (bytes, w, _) = fit(noise(1600, 1200), ImageFormat::Png, 80, Some(1)).unwrap();
485        assert_eq!(w, MIN_AUTO_WIDTH);
486        assert!(bytes.len() > 1);
487    }
488
489    #[test]
490    fn no_budget_means_no_shrinking() {
491        let (_, w, h) = fit(noise(1600, 1200), ImageFormat::Png, 80, None).unwrap();
492        assert_eq!((w, h), (1600, 1200));
493    }
494
495    #[test]
496    fn a_full_unscaled_capture_describes_only_the_display() {
497        assert_eq!(
498            describe_capture("DP-1", (1920, 1080), None, (1920, 1080)),
499            "display DP-1, 1920x1080"
500        );
501    }
502
503    #[test]
504    fn a_scaled_capture_carries_the_coordinate_multiplier() {
505        assert_eq!(
506            describe_capture("DP-1", (1920, 1080), None, (960, 540)),
507            "display DP-1, 1920x1080; scaled down to 960x540 \
508             — multiply image coordinates by 2.000 to get display coordinates"
509        );
510    }
511
512    #[test]
513    fn a_scaled_region_carries_the_offset_too() {
514        let region = Region { x: 100, y: 200, width: 800, height: 600 };
515        assert_eq!(
516            describe_capture("DP-1", (1920, 1080), Some(region), (400, 300)),
517            "display DP-1, 1920x1080; region 800x600 at (100, 200); scaled down to 400x300 \
518             — multiply image coordinates by 2.000, then add (100, 200) to get display coordinates"
519        );
520    }
521}
522
523#[zyris::async_trait]
524impl ScreenCapture for HostScreenCapture {
525    async fn list_displays(&self) -> zyris::Result<Vec<Display>> {
526        let backend = self.backend;
527        blocking(move || displays(backend)).await
528    }
529
530    async fn screenshot(
531        &self,
532        display: Option<String>,
533        region: Option<Region>,
534        format: Option<ImageFormat>,
535        max_width: Option<u32>,
536    ) -> zyris::Result<Datum> {
537        let backend = self.backend;
538        let format = format.unwrap_or(self.default_format);
539        let max_width = max_width.or(self.default_max_width);
540        let jpeg_quality = self.jpeg_quality;
541        let budget = self.budget;
542
543        blocking(move || {
544            let (id, full) = capture(backend, display.as_deref())?;
545            let display_size = full.dimensions();
546
547            let image = downscale(crop(full, region)?, max_width);
548            let (bytes, width, height) = fit(image, format, jpeg_quality, budget)?;
549
550            if budget.is_some_and(|budget| bytes.len() > budget) {
551                // Only reachable at the floor: content that will not compress even at
552                // `MIN_AUTO_WIDTH`. Shrinking further would send something unreadable, so send
553                // the oversized one and say why.
554                tracing::warn!(
555                    display = %id,
556                    bytes = bytes.len(),
557                    budget,
558                    width,
559                    "screenshot is over budget at the minimum width; try format=jpeg"
560                );
561            }
562
563            Ok(Datum::Image {
564                name: format!("display-{id}.{}", format.extension()),
565                description: Some(describe_capture(&id, display_size, region, (width, height))),
566                media_type: format.media_type().to_string(),
567                blob: Blob::from_bytes(bytes),
568            })
569        })
570        .await
571    }
572}