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