Skip to main content

computer_use_linux/
screenshot.rs

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    /// Width of the returned image payload.
52    pub width: u32,
53    /// Height of the returned image payload.
54    pub height: u32,
55    /// Coordinate-space width before payload downscaling.
56    pub coordinate_width: u32,
57    /// Coordinate-space height before payload downscaling.
58    pub coordinate_height: u32,
59    /// Returned pixels per coordinate-space pixel.
60    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
147/// Environment variable forcing a single capture backend, skipping the
148/// fallback chain. Accepts `gnome-shell`, `portal`, `x11`, or `gnome-screenshot`.
149const 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    // Explicit override: use exactly the requested backend, no fallback. Lets
184    // background/systemd contexts pin `gnome-screenshot` when the DBus paths are
185    // blocked, and aids debugging.
186    if let Some(forced) = forced_backend()? {
187        return forced.capture().await;
188    }
189
190    // The Shell and portal DBus paths fail for background processes (systemd
191    // user services, non-interactive parent shells): GNOME Shell's
192    // DBusSenderChecker rejects unknown bus names, and the portal cancels with
193    // response code 2 when there is no foreground window. `gnome-screenshot`
194    // claims an allowlisted bus name and works regardless, so it is the final
195    // fallback. See issue #20.
196    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    // Native X11 only, and ahead of gnome-screenshot: gnome-screenshot 41 masks
205    // everything outside the GDK monitor geometry, which is 1/4 of the frame at
206    // window-scaling-factor 2 on MATE (issue #155). GetImage has no GDK layer.
207    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
224/// `GetImage` on the root window of a native X11 session. Pixels are device
225/// pixels, the space xdotool/XTEST input and X11 window origins use.
226async 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    // Some portals rewrite the request handle, so subscribe before calling Screenshot
373    // and filter by the returned handle instead of subscribing after the call.
374    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
416/// Upper bound on how long we wait for `gnome-screenshot` before killing it.
417/// Matches the portal timeout: a hung capture must not block the tool forever.
418const 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    // `-f <file>` writes a full-screen PNG without prompting; no portal, no
427    // foreground window required. `tokio::process::Command` searches PATH and
428    // provides an async, non-polling wait.
429    let mut child = match Command::new("gnome-screenshot")
430        .args(["-f", filename])
431        .stdin(Stdio::null())
432        .stdout(Stdio::null())
433        .stderr(Stdio::null())
434        .spawn()
435    {
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    // A hung capture must not block the tool forever, so bound the wait and
444    // kill the child if it outlives the deadline.
445    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        // Only this test touches SCREENSHOT_BACKEND_ENV, so no cross-test race.
804        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}