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