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,
13 io::Cursor,
14 path::{Path, PathBuf},
15 process::Stdio,
16 time::{Duration, SystemTime, UNIX_EPOCH},
17};
18use tokio::process::Command;
19use zbus::{
20 message::{Message, Type as MessageType},
21 zvariant::{OwnedObjectPath, OwnedValue, Value},
22 MatchRule, MessageStream, Proxy,
23};
24
25const PORTAL_REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request";
26const PORTAL_REQUEST_PATH_NAMESPACE: &str = "/org/freedesktop/portal/desktop/request";
27
28pub const DEFAULT_SCREENSHOT_MAX_DIMENSION: u32 = 1920;
29pub const DEFAULT_SCREENSHOT_MAX_BYTES: usize = 2 * 1024 * 1024;
30pub const ABSOLUTE_SCREENSHOT_MAX_DIMENSION: u32 = 4096;
31pub const ABSOLUTE_SCREENSHOT_MAX_BYTES: usize = 4 * 1024 * 1024;
32pub const DEFAULT_SCREENSHOT_JPEG_QUALITY: u8 = 80;
33pub const MIN_SCREENSHOT_JPEG_QUALITY: u8 = 1;
34pub const MAX_SCREENSHOT_JPEG_QUALITY: u8 = 95;
35const MIN_SCREENSHOT_MAX_BYTES: usize = 1024;
36
37#[derive(Debug, Clone)]
38pub struct RawScreenshotCapture {
39 pub mime_type: String,
40 pub bytes: Vec<u8>,
41 pub source: String,
42 pub width: u32,
43 pub height: u32,
44}
45
46#[derive(Debug, Clone, Serialize, JsonSchema)]
47pub struct ScreenshotCapture {
48 pub mime_type: String,
49 pub data_url: String,
50 pub source: String,
51 pub width: u32,
53 pub height: u32,
55 pub coordinate_width: u32,
57 pub coordinate_height: u32,
59 pub scale: f32,
61 pub resized: bool,
62 pub bytes: usize,
63 pub original_bytes: usize,
64 pub max_bytes: usize,
65 pub format: ScreenshotOutputFormat,
66 pub quality: Option<u8>,
67}
68
69#[derive(Debug, Clone, Copy, Default)]
70pub struct ScreenshotPayloadOptions {
71 pub max_width: Option<u32>,
72 pub max_height: Option<u32>,
73 pub max_bytes: Option<usize>,
74 pub scale: Option<f32>,
75 pub format: Option<ScreenshotOutputFormat>,
76 pub quality: Option<u8>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "lowercase")]
81pub enum ScreenshotOutputFormat {
82 Png,
83 Jpeg,
84}
85
86impl ScreenshotOutputFormat {
87 fn mime_type(self) -> &'static str {
88 match self {
89 Self::Png => "image/png",
90 Self::Jpeg => "image/jpeg",
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy)]
96struct ResolvedScreenshotPayloadOptions {
97 max_width: u32,
98 max_height: u32,
99 max_bytes: usize,
100 scale: f32,
101 format: ScreenshotOutputFormat,
102 quality: u8,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106enum ScreenshotCleanup {
107 DeletePath(PathBuf),
108 Preserve,
109}
110
111impl ScreenshotPayloadOptions {
112 fn resolve(self) -> ResolvedScreenshotPayloadOptions {
113 let max_width = self
114 .max_width
115 .unwrap_or(DEFAULT_SCREENSHOT_MAX_DIMENSION)
116 .clamp(1, ABSOLUTE_SCREENSHOT_MAX_DIMENSION);
117 let max_height = self
118 .max_height
119 .unwrap_or(DEFAULT_SCREENSHOT_MAX_DIMENSION)
120 .clamp(1, ABSOLUTE_SCREENSHOT_MAX_DIMENSION);
121 let max_bytes = self
122 .max_bytes
123 .unwrap_or(DEFAULT_SCREENSHOT_MAX_BYTES)
124 .clamp(MIN_SCREENSHOT_MAX_BYTES, ABSOLUTE_SCREENSHOT_MAX_BYTES);
125 let scale = self
126 .scale
127 .filter(|value| value.is_finite() && *value > 0.0)
128 .unwrap_or(1.0)
129 .min(1.0);
130 let format = self.format.unwrap_or(ScreenshotOutputFormat::Png);
131 let quality = self
132 .quality
133 .unwrap_or(DEFAULT_SCREENSHOT_JPEG_QUALITY)
134 .clamp(MIN_SCREENSHOT_JPEG_QUALITY, MAX_SCREENSHOT_JPEG_QUALITY);
135
136 ResolvedScreenshotPayloadOptions {
137 max_width,
138 max_height,
139 max_bytes,
140 scale,
141 format,
142 quality,
143 }
144 }
145}
146
147const SCREENSHOT_BACKEND_ENV: &str = "COMPUTER_USE_LINUX_SCREENSHOT_BACKEND";
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum ScreenshotBackend {
153 GnomeShell,
154 Portal,
155 X11,
156 GnomeScreenshot,
157}
158
159impl ScreenshotBackend {
160 fn parse(value: &str) -> Option<Self> {
161 match value.trim().to_ascii_lowercase().as_str() {
162 "gnome-shell" | "gnome_shell" | "shell" => Some(Self::GnomeShell),
163 "portal" | "xdg-portal" | "xdg_portal" => Some(Self::Portal),
164 "x11" | "x11-native" | "x11_native" | "xgetimage" => Some(Self::X11),
165 "gnome-screenshot" | "gnome_screenshot" => Some(Self::GnomeScreenshot),
166 _ => None,
167 }
168 }
169
170 async fn capture(self) -> Result<RawScreenshotCapture> {
171 match self {
172 Self::GnomeShell => capture_with_gnome_shell().await,
173 Self::Portal => capture_with_portal().await,
174 Self::X11 => capture_with_x11().await,
175 Self::GnomeScreenshot => capture_with_gnome_screenshot().await,
176 }
177 }
178}
179
180pub async fn capture_screenshot_raw() -> Result<RawScreenshotCapture> {
181 hydrate_session_bus_env();
182
183 if let Some(forced) = forced_backend()? {
187 return forced.capture().await;
188 }
189
190 let gnome_error = match capture_with_gnome_shell().await {
197 Ok(capture) => return Ok(capture),
198 Err(error) => error,
199 };
200 let portal_error = match capture_with_portal().await {
201 Ok(capture) => return Ok(capture),
202 Err(error) => error,
203 };
204 let x11_error = match capture_with_x11().await {
208 Ok(capture) => return Ok(capture),
209 Err(error) => error,
210 };
211 let cli_error = match capture_with_gnome_screenshot().await {
212 Ok(capture) => return Ok(capture),
213 Err(error) => error,
214 };
215
216 Err(anyhow!(
217 "GNOME Shell screenshot failed: {gnome_error}; \
218 XDG portal screenshot failed: {portal_error}; \
219 native X11 screenshot failed: {x11_error}; \
220 gnome-screenshot fallback failed: {cli_error}"
221 ))
222}
223
224async fn capture_with_x11() -> Result<RawScreenshotCapture> {
227 if !is_native_x11_session() {
228 bail!("not a native X11 session (needs DISPLAY on an X11, not Wayland, session)");
229 }
230 let image = with_x11_display(X11_CAPTURE_TIMEOUT, |display| display.capture_root()).await??;
231 let (width, height) = (image.width, image.height);
232 let bytes =
233 tokio::task::spawn_blocking(move || encode_rgb_png(image.width, image.height, image.rgb))
234 .await
235 .context("X11 screenshot encoder task failed")??;
236 Ok(RawScreenshotCapture {
237 mime_type: "image/png".to_string(),
238 bytes,
239 source: "x11".to_string(),
240 width,
241 height,
242 })
243}
244
245fn encode_rgb_png(width: u32, height: u32, rgb: Vec<u8>) -> Result<Vec<u8>> {
246 let buffer = image::RgbImage::from_raw(width, height, rgb)
247 .context("X11 root image did not match its dimensions")?;
248 let mut out = Vec::new();
249 image::DynamicImage::ImageRgb8(buffer)
250 .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
251 .context("failed to encode X11 screenshot PNG")?;
252 Ok(out)
253}
254
255fn forced_backend() -> Result<Option<ScreenshotBackend>> {
256 match std::env::var(SCREENSHOT_BACKEND_ENV) {
257 Ok(value) if !value.trim().is_empty() => {
258 ScreenshotBackend::parse(&value).map(Some).ok_or_else(|| {
259 anyhow!(
260 "{SCREENSHOT_BACKEND_ENV}={value:?} is not a recognized backend \
261 (expected gnome-shell, portal, x11, or gnome-screenshot)"
262 )
263 })
264 }
265 _ => Ok(None),
266 }
267}
268
269pub async fn capture_screenshot() -> Result<ScreenshotCapture> {
270 let raw = capture_screenshot_raw().await?;
271 prepare_screenshot_payload(raw, ScreenshotPayloadOptions::default())
272}
273
274pub fn prepare_screenshot_payload(
275 raw: RawScreenshotCapture,
276 options: ScreenshotPayloadOptions,
277) -> Result<ScreenshotCapture> {
278 if raw.bytes.is_empty() {
279 bail!("screenshot file was empty");
280 }
281 let (coordinate_width, coordinate_height) = png_dimensions(&raw.bytes)?;
282 let original_bytes = raw.bytes.len();
283 let options = options.resolve();
284 let (target_width, target_height) =
285 target_dimensions(coordinate_width, coordinate_height, options);
286
287 let (bytes, width, height) = if options.format == ScreenshotOutputFormat::Png
288 && target_width == coordinate_width
289 && target_height == coordinate_height
290 && original_bytes <= options.max_bytes
291 {
292 (raw.bytes, coordinate_width, coordinate_height)
293 } else {
294 encode_screenshot_to_fit_bytes(
295 &raw.bytes,
296 coordinate_width,
297 coordinate_height,
298 target_width,
299 target_height,
300 options,
301 )?
302 };
303
304 let encoded = STANDARD.encode(&bytes);
305 let scale = if coordinate_width == 0 {
306 1.0
307 } else {
308 width as f32 / coordinate_width as f32
309 };
310
311 Ok(ScreenshotCapture {
312 mime_type: options.format.mime_type().to_string(),
313 data_url: format!("data:{};base64,{encoded}", options.format.mime_type()),
314 source: raw.source,
315 width,
316 height,
317 coordinate_width,
318 coordinate_height,
319 scale,
320 resized: width != coordinate_width || height != coordinate_height,
321 bytes: bytes.len(),
322 original_bytes,
323 max_bytes: options.max_bytes,
324 format: options.format,
325 quality: (options.format == ScreenshotOutputFormat::Jpeg).then_some(options.quality),
326 })
327}
328
329async fn capture_with_gnome_shell() -> Result<RawScreenshotCapture> {
330 let connection = zbus::Connection::session()
331 .await
332 .context("failed to connect to session bus")?;
333 let proxy = Proxy::new(
334 &connection,
335 "org.gnome.Shell.Screenshot",
336 "/org/gnome/Shell/Screenshot",
337 "org.gnome.Shell.Screenshot",
338 )
339 .await
340 .context("failed to create GNOME Shell screenshot proxy")?;
341 let path = temp_png_path("gnome-shell");
342 let filename = path
343 .to_str()
344 .context("temporary screenshot path is not valid UTF-8")?;
345 let result = proxy.call("Screenshot", &(false, false, filename)).await;
346 let (success, filename_used): (bool, String) = match result {
347 Ok(result) => result,
348 Err(error) => {
349 cleanup_gnome_requested_path(&path);
350 return Err(error).context("GNOME Shell Screenshot call failed");
351 }
352 };
353
354 if !success {
355 cleanup_gnome_requested_path(&path);
356 bail!("GNOME Shell reported screenshot failure");
357 }
358
359 read_png_as_capture(
360 PathBuf::from(filename_used),
361 "gnome-shell",
362 ScreenshotCleanup::DeletePath(path),
363 )
364 .await
365}
366
367async fn capture_with_portal() -> Result<RawScreenshotCapture> {
368 let connection = zbus::Connection::session()
369 .await
370 .context("failed to connect to session bus")?;
371 let token = request_token();
372 let mut response_stream = portal_response_stream(&connection).await?;
375
376 let portal_proxy = Proxy::new(
377 &connection,
378 "org.freedesktop.portal.Desktop",
379 "/org/freedesktop/portal/desktop",
380 "org.freedesktop.portal.Screenshot",
381 )
382 .await
383 .context("failed to create XDG portal screenshot proxy")?;
384 let mut options: HashMap<&str, Value<'_>> = HashMap::new();
385 options.insert("handle_token", Value::from(token.as_str()));
386 options.insert("interactive", Value::from(false));
387 let handle: OwnedObjectPath = portal_proxy
388 .call("Screenshot", &("", options))
389 .await
390 .context("XDG portal Screenshot call failed")?;
391
392 let (response_code, results) = tokio::time::timeout(
393 Duration::from_secs(20),
394 wait_for_portal_response(&mut response_stream, handle.as_str()),
395 )
396 .await
397 .context("timed out waiting for XDG portal screenshot response")??;
398
399 if response_code != 0 {
400 bail!("XDG portal screenshot was denied or cancelled with response code {response_code}");
401 }
402
403 let uri_value = results
404 .get("uri")
405 .context("XDG portal screenshot response did not include a uri")?;
406 let uri: String = uri_value
407 .try_clone()
408 .context("failed to clone XDG portal screenshot uri")?
409 .try_into()
410 .context("XDG portal screenshot uri was not a string")?;
411 let path = file_uri_to_path(&uri)?;
412
413 read_png_as_capture(path, "xdg-desktop-portal", ScreenshotCleanup::Preserve).await
414}
415
416const GNOME_SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20);
419
420async fn capture_with_gnome_screenshot() -> Result<RawScreenshotCapture> {
421 let path = temp_png_path("gnome-screenshot");
422 let filename = path
423 .to_str()
424 .context("temporary screenshot path is not valid UTF-8")?;
425
426 let mut command = Command::new("gnome-screenshot");
430 command
431 .args(["-f", filename])
432 .stdin(Stdio::null())
433 .stdout(Stdio::null())
434 .stderr(Stdio::null());
435 let mut child = match crate::command_runner::spawn_retrying_busy(&mut command).await {
436 Ok(child) => child,
437 Err(error) => {
438 cleanup_gnome_requested_path(&path);
439 return Err(error).context("failed to spawn gnome-screenshot");
440 }
441 };
442
443 let status = match tokio::time::timeout(GNOME_SCREENSHOT_TIMEOUT, child.wait()).await {
446 Ok(Ok(status)) => status,
447 Ok(Err(error)) => {
448 cleanup_gnome_requested_path(&path);
449 return Err(error).context("failed to wait for gnome-screenshot");
450 }
451 Err(_) => {
452 let _ = child.kill().await;
453 cleanup_gnome_requested_path(&path);
454 bail!("gnome-screenshot timed out");
455 }
456 };
457
458 if !status.success() {
459 cleanup_gnome_requested_path(&path);
460 bail!("gnome-screenshot exited with {status}");
461 }
462
463 read_png_as_capture(
464 path.clone(),
465 "gnome-screenshot",
466 ScreenshotCleanup::DeletePath(path),
467 )
468 .await
469}
470
471async fn portal_response_stream(connection: &zbus::Connection) -> Result<MessageStream> {
472 let response_rule = MatchRule::builder()
473 .msg_type(MessageType::Signal)
474 .interface(PORTAL_REQUEST_INTERFACE)?
475 .member("Response")?
476 .path_namespace(PORTAL_REQUEST_PATH_NAMESPACE)?
477 .build();
478
479 MessageStream::for_match_rule(response_rule, connection, None)
480 .await
481 .context("failed to subscribe to XDG portal screenshot responses")
482}
483
484async fn wait_for_portal_response(
485 response_stream: &mut MessageStream,
486 request_path: &str,
487) -> Result<(u32, HashMap<String, OwnedValue>)> {
488 loop {
489 let response = response_stream
490 .next()
491 .await
492 .context("XDG portal screenshot response stream ended")?
493 .context("XDG portal screenshot response stream failed")?;
494
495 if !portal_response_matches_path(&response, request_path) {
496 continue;
497 }
498
499 return response
500 .body()
501 .deserialize()
502 .context("failed to decode XDG portal screenshot response");
503 }
504}
505
506fn portal_response_matches_path(response: &Message, request_path: &str) -> bool {
507 response
508 .header()
509 .path()
510 .is_some_and(|path| path.as_str() == request_path)
511}
512
513async fn read_png_as_capture(
514 path: PathBuf,
515 source: &str,
516 cleanup: ScreenshotCleanup,
517) -> Result<RawScreenshotCapture> {
518 let result = read_png_as_capture_inner(&path, source);
519 if let ScreenshotCleanup::DeletePath(path) = cleanup {
520 let _ = fs::remove_file(path);
521 }
522 result
523}
524
525fn read_png_as_capture_inner(path: &Path, source: &str) -> Result<RawScreenshotCapture> {
526 let bytes = fs::read(path)
527 .with_context(|| format!("failed to read screenshot file {}", path.display()))?;
528 if bytes.is_empty() {
529 bail!("screenshot file was empty: {}", path.display());
530 }
531 let (width, height) = png_dimensions(&bytes)?;
532 Ok(RawScreenshotCapture {
533 mime_type: "image/png".to_string(),
534 bytes,
535 source: source.to_string(),
536 width,
537 height,
538 })
539}
540
541fn target_dimensions(
542 width: u32,
543 height: u32,
544 options: ResolvedScreenshotPayloadOptions,
545) -> (u32, u32) {
546 let width_scale = options.max_width as f64 / width as f64;
547 let height_scale = options.max_height as f64 / height as f64;
548 let scale = f64::from(options.scale)
549 .min(width_scale)
550 .min(height_scale)
551 .min(1.0);
552
553 let target_width = ((width as f64 * scale).round() as u32).clamp(1, width);
554 let target_height = ((height as f64 * scale).round() as u32).clamp(1, height);
555 (target_width, target_height)
556}
557
558fn encode_screenshot_to_fit_bytes(
559 raw: &[u8],
560 original_width: u32,
561 original_height: u32,
562 mut target_width: u32,
563 mut target_height: u32,
564 options: ResolvedScreenshotPayloadOptions,
565) -> Result<(Vec<u8>, u32, u32)> {
566 let img = image::load_from_memory_with_format(raw, image::ImageFormat::Png)
567 .context("failed to decode screenshot PNG for encoding")?;
568
569 loop {
570 let bytes = if options.format == ScreenshotOutputFormat::Png
571 && target_width == original_width
572 && target_height == original_height
573 {
574 raw.to_vec()
575 } else {
576 let output = if target_width == original_width && target_height == original_height {
577 img.clone()
578 } else {
579 img.resize_exact(target_width, target_height, FilterType::Lanczos3)
580 };
581 encode_image(&output, options)?
582 };
583
584 if bytes.len() <= options.max_bytes {
585 return Ok((bytes, target_width, target_height));
586 }
587
588 if target_width == 1 && target_height == 1 {
589 bail!(
590 "screenshot payload is {} bytes at 1x1, over max_bytes {}",
591 bytes.len(),
592 options.max_bytes
593 );
594 }
595
596 (target_width, target_height) = next_dimensions_for_byte_cap(
597 target_width,
598 target_height,
599 bytes.len(),
600 options.max_bytes,
601 );
602 }
603}
604
605fn encode_image(
606 img: &image::DynamicImage,
607 options: ResolvedScreenshotPayloadOptions,
608) -> Result<Vec<u8>> {
609 let mut out = Vec::new();
610 match options.format {
611 ScreenshotOutputFormat::Png => {
612 img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
613 .context("failed to encode screenshot PNG")?;
614 }
615 ScreenshotOutputFormat::Jpeg => {
616 let rgb = img.to_rgb8();
617 JpegEncoder::new_with_quality(&mut out, options.quality)
618 .encode_image(&rgb)
619 .context("failed to encode screenshot JPEG")?;
620 }
621 }
622 Ok(out)
623}
624
625fn next_dimensions_for_byte_cap(
626 width: u32,
627 height: u32,
628 encoded_bytes: usize,
629 max_bytes: usize,
630) -> (u32, u32) {
631 let shrink = ((max_bytes as f64 / encoded_bytes as f64).sqrt() * 0.9).clamp(0.1, 0.95);
632 let mut next_width = ((width as f64 * shrink).floor() as u32).max(1);
633 let mut next_height = ((height as f64 * shrink).floor() as u32).max(1);
634
635 if next_width >= width && width > 1 {
636 next_width = width - 1;
637 }
638 if next_height >= height && height > 1 {
639 next_height = height - 1;
640 }
641
642 (next_width, next_height)
643}
644
645fn cleanup_gnome_requested_path(path: &Path) {
646 let _ = fs::remove_file(path);
647}
648
649fn png_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
650 const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
651 if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE || &bytes[12..16] != b"IHDR" {
652 bail!("screenshot file was not a valid PNG");
653 }
654 let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
655 let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
656 if width == 0 || height == 0 {
657 bail!("screenshot PNG had invalid dimensions {width}x{height}");
658 }
659 Ok((width, height))
660}
661
662fn file_uri_to_path(uri: &str) -> Result<PathBuf> {
663 let Some(rest) = uri.strip_prefix("file://") else {
664 bail!("unsupported screenshot uri: {uri}");
665 };
666 Ok(PathBuf::from(percent_decode(rest)))
667}
668
669fn percent_decode(value: &str) -> String {
670 let bytes = value.as_bytes();
671 let mut decoded = Vec::with_capacity(bytes.len());
672 let mut index = 0;
673
674 while index < bytes.len() {
675 if bytes[index] == b'%' && index + 2 < bytes.len() {
676 if let Ok(hex) = std::str::from_utf8(&bytes[index + 1..index + 3]) {
677 if let Ok(byte) = u8::from_str_radix(hex, 16) {
678 decoded.push(byte);
679 index += 3;
680 continue;
681 }
682 }
683 }
684
685 decoded.push(bytes[index]);
686 index += 1;
687 }
688
689 String::from_utf8_lossy(&decoded).into_owned()
690}
691
692fn temp_png_path(source: &str) -> PathBuf {
693 std::env::temp_dir().join(format!(
694 "computer-use-linux-{source}-{}.png",
695 unique_suffix()
696 ))
697}
698
699fn request_token() -> String {
700 format!("computer_use_linux_{}", unique_suffix().replace('-', "_"))
701}
702
703fn unique_suffix() -> String {
704 let nanos = SystemTime::now()
705 .duration_since(UNIX_EPOCH)
706 .map(|duration| duration.as_nanos())
707 .unwrap_or_default();
708 format!("{}-{nanos}", std::process::id())
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 fn test_path(name: &str) -> PathBuf {
716 std::env::temp_dir().join(format!(
717 "computer-use-linux-screenshot-test-{name}-{}",
718 unique_suffix()
719 ))
720 }
721
722 fn valid_png(width: u32, height: u32) -> Vec<u8> {
723 let mut png = Vec::new();
724 png.extend_from_slice(b"\x89PNG\r\n\x1a\n");
725 png.extend_from_slice(&13_u32.to_be_bytes());
726 png.extend_from_slice(b"IHDR");
727 png.extend_from_slice(&width.to_be_bytes());
728 png.extend_from_slice(&height.to_be_bytes());
729 png.extend_from_slice(&[8, 6, 0, 0, 0]);
730 png
731 }
732
733 fn solid_png(width: u32, height: u32) -> Vec<u8> {
734 let img = image::RgbaImage::from_pixel(width, height, image::Rgba([24, 96, 160, 255]));
735 encode_test_png(img)
736 }
737
738 fn noisy_png(width: u32, height: u32) -> Vec<u8> {
739 let mut img = image::RgbaImage::new(width, height);
740 for (x, y, pixel) in img.enumerate_pixels_mut() {
741 let r = ((x * 31 + y * 17) % 256) as u8;
742 let g = ((x * 13 + y * 47) % 256) as u8;
743 let b = ((x * 97 + y * 7) % 256) as u8;
744 *pixel = image::Rgba([r, g, b, 255]);
745 }
746 encode_test_png(img)
747 }
748
749 fn encode_test_png(img: image::RgbaImage) -> Vec<u8> {
750 let mut out = Vec::new();
751 image::DynamicImage::ImageRgba8(img)
752 .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
753 .unwrap();
754 out
755 }
756
757 fn raw_capture(bytes: Vec<u8>) -> RawScreenshotCapture {
758 let (width, height) = png_dimensions(&bytes).unwrap();
759 RawScreenshotCapture {
760 mime_type: "image/png".to_string(),
761 bytes,
762 source: "test".to_string(),
763 width,
764 height,
765 }
766 }
767
768 #[test]
769 fn decodes_file_uri_percent_escapes() {
770 assert_eq!(
771 file_uri_to_path("file:///tmp/Codex%20Screenshot.png").unwrap(),
772 PathBuf::from("/tmp/Codex Screenshot.png")
773 );
774 }
775
776 #[test]
777 fn parses_known_backend_names() {
778 assert_eq!(
779 ScreenshotBackend::parse("gnome-shell"),
780 Some(ScreenshotBackend::GnomeShell)
781 );
782 assert_eq!(
783 ScreenshotBackend::parse(" Portal "),
784 Some(ScreenshotBackend::Portal)
785 );
786 assert_eq!(
787 ScreenshotBackend::parse("GNOME_SCREENSHOT"),
788 Some(ScreenshotBackend::GnomeScreenshot)
789 );
790 assert_eq!(
791 ScreenshotBackend::parse("x11"),
792 Some(ScreenshotBackend::X11)
793 );
794 assert_eq!(
795 ScreenshotBackend::parse(" X11-Native "),
796 Some(ScreenshotBackend::X11)
797 );
798 assert_eq!(ScreenshotBackend::parse("nonsense"), None);
799 }
800
801 #[test]
802 fn forced_backend_reads_env_override() {
803 std::env::set_var(SCREENSHOT_BACKEND_ENV, "gnome-screenshot");
805 assert_eq!(
806 forced_backend().unwrap(),
807 Some(ScreenshotBackend::GnomeScreenshot)
808 );
809
810 std::env::set_var(SCREENSHOT_BACKEND_ENV, " ");
811 assert_eq!(forced_backend().unwrap(), None);
812
813 std::env::set_var(SCREENSHOT_BACKEND_ENV, "bogus");
814 let error = forced_backend().unwrap_err();
815 assert!(error.to_string().contains("not a recognized backend"));
816
817 std::env::remove_var(SCREENSHOT_BACKEND_ENV);
818 assert_eq!(forced_backend().unwrap(), None);
819 }
820
821 #[test]
822 fn request_token_is_portal_safe() {
823 let token = request_token();
824 assert!(token.starts_with("computer_use_linux_"));
825 assert!(token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
826 }
827
828 #[test]
829 fn reads_png_dimensions_from_ihdr() {
830 let png = valid_png(3840, 1080);
831
832 assert_eq!(png_dimensions(&png).unwrap(), (3840, 1080));
833 }
834
835 #[test]
836 fn default_payload_downscales_long_edge() {
837 let capture =
838 prepare_screenshot_payload(raw_capture(solid_png(4000, 1000)), Default::default())
839 .unwrap();
840
841 assert_eq!((capture.width, capture.height), (1920, 480));
842 assert_eq!(
843 (capture.coordinate_width, capture.coordinate_height),
844 (4000, 1000)
845 );
846 assert!(capture.resized);
847 assert!(capture.bytes <= DEFAULT_SCREENSHOT_MAX_BYTES);
848 assert!(capture.data_url.starts_with("data:image/png;base64,"));
849 }
850
851 #[test]
852 fn larger_bounded_request_can_keep_more_detail() {
853 let capture = prepare_screenshot_payload(
854 raw_capture(solid_png(3000, 1000)),
855 ScreenshotPayloadOptions {
856 max_width: Some(3000),
857 max_height: Some(3000),
858 max_bytes: Some(DEFAULT_SCREENSHOT_MAX_BYTES),
859 ..Default::default()
860 },
861 )
862 .unwrap();
863
864 assert_eq!((capture.width, capture.height), (3000, 1000));
865 assert_eq!(
866 (capture.coordinate_width, capture.coordinate_height),
867 (3000, 1000)
868 );
869 assert!(!capture.resized);
870 }
871
872 #[test]
873 fn byte_cap_downscales_until_payload_fits() {
874 let capture = prepare_screenshot_payload(
875 raw_capture(noisy_png(512, 512)),
876 ScreenshotPayloadOptions {
877 max_width: Some(512),
878 max_height: Some(512),
879 max_bytes: Some(20_000),
880 ..Default::default()
881 },
882 )
883 .unwrap();
884
885 assert!(capture.bytes <= 20_000);
886 assert!(capture.width < 512);
887 assert_eq!(
888 (capture.coordinate_width, capture.coordinate_height),
889 (512, 512)
890 );
891 assert!(capture.resized);
892 }
893
894 #[test]
895 fn jpeg_format_compresses_when_requested() {
896 let capture = prepare_screenshot_payload(
897 raw_capture(noisy_png(512, 512)),
898 ScreenshotPayloadOptions {
899 max_width: Some(512),
900 max_height: Some(512),
901 max_bytes: Some(DEFAULT_SCREENSHOT_MAX_BYTES),
902 format: Some(ScreenshotOutputFormat::Jpeg),
903 quality: Some(60),
904 ..Default::default()
905 },
906 )
907 .unwrap();
908
909 assert_eq!(capture.mime_type, "image/jpeg");
910 assert_eq!(capture.format, ScreenshotOutputFormat::Jpeg);
911 assert_eq!(capture.quality, Some(60));
912 assert_eq!((capture.width, capture.height), (512, 512));
913 assert_eq!(
914 (capture.coordinate_width, capture.coordinate_height),
915 (512, 512)
916 );
917 assert!(capture.bytes < capture.original_bytes);
918 assert!(capture.data_url.starts_with("data:image/jpeg;base64,"));
919 }
920
921 #[tokio::test]
922 async fn portal_capture_preserves_valid_returned_path() {
923 let path = test_path("portal-valid");
924 fs::write(&path, valid_png(1, 1)).unwrap();
925
926 let capture = read_png_as_capture(
927 path.clone(),
928 "xdg-desktop-portal",
929 ScreenshotCleanup::Preserve,
930 )
931 .await
932 .unwrap();
933
934 assert_eq!(capture.source, "xdg-desktop-portal");
935 assert!(path.exists());
936 let _ = fs::remove_file(path);
937 }
938
939 #[tokio::test]
940 async fn portal_capture_preserves_invalid_returned_path() {
941 let path = test_path("portal-invalid");
942 fs::write(&path, b"").unwrap();
943
944 let error = read_png_as_capture(
945 path.clone(),
946 "xdg-desktop-portal",
947 ScreenshotCleanup::Preserve,
948 )
949 .await
950 .unwrap_err();
951
952 assert!(error.to_string().contains("screenshot file was empty"));
953 assert!(path.exists());
954 let _ = fs::remove_file(path);
955 }
956
957 #[tokio::test]
958 async fn gnome_capture_deletes_backend_temp_path_on_success() {
959 let path = test_path("gnome-valid");
960 fs::write(&path, valid_png(1, 1)).unwrap();
961
962 let capture = read_png_as_capture(
963 path.clone(),
964 "gnome-shell",
965 ScreenshotCleanup::DeletePath(path.clone()),
966 )
967 .await
968 .unwrap();
969
970 assert_eq!(capture.source, "gnome-shell");
971 assert!(!path.exists());
972 }
973
974 #[tokio::test]
975 async fn gnome_capture_deletes_backend_temp_path_on_parse_failure() {
976 let path = test_path("gnome-invalid");
977 fs::write(&path, b"").unwrap();
978
979 let error = read_png_as_capture(
980 path.clone(),
981 "gnome-shell",
982 ScreenshotCleanup::DeletePath(path.clone()),
983 )
984 .await
985 .unwrap_err();
986
987 assert!(error.to_string().contains("screenshot file was empty"));
988 assert!(!path.exists());
989 }
990
991 #[test]
992 fn gnome_failure_cleanup_removes_requested_temp_path() {
993 let path = test_path("gnome-pre-read-failure");
994 fs::write(&path, b"partial").unwrap();
995
996 cleanup_gnome_requested_path(&path);
997
998 assert!(!path.exists());
999 }
1000
1001 #[tokio::test]
1002 async fn gnome_deletes_requested_temp_path_and_preserves_unexpected_returned_path() {
1003 let requested = test_path("gnome-requested");
1004 let returned = test_path("gnome-returned");
1005 fs::write(&requested, b"partial").unwrap();
1006 fs::write(&returned, valid_png(1, 1)).unwrap();
1007
1008 let capture = read_png_as_capture(
1009 returned.clone(),
1010 "gnome-shell",
1011 ScreenshotCleanup::DeletePath(requested.clone()),
1012 )
1013 .await
1014 .unwrap();
1015
1016 assert_eq!(capture.source, "gnome-shell");
1017 assert!(!requested.exists());
1018 assert!(returned.exists());
1019 let _ = fs::remove_file(returned);
1020 }
1021}