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}