1use crate::diagnostics::hydrate_session_bus_env;
2use crate::x11_display::{is_native_x11_session, with_x11_display, X11_CAPTURE_TIMEOUT};
3use anyhow::{anyhow, bail, Context, Result};
4use base64::{engine::general_purpose::STANDARD, Engine};
5use futures_util::StreamExt;
6use image::codecs::jpeg::JpegEncoder;
7use image::imageops::FilterType;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::{
11 collections::HashMap,
12 fs::{self, OpenOptions},
13 io::{Cursor, Read},
14 os::unix::fs::OpenOptionsExt,
15 path::{Path, PathBuf},
16 process::Stdio,
17 time::{Duration, SystemTime, UNIX_EPOCH},
18};
19use tokio::process::Command;
20use zbus::{
21 message::{Message, Type as MessageType},
22 names::{BusName, OwnedUniqueName, WellKnownName},
23 zvariant::{OwnedObjectPath, OwnedValue, Value},
24 MatchRule, MessageStream, Proxy,
25};
26
27const PORTAL_DESKTOP_SERVICE: &str = "org.freedesktop.portal.Desktop";
28const PORTAL_DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop";
29const PORTAL_SCREENSHOT_INTERFACE: &str = "org.freedesktop.portal.Screenshot";
30const PORTAL_REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request";
31const PORTAL_REQUEST_PATH_NAMESPACE: &str = "/org/freedesktop/portal/desktop/request";
32const MAX_SCREENSHOT_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
33const MAX_SCREENSHOT_SOURCE_PIXELS: u64 = 128 * 1024 * 1024;
34
35pub const DEFAULT_SCREENSHOT_MAX_DIMENSION: u32 = 1920;
36pub const DEFAULT_SCREENSHOT_MAX_BYTES: usize = 2 * 1024 * 1024;
37pub const ABSOLUTE_SCREENSHOT_MAX_DIMENSION: u32 = 4096;
38pub const ABSOLUTE_SCREENSHOT_MAX_BYTES: usize = 4 * 1024 * 1024;
39pub const DEFAULT_SCREENSHOT_JPEG_QUALITY: u8 = 80;
40pub const MIN_SCREENSHOT_JPEG_QUALITY: u8 = 1;
41pub const MAX_SCREENSHOT_JPEG_QUALITY: u8 = 95;
42const MIN_SCREENSHOT_MAX_BYTES: usize = 1024;
43
44#[derive(Debug, Clone)]
45pub struct RawScreenshotCapture {
46 pub mime_type: String,
47 pub bytes: Vec<u8>,
48 pub source: String,
49 pub width: u32,
50 pub height: u32,
51}
52
53#[derive(Debug, Clone, Serialize, JsonSchema)]
54pub struct ScreenshotCapture {
55 pub mime_type: String,
56 pub data_url: String,
57 pub source: String,
58 pub width: u32,
60 pub height: u32,
62 pub coordinate_width: u32,
64 pub coordinate_height: u32,
66 pub scale: f32,
68 pub resized: bool,
69 pub bytes: usize,
70 pub original_bytes: usize,
71 pub max_bytes: usize,
72 pub format: ScreenshotOutputFormat,
73 pub quality: Option<u8>,
74}
75
76#[derive(Debug, Clone, Copy, Default)]
77pub struct ScreenshotPayloadOptions {
78 pub max_width: Option<u32>,
79 pub max_height: Option<u32>,
80 pub max_bytes: Option<usize>,
81 pub scale: Option<f32>,
82 pub format: Option<ScreenshotOutputFormat>,
83 pub quality: Option<u8>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
87#[serde(rename_all = "lowercase")]
88pub enum ScreenshotOutputFormat {
89 Png,
90 Jpeg,
91}
92
93impl ScreenshotOutputFormat {
94 fn mime_type(self) -> &'static str {
95 match self {
96 Self::Png => "image/png",
97 Self::Jpeg => "image/jpeg",
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy)]
103struct ResolvedScreenshotPayloadOptions {
104 max_width: u32,
105 max_height: u32,
106 max_bytes: usize,
107 scale: f32,
108 format: ScreenshotOutputFormat,
109 quality: u8,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113enum ScreenshotCleanup {
114 DeletePath(PathBuf),
115 Preserve,
116}
117
118struct OwnedScreenshotCleanup(ScreenshotCleanup);
119
120impl Drop for OwnedScreenshotCleanup {
121 fn drop(&mut self) {
122 if let ScreenshotCleanup::DeletePath(path) = &self.0 {
123 let _ = fs::remove_file(path);
124 }
125 }
126}
127
128impl ScreenshotPayloadOptions {
129 fn resolve(self) -> ResolvedScreenshotPayloadOptions {
130 let max_width = self
131 .max_width
132 .unwrap_or(DEFAULT_SCREENSHOT_MAX_DIMENSION)
133 .clamp(1, ABSOLUTE_SCREENSHOT_MAX_DIMENSION);
134 let max_height = self
135 .max_height
136 .unwrap_or(DEFAULT_SCREENSHOT_MAX_DIMENSION)
137 .clamp(1, ABSOLUTE_SCREENSHOT_MAX_DIMENSION);
138 let max_bytes = self
139 .max_bytes
140 .unwrap_or(DEFAULT_SCREENSHOT_MAX_BYTES)
141 .clamp(MIN_SCREENSHOT_MAX_BYTES, ABSOLUTE_SCREENSHOT_MAX_BYTES);
142 let scale = self
143 .scale
144 .filter(|value| value.is_finite() && *value > 0.0)
145 .unwrap_or(1.0)
146 .min(1.0);
147 let format = self.format.unwrap_or(ScreenshotOutputFormat::Png);
148 let quality = self
149 .quality
150 .unwrap_or(DEFAULT_SCREENSHOT_JPEG_QUALITY)
151 .clamp(MIN_SCREENSHOT_JPEG_QUALITY, MAX_SCREENSHOT_JPEG_QUALITY);
152
153 ResolvedScreenshotPayloadOptions {
154 max_width,
155 max_height,
156 max_bytes,
157 scale,
158 format,
159 quality,
160 }
161 }
162}
163
164const SCREENSHOT_BACKEND_ENV: &str = "COMPUTER_USE_LINUX_SCREENSHOT_BACKEND";
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169enum ScreenshotBackend {
170 GnomeShell,
171 Portal,
172 X11,
173 GnomeScreenshot,
174}
175
176impl ScreenshotBackend {
177 fn parse(value: &str) -> Option<Self> {
178 match value.trim().to_ascii_lowercase().as_str() {
179 "gnome-shell" | "gnome_shell" | "shell" => Some(Self::GnomeShell),
180 "portal" | "xdg-portal" | "xdg_portal" => Some(Self::Portal),
181 "x11" | "x11-native" | "x11_native" | "xgetimage" => Some(Self::X11),
182 "gnome-screenshot" | "gnome_screenshot" => Some(Self::GnomeScreenshot),
183 _ => None,
184 }
185 }
186
187 async fn capture(self) -> Result<RawScreenshotCapture> {
188 match self {
189 Self::GnomeShell => capture_with_gnome_shell().await,
190 Self::Portal => capture_with_portal().await,
191 Self::X11 => capture_with_x11().await,
192 Self::GnomeScreenshot => capture_with_gnome_screenshot().await,
193 }
194 }
195}
196
197pub async fn capture_screenshot_raw() -> Result<RawScreenshotCapture> {
198 hydrate_session_bus_env();
199
200 if let Some(forced) = forced_backend()? {
204 return forced.capture().await;
205 }
206
207 let gnome_error = match capture_with_gnome_shell().await {
214 Ok(capture) => return Ok(capture),
215 Err(error) => error,
216 };
217 let portal_error = match capture_with_portal().await {
218 Ok(capture) => return Ok(capture),
219 Err(error) => error,
220 };
221 let x11_error = match capture_with_x11().await {
225 Ok(capture) => return Ok(capture),
226 Err(error) => error,
227 };
228 let cli_error = match capture_with_gnome_screenshot().await {
229 Ok(capture) => return Ok(capture),
230 Err(error) => error,
231 };
232
233 Err(anyhow!(
234 "GNOME Shell screenshot failed: {gnome_error:#}; \
235 XDG portal screenshot failed: {portal_error:#}; \
236 native X11 screenshot failed: {x11_error:#}; \
237 gnome-screenshot fallback failed: {cli_error:#}"
238 ))
239}
240
241async fn capture_with_x11() -> Result<RawScreenshotCapture> {
244 if !is_native_x11_session() {
245 bail!("not a native X11 session (needs DISPLAY on an X11, not Wayland, session)");
246 }
247 let image = with_x11_display(X11_CAPTURE_TIMEOUT, |display| display.capture_root()).await??;
248 let (width, height) = (image.width, image.height);
249 let bytes =
250 tokio::task::spawn_blocking(move || encode_rgb_png(image.width, image.height, image.rgb))
251 .await
252 .context("X11 screenshot encoder task failed")??;
253 Ok(RawScreenshotCapture {
254 mime_type: "image/png".to_string(),
255 bytes,
256 source: "x11".to_string(),
257 width,
258 height,
259 })
260}
261
262fn encode_rgb_png(width: u32, height: u32, rgb: Vec<u8>) -> Result<Vec<u8>> {
263 let buffer = image::RgbImage::from_raw(width, height, rgb)
264 .context("X11 root image did not match its dimensions")?;
265 let mut out = Vec::new();
266 image::DynamicImage::ImageRgb8(buffer)
267 .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
268 .context("failed to encode X11 screenshot PNG")?;
269 Ok(out)
270}
271
272fn forced_backend() -> Result<Option<ScreenshotBackend>> {
273 match std::env::var(SCREENSHOT_BACKEND_ENV) {
274 Ok(value) if !value.trim().is_empty() => {
275 ScreenshotBackend::parse(&value).map(Some).ok_or_else(|| {
276 anyhow!(
277 "{SCREENSHOT_BACKEND_ENV}={value:?} is not a recognized backend \
278 (expected gnome-shell, portal, x11, or gnome-screenshot)"
279 )
280 })
281 }
282 _ => Ok(None),
283 }
284}
285
286pub async fn capture_screenshot() -> Result<ScreenshotCapture> {
287 let _pipeline = screenshot_pipeline_permit().await?;
288 let raw = capture_screenshot_raw().await?;
289 run_image_task(move || prepare_screenshot_payload(raw, ScreenshotPayloadOptions::default()))
290 .await
291}
292
293pub(crate) async fn screenshot_pipeline_permit() -> Result<tokio::sync::SemaphorePermit<'static>> {
294 static CAPTURE_PIPELINE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
295 Ok(CAPTURE_PIPELINE.acquire().await?)
296}
297
298pub(crate) async fn run_image_task<T: Send + 'static>(
299 work: impl FnOnce() -> Result<T> + Send + 'static,
300) -> Result<T> {
301 static IMAGE_WORKER: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
302 let permit = IMAGE_WORKER.acquire().await?;
303 tokio::task::spawn_blocking(move || {
304 let _permit = permit;
305 work()
306 })
307 .await
308 .context("screenshot processing task failed")?
309}
310
311pub fn prepare_screenshot_payload(
312 raw: RawScreenshotCapture,
313 options: ScreenshotPayloadOptions,
314) -> Result<ScreenshotCapture> {
315 if raw.bytes.is_empty() {
316 bail!("screenshot file was empty");
317 }
318 let (coordinate_width, coordinate_height) = png_dimensions(&raw.bytes)?;
319 let original_bytes = raw.bytes.len();
320 let options = options.resolve();
321 let (target_width, target_height) =
322 target_dimensions(coordinate_width, coordinate_height, options);
323
324 let (bytes, width, height) = if options.format == ScreenshotOutputFormat::Png
325 && target_width == coordinate_width
326 && target_height == coordinate_height
327 && original_bytes <= options.max_bytes
328 {
329 (raw.bytes, coordinate_width, coordinate_height)
330 } else {
331 encode_screenshot_to_fit_bytes(
332 &raw.bytes,
333 coordinate_width,
334 coordinate_height,
335 target_width,
336 target_height,
337 options,
338 )?
339 };
340
341 let encoded = STANDARD.encode(&bytes);
342 let scale = if coordinate_width == 0 {
343 1.0
344 } else {
345 width as f32 / coordinate_width as f32
346 };
347
348 Ok(ScreenshotCapture {
349 mime_type: options.format.mime_type().to_string(),
350 data_url: format!("data:{};base64,{encoded}", options.format.mime_type()),
351 source: raw.source,
352 width,
353 height,
354 coordinate_width,
355 coordinate_height,
356 scale,
357 resized: width != coordinate_width || height != coordinate_height,
358 bytes: bytes.len(),
359 original_bytes,
360 max_bytes: options.max_bytes,
361 format: options.format,
362 quality: (options.format == ScreenshotOutputFormat::Jpeg).then_some(options.quality),
363 })
364}
365
366async fn capture_with_gnome_shell() -> Result<RawScreenshotCapture> {
367 let connection = zbus::Connection::session()
368 .await
369 .context("failed to connect to session bus")?;
370 let proxy = Proxy::new(
371 &connection,
372 "org.gnome.Shell.Screenshot",
373 "/org/gnome/Shell/Screenshot",
374 "org.gnome.Shell.Screenshot",
375 )
376 .await
377 .context("failed to create GNOME Shell screenshot proxy")?;
378 let path = temp_png_path("gnome-shell");
379 let filename = path
380 .to_str()
381 .context("temporary screenshot path is not valid UTF-8")?;
382 let result = proxy.call("Screenshot", &(false, false, filename)).await;
383 let (success, filename_used): (bool, String) = match result {
384 Ok(result) => result,
385 Err(error) => {
386 cleanup_gnome_requested_path(&path);
387 return Err(error).context("GNOME Shell Screenshot call failed");
388 }
389 };
390
391 if !success {
392 cleanup_gnome_requested_path(&path);
393 bail!("GNOME Shell reported screenshot failure");
394 }
395
396 read_png_as_capture(
397 PathBuf::from(filename_used),
398 "gnome-shell",
399 ScreenshotCleanup::DeletePath(path),
400 )
401 .await
402}
403
404async fn capture_with_portal() -> Result<RawScreenshotCapture> {
405 let connection = zbus::Connection::session()
406 .await
407 .context("failed to connect to session bus")?;
408 let portal_owner = portal_desktop_owner(&connection).await?;
409 let token = request_token();
410 let mut response_stream = portal_response_stream(&connection, &portal_owner).await?;
413
414 let portal_proxy = Proxy::new(
415 &connection,
416 portal_owner.clone(),
417 PORTAL_DESKTOP_PATH,
418 PORTAL_SCREENSHOT_INTERFACE,
419 )
420 .await
421 .context("failed to create XDG portal screenshot proxy")?;
422 let mut options: HashMap<&str, Value<'_>> = HashMap::new();
423 options.insert("handle_token", Value::from(token.as_str()));
424 options.insert("interactive", Value::from(false));
425 let handle: OwnedObjectPath = portal_proxy
426 .call("Screenshot", &("", options))
427 .await
428 .context("XDG portal Screenshot call failed")?;
429
430 let (response_code, results) = tokio::time::timeout(
431 Duration::from_secs(20),
432 wait_for_portal_response(&mut response_stream, handle.as_str(), portal_owner.as_str()),
433 )
434 .await
435 .context("timed out waiting for XDG portal screenshot response")??;
436
437 match response_code {
438 0 => {}
439 1 => bail!("XDG portal screenshot was cancelled by the user (response code 1)"),
440 2 => bail!(
441 "XDG portal screenshot ended without success (response code 2); \
442 check the desktop portal logs for permission or backend errors"
443 ),
444 _ => bail!("XDG portal screenshot returned unknown response code {response_code}"),
445 }
446
447 let uri_value = results
448 .get("uri")
449 .context("XDG portal screenshot response did not include a uri")?;
450 let uri: String = uri_value
451 .try_clone()
452 .context("failed to clone XDG portal screenshot uri")?
453 .try_into()
454 .context("XDG portal screenshot uri was not a string")?;
455 let path = file_uri_to_path(&uri)?;
456
457 read_png_as_capture(path, "xdg-desktop-portal", ScreenshotCleanup::Preserve).await
458}
459
460const GNOME_SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20);
463
464async fn capture_with_gnome_screenshot() -> Result<RawScreenshotCapture> {
465 let path = temp_png_path("gnome-screenshot");
466 let filename = path
467 .to_str()
468 .context("temporary screenshot path is not valid UTF-8")?;
469
470 let mut command = Command::new("gnome-screenshot");
474 command
475 .args(["-f", filename])
476 .stdin(Stdio::null())
477 .stdout(Stdio::null())
478 .stderr(Stdio::null());
479 let mut child = match crate::command_runner::spawn_retrying_busy(&mut command).await {
480 Ok(child) => child,
481 Err(error) => {
482 cleanup_gnome_requested_path(&path);
483 return Err(error).context("failed to spawn gnome-screenshot");
484 }
485 };
486
487 let status = match tokio::time::timeout(GNOME_SCREENSHOT_TIMEOUT, child.wait()).await {
490 Ok(Ok(status)) => status,
491 Ok(Err(error)) => {
492 cleanup_gnome_requested_path(&path);
493 return Err(error).context("failed to wait for gnome-screenshot");
494 }
495 Err(_) => {
496 let _ = child.kill().await;
497 cleanup_gnome_requested_path(&path);
498 bail!("gnome-screenshot timed out");
499 }
500 };
501
502 if !status.success() {
503 cleanup_gnome_requested_path(&path);
504 bail!("gnome-screenshot exited with {status}");
505 }
506
507 read_png_as_capture(
508 path.clone(),
509 "gnome-screenshot",
510 ScreenshotCleanup::DeletePath(path),
511 )
512 .await
513}
514
515async fn portal_desktop_owner(connection: &zbus::Connection) -> Result<OwnedUniqueName> {
516 let dbus = zbus::fdo::DBusProxy::new(connection)
517 .await
518 .context("failed to create session-bus identity proxy")?;
519 let _: u32 = dbus
520 .start_service_by_name(WellKnownName::try_from(PORTAL_DESKTOP_SERVICE)?, 0)
521 .await
522 .context("failed to activate the XDG desktop portal")?;
523 dbus.get_name_owner(BusName::try_from(PORTAL_DESKTOP_SERVICE)?)
524 .await
525 .context("failed to resolve the XDG desktop portal owner")
526}
527
528async fn portal_response_stream(
529 connection: &zbus::Connection,
530 portal_owner: &OwnedUniqueName,
531) -> Result<MessageStream> {
532 let response_rule = MatchRule::builder()
533 .msg_type(MessageType::Signal)
534 .sender(portal_owner.clone())?
535 .interface(PORTAL_REQUEST_INTERFACE)?
536 .member("Response")?
537 .path_namespace(PORTAL_REQUEST_PATH_NAMESPACE)?
538 .build();
539
540 MessageStream::for_match_rule(response_rule, connection, None)
541 .await
542 .context("failed to subscribe to XDG portal screenshot responses")
543}
544
545async fn wait_for_portal_response(
546 response_stream: &mut MessageStream,
547 request_path: &str,
548 portal_owner: &str,
549) -> Result<(u32, HashMap<String, OwnedValue>)> {
550 loop {
551 let response = response_stream
552 .next()
553 .await
554 .context("XDG portal screenshot response stream ended")?
555 .context("XDG portal screenshot response stream failed")?;
556
557 if !portal_response_matches(&response, request_path, portal_owner) {
558 continue;
559 }
560
561 return response
562 .body()
563 .deserialize()
564 .context("failed to decode XDG portal screenshot response");
565 }
566}
567
568fn portal_response_matches(response: &Message, request_path: &str, portal_owner: &str) -> bool {
569 let header = response.header();
570 header
571 .sender()
572 .is_some_and(|sender| sender.as_str() == portal_owner)
573 && header
574 .path()
575 .is_some_and(|path| path.as_str() == request_path)
576}
577
578async fn read_png_as_capture(
579 path: PathBuf,
580 source: &str,
581 cleanup: ScreenshotCleanup,
582) -> Result<RawScreenshotCapture> {
583 let source = source.to_string();
584 let cleanup = OwnedScreenshotCleanup(cleanup);
585 run_image_task(move || {
586 let _cleanup = cleanup;
587 read_png_as_capture_inner(&path, &source)
588 })
589 .await
590}
591
592fn read_png_as_capture_inner(path: &Path, source: &str) -> Result<RawScreenshotCapture> {
593 let file = OpenOptions::new()
594 .read(true)
595 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
596 .open(path)
597 .with_context(|| format!("failed to open screenshot file {}", path.display()))?;
598 let metadata = file
599 .metadata()
600 .with_context(|| format!("failed to inspect screenshot file {}", path.display()))?;
601 if !metadata.file_type().is_file() {
602 bail!("screenshot path is not a regular file: {}", path.display());
603 }
604 if metadata.len() > MAX_SCREENSHOT_SOURCE_BYTES {
605 bail!(
606 "screenshot file is {} bytes, over the {}-byte source limit",
607 metadata.len(),
608 MAX_SCREENSHOT_SOURCE_BYTES
609 );
610 }
611 let mut bytes = Vec::with_capacity(metadata.len() as usize);
612 file.take(MAX_SCREENSHOT_SOURCE_BYTES + 1)
613 .read_to_end(&mut bytes)
614 .with_context(|| format!("failed to read screenshot file {}", path.display()))?;
615 if bytes.len() as u64 > MAX_SCREENSHOT_SOURCE_BYTES {
616 bail!(
617 "screenshot file grew beyond the {}-byte source limit",
618 MAX_SCREENSHOT_SOURCE_BYTES
619 );
620 }
621 if bytes.is_empty() {
622 bail!("screenshot file was empty: {}", path.display());
623 }
624 let (width, height) = png_dimensions(&bytes)?;
625 Ok(RawScreenshotCapture {
626 mime_type: "image/png".to_string(),
627 bytes,
628 source: source.to_string(),
629 width,
630 height,
631 })
632}
633
634fn target_dimensions(
635 width: u32,
636 height: u32,
637 options: ResolvedScreenshotPayloadOptions,
638) -> (u32, u32) {
639 let width_scale = options.max_width as f64 / width as f64;
640 let height_scale = options.max_height as f64 / height as f64;
641 let scale = f64::from(options.scale)
642 .min(width_scale)
643 .min(height_scale)
644 .min(1.0);
645
646 let target_width = ((width as f64 * scale).round() as u32).clamp(1, width);
647 let target_height = ((height as f64 * scale).round() as u32).clamp(1, height);
648 (target_width, target_height)
649}
650
651fn encode_screenshot_to_fit_bytes(
652 raw: &[u8],
653 original_width: u32,
654 original_height: u32,
655 mut target_width: u32,
656 mut target_height: u32,
657 options: ResolvedScreenshotPayloadOptions,
658) -> Result<(Vec<u8>, u32, u32)> {
659 let img = image::load_from_memory_with_format(raw, image::ImageFormat::Png)
660 .context("failed to decode screenshot PNG for encoding")?;
661
662 loop {
663 let bytes = if options.format == ScreenshotOutputFormat::Png
664 && target_width == original_width
665 && target_height == original_height
666 {
667 raw.to_vec()
668 } else {
669 let output = if target_width == original_width && target_height == original_height {
670 img.clone()
671 } else {
672 img.resize_exact(target_width, target_height, FilterType::Lanczos3)
673 };
674 encode_image(&output, options)?
675 };
676
677 if bytes.len() <= options.max_bytes {
678 return Ok((bytes, target_width, target_height));
679 }
680
681 if target_width == 1 && target_height == 1 {
682 bail!(
683 "screenshot payload is {} bytes at 1x1, over max_bytes {}",
684 bytes.len(),
685 options.max_bytes
686 );
687 }
688
689 (target_width, target_height) = next_dimensions_for_byte_cap(
690 target_width,
691 target_height,
692 bytes.len(),
693 options.max_bytes,
694 );
695 }
696}
697
698fn encode_image(
699 img: &image::DynamicImage,
700 options: ResolvedScreenshotPayloadOptions,
701) -> Result<Vec<u8>> {
702 let mut out = Vec::new();
703 match options.format {
704 ScreenshotOutputFormat::Png => {
705 img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
706 .context("failed to encode screenshot PNG")?;
707 }
708 ScreenshotOutputFormat::Jpeg => {
709 let rgb = img.to_rgb8();
710 JpegEncoder::new_with_quality(&mut out, options.quality)
711 .encode_image(&rgb)
712 .context("failed to encode screenshot JPEG")?;
713 }
714 }
715 Ok(out)
716}
717
718fn next_dimensions_for_byte_cap(
719 width: u32,
720 height: u32,
721 encoded_bytes: usize,
722 max_bytes: usize,
723) -> (u32, u32) {
724 let shrink = ((max_bytes as f64 / encoded_bytes as f64).sqrt() * 0.9).clamp(0.1, 0.95);
725 let mut next_width = ((width as f64 * shrink).floor() as u32).max(1);
726 let mut next_height = ((height as f64 * shrink).floor() as u32).max(1);
727
728 if next_width >= width && width > 1 {
729 next_width = width - 1;
730 }
731 if next_height >= height && height > 1 {
732 next_height = height - 1;
733 }
734
735 (next_width, next_height)
736}
737
738fn cleanup_gnome_requested_path(path: &Path) {
739 let _ = fs::remove_file(path);
740}
741
742fn png_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
743 const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
744 if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE || &bytes[12..16] != b"IHDR" {
745 bail!("screenshot file was not a valid PNG");
746 }
747 let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
748 let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
749 if width == 0 || height == 0 {
750 bail!("screenshot PNG had invalid dimensions {width}x{height}");
751 }
752 let pixels = u64::from(width) * u64::from(height);
753 if pixels > MAX_SCREENSHOT_SOURCE_PIXELS {
754 bail!(
755 "screenshot PNG has {pixels} pixels, over the {MAX_SCREENSHOT_SOURCE_PIXELS}-pixel source limit"
756 );
757 }
758 Ok((width, height))
759}
760
761fn file_uri_to_path(uri: &str) -> Result<PathBuf> {
762 let Some(rest) = uri.strip_prefix("file://") else {
763 bail!("unsupported screenshot uri: {uri}");
764 };
765 let path = PathBuf::from(percent_decode(rest));
766 if !path.is_absolute() {
767 bail!("screenshot uri did not contain an absolute file path: {uri}");
768 }
769 Ok(path)
770}
771
772fn percent_decode(value: &str) -> String {
773 let bytes = value.as_bytes();
774 let mut decoded = Vec::with_capacity(bytes.len());
775 let mut index = 0;
776
777 while index < bytes.len() {
778 if bytes[index] == b'%' && index + 2 < bytes.len() {
779 if let Ok(hex) = std::str::from_utf8(&bytes[index + 1..index + 3]) {
780 if let Ok(byte) = u8::from_str_radix(hex, 16) {
781 decoded.push(byte);
782 index += 3;
783 continue;
784 }
785 }
786 }
787
788 decoded.push(bytes[index]);
789 index += 1;
790 }
791
792 String::from_utf8_lossy(&decoded).into_owned()
793}
794
795fn temp_png_path(source: &str) -> PathBuf {
796 std::env::temp_dir().join(format!(
797 "computer-use-linux-{source}-{}.png",
798 unique_suffix()
799 ))
800}
801
802fn request_token() -> String {
803 format!("computer_use_linux_{}", unique_suffix().replace('-', "_"))
804}
805
806fn unique_suffix() -> String {
807 let nanos = SystemTime::now()
808 .duration_since(UNIX_EPOCH)
809 .map(|duration| duration.as_nanos())
810 .unwrap_or_default();
811 format!("{}-{nanos}", std::process::id())
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 fn test_path(name: &str) -> PathBuf {
819 std::env::temp_dir().join(format!(
820 "computer-use-linux-screenshot-test-{name}-{}",
821 unique_suffix()
822 ))
823 }
824
825 fn valid_png(width: u32, height: u32) -> Vec<u8> {
826 let mut png = Vec::new();
827 png.extend_from_slice(b"\x89PNG\r\n\x1a\n");
828 png.extend_from_slice(&13_u32.to_be_bytes());
829 png.extend_from_slice(b"IHDR");
830 png.extend_from_slice(&width.to_be_bytes());
831 png.extend_from_slice(&height.to_be_bytes());
832 png.extend_from_slice(&[8, 6, 0, 0, 0]);
833 png
834 }
835
836 fn solid_png(width: u32, height: u32) -> Vec<u8> {
837 let img = image::RgbaImage::from_pixel(width, height, image::Rgba([24, 96, 160, 255]));
838 encode_test_png(img)
839 }
840
841 fn noisy_png(width: u32, height: u32) -> Vec<u8> {
842 let mut img = image::RgbaImage::new(width, height);
843 for (x, y, pixel) in img.enumerate_pixels_mut() {
844 let r = ((x * 31 + y * 17) % 256) as u8;
845 let g = ((x * 13 + y * 47) % 256) as u8;
846 let b = ((x * 97 + y * 7) % 256) as u8;
847 *pixel = image::Rgba([r, g, b, 255]);
848 }
849 encode_test_png(img)
850 }
851
852 fn encode_test_png(img: image::RgbaImage) -> Vec<u8> {
853 let mut out = Vec::new();
854 image::DynamicImage::ImageRgba8(img)
855 .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
856 .unwrap();
857 out
858 }
859
860 fn raw_capture(bytes: Vec<u8>) -> RawScreenshotCapture {
861 let (width, height) = png_dimensions(&bytes).unwrap();
862 RawScreenshotCapture {
863 mime_type: "image/png".to_string(),
864 bytes,
865 source: "test".to_string(),
866 width,
867 height,
868 }
869 }
870
871 #[test]
872 fn decodes_file_uri_percent_escapes() {
873 assert_eq!(
874 file_uri_to_path("file:///tmp/Codex%20Screenshot.png").unwrap(),
875 PathBuf::from("/tmp/Codex Screenshot.png")
876 );
877 }
878
879 #[test]
880 fn file_uri_requires_an_absolute_path() {
881 assert!(file_uri_to_path("file://relative/path.png").is_err());
882 }
883
884 #[test]
885 fn portal_response_requires_the_pinned_sender_and_exact_path() {
886 let request_path = "/org/freedesktop/portal/desktop/request/1_7/token";
887 let response = Message::signal(request_path, PORTAL_REQUEST_INTERFACE, "Response")
888 .unwrap()
889 .sender(":1.42")
890 .unwrap()
891 .build(&())
892 .unwrap();
893
894 assert!(portal_response_matches(&response, request_path, ":1.42"));
895 assert!(!portal_response_matches(&response, request_path, ":1.99"));
896 assert!(!portal_response_matches(
897 &response,
898 "/org/freedesktop/portal/desktop/request/1_7/other",
899 ":1.42"
900 ));
901 }
902
903 #[test]
904 fn parses_known_backend_names() {
905 assert_eq!(
906 ScreenshotBackend::parse("gnome-shell"),
907 Some(ScreenshotBackend::GnomeShell)
908 );
909 assert_eq!(
910 ScreenshotBackend::parse(" Portal "),
911 Some(ScreenshotBackend::Portal)
912 );
913 assert_eq!(
914 ScreenshotBackend::parse("GNOME_SCREENSHOT"),
915 Some(ScreenshotBackend::GnomeScreenshot)
916 );
917 assert_eq!(
918 ScreenshotBackend::parse("x11"),
919 Some(ScreenshotBackend::X11)
920 );
921 assert_eq!(
922 ScreenshotBackend::parse(" X11-Native "),
923 Some(ScreenshotBackend::X11)
924 );
925 assert_eq!(ScreenshotBackend::parse("nonsense"), None);
926 }
927
928 #[test]
929 fn forced_backend_reads_env_override() {
930 std::env::set_var(SCREENSHOT_BACKEND_ENV, "gnome-screenshot");
932 assert_eq!(
933 forced_backend().unwrap(),
934 Some(ScreenshotBackend::GnomeScreenshot)
935 );
936
937 std::env::set_var(SCREENSHOT_BACKEND_ENV, " ");
938 assert_eq!(forced_backend().unwrap(), None);
939
940 std::env::set_var(SCREENSHOT_BACKEND_ENV, "bogus");
941 let error = forced_backend().unwrap_err();
942 assert!(error.to_string().contains("not a recognized backend"));
943
944 std::env::remove_var(SCREENSHOT_BACKEND_ENV);
945 assert_eq!(forced_backend().unwrap(), None);
946 }
947
948 #[test]
949 fn request_token_is_portal_safe() {
950 let token = request_token();
951 assert!(token.starts_with("computer_use_linux_"));
952 assert!(token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
953 }
954
955 #[test]
956 fn reads_png_dimensions_from_ihdr() {
957 let png = valid_png(3840, 1080);
958
959 assert_eq!(png_dimensions(&png).unwrap(), (3840, 1080));
960 }
961
962 #[test]
963 fn default_payload_downscales_long_edge() {
964 let capture =
965 prepare_screenshot_payload(raw_capture(solid_png(4000, 1000)), Default::default())
966 .unwrap();
967
968 assert_eq!((capture.width, capture.height), (1920, 480));
969 assert_eq!(
970 (capture.coordinate_width, capture.coordinate_height),
971 (4000, 1000)
972 );
973 assert!(capture.resized);
974 assert!(capture.bytes <= DEFAULT_SCREENSHOT_MAX_BYTES);
975 assert!(capture.data_url.starts_with("data:image/png;base64,"));
976 }
977
978 #[test]
979 fn larger_bounded_request_can_keep_more_detail() {
980 let capture = prepare_screenshot_payload(
981 raw_capture(solid_png(3000, 1000)),
982 ScreenshotPayloadOptions {
983 max_width: Some(3000),
984 max_height: Some(3000),
985 max_bytes: Some(DEFAULT_SCREENSHOT_MAX_BYTES),
986 ..Default::default()
987 },
988 )
989 .unwrap();
990
991 assert_eq!((capture.width, capture.height), (3000, 1000));
992 assert_eq!(
993 (capture.coordinate_width, capture.coordinate_height),
994 (3000, 1000)
995 );
996 assert!(!capture.resized);
997 }
998
999 #[test]
1000 fn byte_cap_downscales_until_payload_fits() {
1001 let capture = prepare_screenshot_payload(
1002 raw_capture(noisy_png(512, 512)),
1003 ScreenshotPayloadOptions {
1004 max_width: Some(512),
1005 max_height: Some(512),
1006 max_bytes: Some(20_000),
1007 ..Default::default()
1008 },
1009 )
1010 .unwrap();
1011
1012 assert!(capture.bytes <= 20_000);
1013 assert!(capture.width < 512);
1014 assert_eq!(
1015 (capture.coordinate_width, capture.coordinate_height),
1016 (512, 512)
1017 );
1018 assert!(capture.resized);
1019 }
1020
1021 #[test]
1022 fn jpeg_format_compresses_when_requested() {
1023 let capture = prepare_screenshot_payload(
1024 raw_capture(noisy_png(512, 512)),
1025 ScreenshotPayloadOptions {
1026 max_width: Some(512),
1027 max_height: Some(512),
1028 max_bytes: Some(DEFAULT_SCREENSHOT_MAX_BYTES),
1029 format: Some(ScreenshotOutputFormat::Jpeg),
1030 quality: Some(60),
1031 ..Default::default()
1032 },
1033 )
1034 .unwrap();
1035
1036 assert_eq!(capture.mime_type, "image/jpeg");
1037 assert_eq!(capture.format, ScreenshotOutputFormat::Jpeg);
1038 assert_eq!(capture.quality, Some(60));
1039 assert_eq!((capture.width, capture.height), (512, 512));
1040 assert_eq!(
1041 (capture.coordinate_width, capture.coordinate_height),
1042 (512, 512)
1043 );
1044 assert!(capture.bytes < capture.original_bytes);
1045 assert!(capture.data_url.starts_with("data:image/jpeg;base64,"));
1046 }
1047
1048 #[tokio::test]
1049 async fn cancelled_queued_capture_removes_its_owned_file() {
1050 let cache = std::env::var_os("XDG_CACHE_HOME")
1051 .map(PathBuf::from)
1052 .unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap()).join(".cache"));
1053 let path = cache.join(format!(
1054 "cul-cancelled-capture-{}.png",
1055 getrandom::u64().unwrap()
1056 ));
1057 fs::create_dir_all(&cache).unwrap();
1058 fs::write(&path, valid_png(1, 1)).unwrap();
1059 let (release, wait) = std::sync::mpsc::channel();
1060 let (started, ready) = tokio::sync::oneshot::channel();
1061 let busy = tokio::spawn(run_image_task(move || {
1062 started.send(()).unwrap();
1063 wait.recv().unwrap();
1064 Ok(())
1065 }));
1066 ready.await.unwrap();
1067 let mut queued = tokio::spawn(read_png_as_capture(
1068 path.clone(),
1069 "owned-test-capture",
1070 ScreenshotCleanup::DeletePath(path.clone()),
1071 ));
1072 assert!(tokio::time::timeout(Duration::from_millis(20), &mut queued)
1073 .await
1074 .is_err());
1075 queued.abort();
1076 assert!(queued.await.unwrap_err().is_cancelled());
1077 release.send(()).unwrap();
1078 busy.await.unwrap().unwrap();
1079 let removed = !path.exists();
1080 let _ = fs::remove_file(path);
1081 assert!(removed, "cancelled screenshot left its owned file behind");
1082 }
1083
1084 #[tokio::test]
1085 async fn portal_capture_preserves_valid_returned_path() {
1086 let path = test_path("portal-valid");
1087 fs::write(&path, valid_png(1, 1)).unwrap();
1088
1089 let capture = read_png_as_capture(
1090 path.clone(),
1091 "xdg-desktop-portal",
1092 ScreenshotCleanup::Preserve,
1093 )
1094 .await
1095 .unwrap();
1096
1097 assert_eq!(capture.source, "xdg-desktop-portal");
1098 assert!(path.exists());
1099 let _ = fs::remove_file(path);
1100 }
1101
1102 #[tokio::test]
1103 async fn portal_capture_preserves_invalid_returned_path() {
1104 let path = test_path("portal-invalid");
1105 fs::write(&path, b"").unwrap();
1106
1107 let error = read_png_as_capture(
1108 path.clone(),
1109 "xdg-desktop-portal",
1110 ScreenshotCleanup::Preserve,
1111 )
1112 .await
1113 .unwrap_err();
1114
1115 assert!(error.to_string().contains("screenshot file was empty"));
1116 assert!(path.exists());
1117 let _ = fs::remove_file(path);
1118 }
1119
1120 #[tokio::test]
1121 async fn screenshot_reader_rejects_symlinks() {
1122 let target = test_path("symlink-target");
1123 let link = test_path("symlink-link");
1124 fs::write(&target, valid_png(1, 1)).unwrap();
1125 std::os::unix::fs::symlink(&target, &link).unwrap();
1126
1127 let error = read_png_as_capture(link.clone(), "test", ScreenshotCleanup::Preserve)
1128 .await
1129 .unwrap_err();
1130
1131 assert!(error.to_string().contains("failed to open screenshot file"));
1132 let _ = fs::remove_file(link);
1133 let _ = fs::remove_file(target);
1134 }
1135
1136 #[tokio::test]
1137 async fn screenshot_reader_rejects_oversized_source_files() {
1138 let path = test_path("oversized-source");
1139 let file = fs::File::create(&path).unwrap();
1140 file.set_len(MAX_SCREENSHOT_SOURCE_BYTES + 1).unwrap();
1141
1142 let error = read_png_as_capture(path.clone(), "test", ScreenshotCleanup::Preserve)
1143 .await
1144 .unwrap_err();
1145
1146 assert!(error.to_string().contains("over the"));
1147 let _ = fs::remove_file(path);
1148 }
1149
1150 #[test]
1151 fn png_dimensions_reject_decompression_bomb_dimensions() {
1152 let png = valid_png(u32::MAX, u32::MAX);
1153
1154 assert!(png_dimensions(&png)
1155 .unwrap_err()
1156 .to_string()
1157 .contains("pixel source limit"));
1158 }
1159
1160 #[tokio::test]
1161 async fn gnome_capture_deletes_backend_temp_path_on_success() {
1162 let path = test_path("gnome-valid");
1163 fs::write(&path, valid_png(1, 1)).unwrap();
1164
1165 let capture = read_png_as_capture(
1166 path.clone(),
1167 "gnome-shell",
1168 ScreenshotCleanup::DeletePath(path.clone()),
1169 )
1170 .await
1171 .unwrap();
1172
1173 assert_eq!(capture.source, "gnome-shell");
1174 assert!(!path.exists());
1175 }
1176
1177 #[tokio::test]
1178 async fn gnome_capture_deletes_backend_temp_path_on_parse_failure() {
1179 let path = test_path("gnome-invalid");
1180 fs::write(&path, b"").unwrap();
1181
1182 let error = read_png_as_capture(
1183 path.clone(),
1184 "gnome-shell",
1185 ScreenshotCleanup::DeletePath(path.clone()),
1186 )
1187 .await
1188 .unwrap_err();
1189
1190 assert!(error.to_string().contains("screenshot file was empty"));
1191 assert!(!path.exists());
1192 }
1193
1194 #[test]
1195 fn gnome_failure_cleanup_removes_requested_temp_path() {
1196 let path = test_path("gnome-pre-read-failure");
1197 fs::write(&path, b"partial").unwrap();
1198
1199 cleanup_gnome_requested_path(&path);
1200
1201 assert!(!path.exists());
1202 }
1203
1204 #[tokio::test]
1205 async fn gnome_deletes_requested_temp_path_and_preserves_unexpected_returned_path() {
1206 let requested = test_path("gnome-requested");
1207 let returned = test_path("gnome-returned");
1208 fs::write(&requested, b"partial").unwrap();
1209 fs::write(&returned, valid_png(1, 1)).unwrap();
1210
1211 let capture = read_png_as_capture(
1212 returned.clone(),
1213 "gnome-shell",
1214 ScreenshotCleanup::DeletePath(requested.clone()),
1215 )
1216 .await
1217 .unwrap();
1218
1219 assert_eq!(capture.source, "gnome-shell");
1220 assert!(!requested.exists());
1221 assert!(returned.exists());
1222 let _ = fs::remove_file(returned);
1223 }
1224}