Skip to main content

computer_use_linux/
screenshot.rs

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    /// Width of the returned image payload.
51    pub width: u32,
52    /// Height of the returned image payload.
53    pub height: u32,
54    /// Coordinate-space width before payload downscaling.
55    pub coordinate_width: u32,
56    /// Coordinate-space height before payload downscaling.
57    pub coordinate_height: u32,
58    /// Returned pixels per coordinate-space pixel.
59    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
146/// Environment variable forcing a single capture backend, skipping the
147/// fallback chain. Accepts `gnome-shell`, `portal`, or `gnome-screenshot`.
148const 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    // Explicit override: use exactly the requested backend, no fallback. Lets
180    // background/systemd contexts pin `gnome-screenshot` when the DBus paths are
181    // blocked, and aids debugging.
182    if let Some(forced) = forced_backend()? {
183        return forced.capture().await;
184    }
185
186    // The Shell and portal DBus paths fail for background processes (systemd
187    // user services, non-interactive parent shells): GNOME Shell's
188    // DBusSenderChecker rejects unknown bus names, and the portal cancels with
189    // response code 2 when there is no foreground window. `gnome-screenshot`
190    // claims an allowlisted bus name and works regardless, so it is the final
191    // fallback. See issue #20.
192    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    // Some portals rewrite the request handle, so subscribe before calling Screenshot
330    // and filter by the returned handle instead of subscribing after the call.
331    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
373/// Upper bound on how long we wait for `gnome-screenshot` before killing it.
374/// Matches the portal timeout: a hung capture must not block the tool forever.
375const 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    // `-f <file>` writes a full-screen PNG without prompting; no portal, no
384    // foreground window required. `tokio::process::Command` searches PATH and
385    // provides an async, non-polling wait.
386    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    // A hung capture must not block the tool forever, so bound the wait and
401    // kill the child if it outlives the deadline.
402    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        // Only this test touches SCREENSHOT_BACKEND_ENV, so no cross-test race.
753        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}