Skip to main content

mermaid_cli/providers/tool/computer_use/
driver.rs

1//! `ComputerUseDriver` — the shared backend-dispatch layer for the
2//! seven computer-use tools.
3//!
4//! The driver wraps three things:
5//!
6//!   1. A `Backend` discriminant (`X11`, `Wayland`, `MacOS`, …). Tools
7//!      match on it to pick the right subprocess dispatch.
8//!   2. A bounded `ScreenshotRegistry`. Every capture gets a stable
9//!      `id`; the model includes that id on later `click(x, y,
10//!      screenshot_id)` so coordinate translation uses the right
11//!      scale+offset even if the newest screenshot has shifted the
12//!      "latest" entry.
13//!   3. `ensure_alive()` — a cheap re-probe called at the top of every
14//!      tool's `execute`. Catches the case where the display went
15//!      away between registration and invocation (detached SSH,
16//!      closed lid).
17//!
18//! Subprocess dispatch uses `tokio::process::Command` so each external
19//! binary can race against `ctx.token.cancelled()`. `kill_on_drop(true)`
20//! reaps children whose parent future gets cancelled.
21
22use std::collections::VecDeque;
23use std::path::PathBuf;
24use std::sync::Mutex;
25use std::sync::atomic::{AtomicU64, Ordering};
26
27use anyhow::{Context, Result};
28use base64::{Engine as _, engine::general_purpose};
29use tokio::process::Command;
30use tokio_util::sync::CancellationToken;
31
32use crate::constants::{SCREENSHOT_MAX_WIDTH, SCREENSHOT_REGISTRY_CAPACITY};
33
34use super::Backend;
35
36/// Per-capture metadata retained so subsequent clicks can translate
37/// model-space coords back to screen-space.
38#[derive(Debug, Clone)]
39pub struct ScreenshotMetadata {
40    pub id: u64,
41    pub scale_factor: f64,
42    pub offset_x: i32,
43    pub offset_y: i32,
44    /// Displayed (post-downscale) pixel dimensions — the exact frame the model
45    /// reasoned over. `scale_coords` clamps model-supplied coords to
46    /// `[0, width) × [0, height)` before translating (#96); a value of `0`
47    /// means the PNG header was unreadable, so the upper clamp is skipped.
48    pub width: u32,
49    pub height: u32,
50    /// Human-readable capture kind (`"fullscreen"`, `"focused window"`,
51    /// …). Surfaced in error messages if the model references an
52    /// evicted id.
53    pub kind: String,
54}
55
56/// Bounded ring buffer of recent screenshot metadata. Capacity from
57/// `constants::SCREENSHOT_REGISTRY_CAPACITY` (= 16). When full, push
58/// evicts the oldest; referencing an evicted id fails cleanly with
59/// "take a fresh screenshot" rather than silently clicking at wrong
60/// coordinates.
61#[derive(Debug, Default)]
62pub struct ScreenshotRegistry {
63    entries: VecDeque<ScreenshotMetadata>,
64}
65
66impl ScreenshotRegistry {
67    pub fn new() -> Self {
68        Self {
69            entries: VecDeque::new(),
70        }
71    }
72
73    pub fn push(&mut self, meta: ScreenshotMetadata) {
74        if self.entries.len() >= SCREENSHOT_REGISTRY_CAPACITY {
75            self.entries.pop_front();
76        }
77        self.entries.push_back(meta);
78    }
79
80    pub fn get(&self, id: u64) -> Option<&ScreenshotMetadata> {
81        self.entries.iter().find(|m| m.id == id)
82    }
83
84    pub fn latest(&self) -> Option<&ScreenshotMetadata> {
85        self.entries.back()
86    }
87
88    pub fn len(&self) -> usize {
89        self.entries.len()
90    }
91
92    pub fn is_empty(&self) -> bool {
93        self.entries.is_empty()
94    }
95}
96
97/// What the screenshot tool accepts: which slice of the display to
98/// capture.
99#[derive(Debug, Clone)]
100pub enum ScreenshotSpec {
101    Fullscreen,
102    Focused,
103    Monitor(String),
104    /// `(x, y, width, height)` in screen pixels.
105    Region(i32, i32, u32, u32),
106    Window(String),
107}
108
109/// Result of a capture: encoded bytes + registry id + a human-readable
110/// summary for the tool's `output` field.
111#[derive(Debug)]
112pub struct CaptureResult {
113    pub id: u64,
114    pub base64_png: String,
115    pub raw_bytes: Vec<u8>,
116    pub width: u32,
117    pub height: u32,
118    pub scale_factor: f64,
119    pub offset_x: i32,
120    pub offset_y: i32,
121    pub summary: String,
122}
123
124/// Shared driver all seven computer-use tools hold an `Arc<>` to.
125pub struct ComputerUseDriver {
126    backend: Backend,
127    registry: Mutex<ScreenshotRegistry>,
128    /// Monotonic counter for temp file uniqueness. Distinct from the
129    /// registry id counter so filenames don't collide across runs
130    /// that share temp dir.
131    file_counter: AtomicU64,
132    /// Monotonic counter for registry ids — stable across process
133    /// lifetime, survives evictions.
134    id_counter: AtomicU64,
135}
136
137impl ComputerUseDriver {
138    pub fn new(backend: Backend) -> Self {
139        Self {
140            backend,
141            registry: Mutex::new(ScreenshotRegistry::new()),
142            file_counter: AtomicU64::new(0),
143            id_counter: AtomicU64::new(0),
144        }
145    }
146
147    pub fn backend(&self) -> Backend {
148        self.backend
149    }
150
151    /// Cheap mid-call liveness check. Tools call this first inside
152    /// `execute()`; if the display went away after registration,
153    /// they return a clean error instead of hanging on subprocess
154    /// dispatch.
155    pub fn ensure_alive(&self) -> Result<(), String> {
156        if super::display_is_reachable(self.backend) {
157            Ok(())
158        } else {
159            Err(format!(
160                "Display unreachable (backend={:?}). Was the session \
161                 detached, or did `DISPLAY` change?",
162                self.backend
163            ))
164        }
165    }
166
167    /// Async form of [`ensure_alive`]. The X11 display probe spawns a
168    /// `xdpyinfo`/`xdotool` subprocess and blocks on its exit; on the async
169    /// tool path that would block a worker, so run it on the blocking pool (#34).
170    pub async fn ensure_alive_async(&self) -> Result<(), String> {
171        let backend = self.backend;
172        match tokio::task::spawn_blocking(move || super::display_is_reachable(backend)).await {
173            Ok(true) => Ok(()),
174            Ok(false) => Err(format!(
175                "Display unreachable (backend={:?}). Was the session \
176                 detached, or did `DISPLAY` change?",
177                self.backend
178            )),
179            Err(_) => Err("display liveness probe failed to run".to_string()),
180        }
181    }
182
183    /// Translate model-space coords to screen-space using the metadata
184    /// registered for `screenshot_id` (or the latest if None).
185    pub fn scale_coords(
186        &self,
187        x: i32,
188        y: i32,
189        screenshot_id: Option<u64>,
190    ) -> Result<(i32, i32), String> {
191        let reg = self.registry.lock().map_err(|e| e.to_string())?;
192        let meta = match screenshot_id {
193            Some(id) => reg.get(id).cloned().ok_or_else(|| {
194                format!(
195                    "Screenshot id {} not found in registry (likely evicted — capacity {}). \
196                     Take a fresh screenshot and retry with the new id.",
197                    id, SCREENSHOT_REGISTRY_CAPACITY
198                )
199            })?,
200            None => reg.latest().cloned().ok_or_else(|| {
201                "No screenshots registered yet — call `screenshot` before \
202                 `click` / `mouse_move`."
203                    .to_string()
204            })?,
205        };
206        // #96: clamp the model-supplied coords into the frame's own pixel bounds
207        // before translating (skip the upper clamp when a dim is 0 = PNG header
208        // unreadable, so `clamp`'s min <= max precondition always holds).
209        let cx = if meta.width > 0 {
210            x.clamp(0, meta.width as i32 - 1)
211        } else {
212            x.max(0)
213        };
214        let cy = if meta.height > 0 {
215            y.clamp(0, meta.height as i32 - 1)
216        } else {
217            y.max(0)
218        };
219        // #32: the f64 -> i32 cast already saturates (NaN -> 0, ±huge ->
220        // i32::MIN/MAX); the `+ offset` i32 add is the real defect — it panics on
221        // overflow in debug and wraps to a negative coord in release.
222        // saturating_add mirrors the saturating_sub at driver.rs:452. The clamp
223        // above already bounds the result inside the region; this is
224        // defense-in-depth for extreme offsets.
225        Ok((
226            ((cx as f64 * meta.scale_factor) as i32).saturating_add(meta.offset_x),
227            ((cy as f64 * meta.scale_factor) as i32).saturating_add(meta.offset_y),
228        ))
229    }
230
231    /// Allocate a fresh registry id and record metadata.
232    pub fn register_screenshot(
233        &self,
234        scale_factor: f64,
235        offset_x: i32,
236        offset_y: i32,
237        width: u32,
238        height: u32,
239        kind: String,
240    ) -> u64 {
241        let id = self.id_counter.fetch_add(1, Ordering::Relaxed);
242        if let Ok(mut reg) = self.registry.lock() {
243            reg.push(ScreenshotMetadata {
244                id,
245                scale_factor,
246                offset_x,
247                offset_y,
248                width,
249                height,
250                kind,
251            });
252        }
253        id
254    }
255
256    /// Capture and return the encoded result. Respects cancellation
257    /// via `token.cancelled()` races in the subprocess wait.
258    pub async fn capture(
259        &self,
260        spec: ScreenshotSpec,
261        token: &CancellationToken,
262    ) -> Result<CaptureResult> {
263        self.ensure_alive_async()
264            .await
265            .map_err(|error| anyhow::anyhow!(error))?;
266
267        let seq = self.file_counter.fetch_add(1, Ordering::Relaxed);
268        // Write screenshots into the 0700 per-user scratch dir, not a
269        // world-readable fixed path in shared /tmp where another local user
270        // could read the captured frame (#33).
271        let temp_path =
272            crate::utils::private_temp_dir()?.join(format!("mermaid-screenshot-{}.png", seq));
273        let temp_str = temp_path.to_string_lossy().to_string();
274        let _guard = TempFileGuard(temp_path.clone());
275
276        let (offset_x, offset_y, kind) =
277            dispatch_capture(self.backend, &spec, &temp_str, token).await?;
278
279        let scale_factor = downscale_if_needed(&temp_str, SCREENSHOT_MAX_WIDTH).await?;
280
281        let raw_bytes = tokio::fs::read(&temp_path)
282            .await
283            .context("reading captured screenshot")?;
284        let width = read_png_width(&raw_bytes).unwrap_or(0);
285        let height = read_png_height(&raw_bytes).unwrap_or(0);
286
287        // Register AFTER dims are known so `scale_coords` can clamp model coords
288        // to this frame's pixel bounds (#96).
289        let id = self.register_screenshot(
290            scale_factor,
291            offset_x,
292            offset_y,
293            width,
294            height,
295            kind.clone(),
296        );
297
298        let base64_png = general_purpose::STANDARD.encode(&raw_bytes);
299
300        let offset_info = if offset_x != 0 || offset_y != 0 {
301            format!(", offset: +{}+{}", offset_x, offset_y)
302        } else {
303            String::new()
304        };
305        let summary = format!(
306            "Screenshot captured (id: {}, {}, {}x{}, scale: {:.2}x{})",
307            id, kind, width, height, scale_factor, offset_info
308        );
309
310        Ok(CaptureResult {
311            id,
312            base64_png,
313            raw_bytes,
314            width,
315            height,
316            scale_factor,
317            offset_x,
318            offset_y,
319            summary,
320        })
321    }
322
323    /// Convenience for click/type/key tools: capture the focused
324    /// window and return `(summary, base64_png)` for inclusion in
325    /// the tool's auto-screenshot. Best-effort — on error returns
326    /// `None` and the caller can fall back to a screenshot-less
327    /// outcome.
328    pub async fn capture_focused_for_autoshot(
329        &self,
330        token: &CancellationToken,
331    ) -> Option<(String, String)> {
332        let cap = self.capture(ScreenshotSpec::Focused, token).await.ok()?;
333        Some((cap.summary, cap.base64_png))
334    }
335
336    /// X11-only: verify the cursor actually landed where xdotool was
337    /// told to move it. Returns `Some(warning)` if the cursor ended
338    /// up more than `CURSOR_LANDED_TOLERANCE_PX` away (focus change,
339    /// window moved, WM rejected the move). `None` if within
340    /// tolerance or the probe itself failed (best-effort — never
341    /// blocks the click).
342    pub async fn check_cursor_landed(&self, sx: i32, sy: i32) -> Option<String> {
343        if !matches!(self.backend, Backend::X11) {
344            return None;
345        }
346        let out = run_cmd_stdout(Command::new("xdotool").arg("getmouselocation"))
347            .await
348            .ok()?;
349        let mut actual_x: Option<i32> = None;
350        let mut actual_y: Option<i32> = None;
351        for tok in out.split_whitespace() {
352            if let Some(v) = tok.strip_prefix("X:") {
353                actual_x = v.parse().ok();
354            } else if let Some(v) = tok.strip_prefix("Y:") {
355                actual_y = v.parse().ok();
356            }
357        }
358        let (ax, ay) = (actual_x?, actual_y?);
359        if (ax - sx).abs() > CURSOR_LANDED_TOLERANCE_PX
360            || (ay - sy).abs() > CURSOR_LANDED_TOLERANCE_PX
361        {
362            Some(format!(
363                "WARNING: cursor at ({}, {}), expected ({}, {}). Window may have moved \
364                 or focus changed before the click landed.",
365                ax, ay, sx, sy
366            ))
367        } else {
368            None
369        }
370    }
371}
372
373/// HiDPI fractional scaling can put the cursor a pixel or two off the
374/// exact target; >5px means something other than rounding is wrong.
375const CURSOR_LANDED_TOLERANCE_PX: i32 = 5;
376
377// ───── action dispatch (shared by click / type / key / scroll / move / list) ──
378
379impl ComputerUseDriver {
380    /// Click at the given SCREEN coordinates (already scaled by
381    /// `scale_coords`). `button` is `"left" | "middle" | "right"`.
382    pub async fn click(
383        &self,
384        sx: i32,
385        sy: i32,
386        button: &str,
387        token: &CancellationToken,
388    ) -> Result<()> {
389        let code = match button {
390            "middle" => "2",
391            "right" => "3",
392            _ => "1",
393        };
394        match self.backend {
395            Backend::X11 => {
396                run_cmd_cancellable(
397                    Command::new("xdotool").args([
398                        "mousemove",
399                        "--sync",
400                        &sx.to_string(),
401                        &sy.to_string(),
402                        "click",
403                        "--clearmodifiers",
404                        code,
405                    ]),
406                    token,
407                )
408                .await
409            },
410            Backend::Wayland => {
411                if !super::has_command("ydotool") {
412                    anyhow::bail!("ydotool required for Wayland mouse control")
413                }
414                run_cmd_cancellable(
415                    Command::new("ydotool").args([
416                        "mousemove",
417                        "--absolute",
418                        "-x",
419                        &sx.to_string(),
420                        "-y",
421                        &sy.to_string(),
422                    ]),
423                    token,
424                )
425                .await?;
426                run_cmd_cancellable(
427                    Command::new("ydotool").args(["click", &format!("0x{}", code)]),
428                    token,
429                )
430                .await
431            },
432            _ => anyhow::bail!("click not supported on this platform"),
433        }
434    }
435
436    /// Type text at the current focus. Per-keystroke delay from
437    /// `TYPE_KEY_DELAY_MS` — empirically needed for slow Electron /
438    /// web targets that drop characters at lower rates.
439    pub async fn type_text(&self, text: &str, token: &CancellationToken) -> Result<()> {
440        let delay = crate::constants::TYPE_KEY_DELAY_MS.to_string();
441        match self.backend {
442            Backend::X11 => {
443                run_cmd_cancellable(
444                    Command::new("xdotool").args([
445                        "type",
446                        "--clearmodifiers",
447                        "--delay",
448                        &delay,
449                        text,
450                    ]),
451                    token,
452                )
453                .await
454            },
455            Backend::Wayland => {
456                if super::has_command("wtype") {
457                    run_cmd_cancellable(Command::new("wtype").arg(text), token).await
458                } else if super::has_command("ydotool") {
459                    run_cmd_cancellable(
460                        Command::new("ydotool").args(["type", "--delay", &delay, text]),
461                        token,
462                    )
463                    .await
464                } else {
465                    anyhow::bail!("wtype or ydotool required for Wayland text input")
466                }
467            },
468            _ => anyhow::bail!("type_text not supported on this platform"),
469        }
470    }
471
472    /// Press a key (or key combination like `"ctrl+shift+t"`).
473    pub async fn press_key(&self, key: &str, token: &CancellationToken) -> Result<()> {
474        match self.backend {
475            Backend::X11 => {
476                run_cmd_cancellable(Command::new("xdotool").args(["key", key]), token).await
477            },
478            Backend::Wayland => {
479                if super::has_command("wtype") {
480                    // wtype: -M/-m modifiers around -k final key.
481                    let parts: Vec<&str> = key.split('+').collect();
482                    let mut args: Vec<String> = Vec::new();
483                    for (i, part) in parts.iter().enumerate() {
484                        if i < parts.len() - 1 {
485                            args.push("-M".to_string());
486                            args.push(part.to_string());
487                        } else {
488                            args.push("-k".to_string());
489                            args.push(part.to_string());
490                        }
491                    }
492                    for part in parts.iter().take(parts.len().saturating_sub(1)) {
493                        args.push("-m".to_string());
494                        args.push(part.to_string());
495                    }
496                    run_cmd_cancellable(Command::new("wtype").args(&args), token).await
497                } else if super::has_command("ydotool") {
498                    run_cmd_cancellable(Command::new("ydotool").args(["key", key]), token).await
499                } else {
500                    anyhow::bail!("wtype or ydotool required for Wayland key input")
501                }
502            },
503            _ => anyhow::bail!("press_key not supported on this platform"),
504        }
505    }
506
507    /// Scroll `amount` ticks in `direction` ("up" / "down").
508    pub async fn scroll(
509        &self,
510        direction: &str,
511        amount: i32,
512        token: &CancellationToken,
513    ) -> Result<()> {
514        match self.backend {
515            Backend::X11 => {
516                // xdotool: button 4 = scroll up, 5 = scroll down.
517                let button = if direction == "up" { "4" } else { "5" };
518                let mut args: Vec<String> = Vec::new();
519                for _ in 0..amount {
520                    args.push("click".to_string());
521                    args.push(button.to_string());
522                }
523                run_cmd_cancellable(Command::new("xdotool").args(&args), token).await
524            },
525            Backend::Wayland => {
526                if !super::has_command("ydotool") {
527                    anyhow::bail!("ydotool required for Wayland scroll")
528                }
529                let wheel_amount = if direction == "up" { -amount } else { amount };
530                run_cmd_cancellable(
531                    Command::new("ydotool").args([
532                        "mousemove",
533                        "--wheel",
534                        &wheel_amount.to_string(),
535                    ]),
536                    token,
537                )
538                .await
539            },
540            _ => anyhow::bail!("scroll not supported on this platform"),
541        }
542    }
543
544    /// Move the mouse cursor to SCREEN coords (already scaled).
545    pub async fn mouse_move(&self, sx: i32, sy: i32, token: &CancellationToken) -> Result<()> {
546        match self.backend {
547            Backend::X11 => {
548                run_cmd_cancellable(
549                    Command::new("xdotool").args([
550                        "mousemove",
551                        "--sync",
552                        &sx.to_string(),
553                        &sy.to_string(),
554                    ]),
555                    token,
556                )
557                .await
558            },
559            Backend::Wayland => {
560                if !super::has_command("ydotool") {
561                    anyhow::bail!("ydotool required for Wayland mouse control")
562                }
563                run_cmd_cancellable(
564                    Command::new("ydotool").args([
565                        "mousemove",
566                        "--absolute",
567                        "-x",
568                        &sx.to_string(),
569                        "-y",
570                        &sy.to_string(),
571                    ]),
572                    token,
573                )
574                .await
575            },
576            _ => anyhow::bail!("mouse_move not supported on this platform"),
577        }
578    }
579
580    /// List visible window titles. X11 only; Wayland has no portable
581    /// enumeration primitive.
582    pub async fn list_windows(&self, _token: &CancellationToken) -> Result<Vec<String>> {
583        if !matches!(self.backend, Backend::X11) {
584            anyhow::bail!(
585                "list_windows requires X11. Wayland has no portable window-enumeration \
586                 primitive. Run mermaid from an X11 session."
587            );
588        }
589        let wids =
590            run_cmd_stdout(Command::new("xdotool").args(["search", "--onlyvisible", "--name", ""]))
591                .await?;
592        let mut windows = Vec::new();
593        for wid in wids.lines() {
594            let wid = wid.trim();
595            if wid.is_empty() {
596                continue;
597            }
598            if let Ok(name) =
599                run_cmd_stdout(Command::new("xdotool").args(["getwindowname", wid])).await
600            {
601                let name = name.trim().to_string();
602                if !name.is_empty() && !windows.contains(&name) {
603                    windows.push(name);
604                }
605            }
606        }
607        Ok(windows)
608    }
609}
610
611// ───── RAII temp-file cleanup ──────────────────────────────────────
612
613struct TempFileGuard(PathBuf);
614
615impl Drop for TempFileGuard {
616    fn drop(&mut self) {
617        let _ = std::fs::remove_file(&self.0);
618    }
619}
620
621// ───── subprocess dispatch ─────────────────────────────────────────
622
623async fn dispatch_capture(
624    backend: Backend,
625    spec: &ScreenshotSpec,
626    out_path: &str,
627    token: &CancellationToken,
628) -> Result<(i32, i32, String)> {
629    // Returns (offset_x, offset_y, kind_label). Each branch `select!`s
630    // on `token.cancelled()` so Ctrl+C during a slow capture aborts
631    // the subprocess cleanly.
632    match (backend, spec) {
633        (Backend::X11, ScreenshotSpec::Fullscreen) => {
634            run_cmd_cancellable(Command::new("scrot").args(["-o", out_path]), token).await?;
635            Ok((0, 0, "fullscreen".to_string()))
636        },
637        (Backend::Wayland, ScreenshotSpec::Fullscreen) => {
638            run_cmd_cancellable(Command::new("grim").arg(out_path), token).await?;
639            Ok((0, 0, "fullscreen".to_string()))
640        },
641        (Backend::MacOS, ScreenshotSpec::Fullscreen) => {
642            run_cmd_cancellable(Command::new("screencapture").args(["-x", out_path]), token)
643                .await?;
644            Ok((0, 0, "fullscreen".to_string()))
645        },
646        (Backend::X11, ScreenshotSpec::Focused) => {
647            let (wx, wy) = get_focused_window_geometry_x11()
648                .await
649                .map(|(x, y, _, _)| (x, y))
650                .unwrap_or((0, 0));
651            run_cmd_cancellable(Command::new("scrot").args(["-u", "-o", out_path]), token).await?;
652            Ok((wx, wy, "focused window".to_string()))
653        },
654        (Backend::Wayland, ScreenshotSpec::Focused) => anyhow::bail!(
655            "Mode 'focused' not supported on Wayland (grim has no focused-window \
656             primitive). Use mode: 'fullscreen' or mode: 'monitor' with a specific \
657             output name."
658        ),
659        (Backend::MacOS, ScreenshotSpec::Focused) => {
660            // #100: `screencapture -W` captures only the focused window but
661            // reports no origin, so a click computed from the frame mis-targets
662            // whenever the window isn't at screen (0,0). Capture the full main
663            // display instead — the (0,0) offset is then genuinely correct and
664            // coords translate exactly. We deliberately avoid an AppleScript
665            // window-position probe: it returns points, but offsets here are in
666            // device pixels, so it'd be 2x off on a Retina display (a silent bug
667            // we can't catch on the Linux CI).
668            run_cmd_cancellable(Command::new("screencapture").args(["-x", out_path]), token)
669                .await?;
670            Ok((0, 0, "focused window (full display on macOS)".to_string()))
671        },
672        (Backend::X11, ScreenshotSpec::Region(x, y, w, h)) => {
673            run_cmd_cancellable(
674                Command::new("scrot").args([
675                    "-a",
676                    &format!("{},{},{},{}", x, y, w, h),
677                    "-o",
678                    out_path,
679                ]),
680                token,
681            )
682            .await?;
683            Ok((*x, *y, format!("region {}x{}+{}+{}", w, h, x, y)))
684        },
685        (Backend::Wayland, ScreenshotSpec::Region(x, y, w, h)) => {
686            run_cmd_cancellable(
687                Command::new("grim").args(["-g", &format!("{},{} {}x{}", x, y, w, h), out_path]),
688                token,
689            )
690            .await?;
691            Ok((*x, *y, format!("region {}x{}+{}+{}", w, h, x, y)))
692        },
693        (Backend::X11, ScreenshotSpec::Monitor(name)) => {
694            let (mx, my, mw, mh) = parse_monitor_geometry_x11(name).await.ok_or_else(|| {
695                anyhow::anyhow!(
696                    "Monitor '{}' not found. Run `xrandr --query` to list outputs.",
697                    name
698                )
699            })?;
700            run_cmd_cancellable(
701                Command::new("scrot").args([
702                    "-a",
703                    &format!("{},{},{},{}", mx, my, mw, mh),
704                    "-o",
705                    out_path,
706                ]),
707                token,
708            )
709            .await?;
710            Ok((mx, my, format!("monitor {}", name)))
711        },
712        (Backend::Wayland, ScreenshotSpec::Monitor(name)) => {
713            run_cmd_cancellable(Command::new("grim").args(["-o", name, out_path]), token).await?;
714            Ok((0, 0, format!("monitor {}", name)))
715        },
716        (Backend::X11, ScreenshotSpec::Window(title)) => {
717            // Search for window by name, activate it, sync, then
718            // capture the focused window.
719            let wid = run_cmd_stdout(Command::new("xdotool").args(["search", "--name", title]))
720                .await?
721                .lines()
722                .next()
723                .map(str::trim)
724                .filter(|s| !s.is_empty())
725                .map(str::to_string)
726                .ok_or_else(|| {
727                    anyhow::anyhow!(
728                        "No window found matching '{}'. Use list_windows to see available \
729                         windows.",
730                        title
731                    )
732                })?;
733            run_cmd_cancellable(
734                Command::new("xdotool").args(["windowactivate", "--sync", &wid]),
735                token,
736            )
737            .await?;
738            tokio::time::sleep(std::time::Duration::from_millis(
739                crate::constants::WINDOW_FOCUS_DELAY_MS,
740            ))
741            .await;
742            let (wx, wy) = get_window_geometry_x11(&wid)
743                .await
744                .map(|(x, y, _, _)| (x, y))
745                .unwrap_or((0, 0));
746            run_cmd_cancellable(Command::new("scrot").args(["-u", "-o", out_path]), token).await?;
747            Ok((wx, wy, format!("window \"{}\"", title)))
748        },
749        (Backend::Wayland, ScreenshotSpec::Window(_)) => anyhow::bail!(
750            "Mode 'window' not supported on Wayland (grim has no window-by-name capture). \
751             Use mode: 'fullscreen' or mode: 'monitor' with a specific output name."
752        ),
753        (Backend::MacOS, _) => anyhow::bail!(
754            "This screenshot mode is not yet ported to macOS. Use mode: 'fullscreen' for now."
755        ),
756        (Backend::Windows, _) | (Backend::Unsupported, _) => {
757            anyhow::bail!("Unsupported platform for computer-use capture")
758        },
759    }
760}
761
762/// Run a `Command` to completion, racing it against cancellation.
763/// Relies on `kill_on_drop(true)` reaping the child when the future
764/// is dropped on cancel.
765pub(crate) async fn run_cmd_cancellable(
766    cmd: &mut Command,
767    token: &CancellationToken,
768) -> Result<()> {
769    cmd.kill_on_drop(true);
770    tokio::select! {
771        biased;
772        _ = token.cancelled() => anyhow::bail!("cancelled"),
773        res = cmd.output() => {
774            let out = res.context("subprocess spawn")?;
775            if !out.status.success() {
776                anyhow::bail!(
777                    "subprocess failed: {}",
778                    String::from_utf8_lossy(&out.stderr).trim()
779                );
780            }
781            Ok(())
782        }
783    }
784}
785
786async fn run_cmd_stdout(cmd: &mut Command) -> Result<String> {
787    run_cmd_stdout_with_timeout(
788        cmd,
789        std::time::Duration::from_secs(crate::constants::COMPUTER_USE_CMD_TIMEOUT_SECS),
790    )
791    .await
792}
793
794/// `run_cmd_stdout` with an explicit cap (extracted so the timeout is testable
795/// with a tiny duration). `kill_on_drop(true)` reaps the child if the timeout
796/// fires and the `output()` future is dropped (#97).
797async fn run_cmd_stdout_with_timeout(
798    cmd: &mut Command,
799    timeout: std::time::Duration,
800) -> Result<String> {
801    cmd.kill_on_drop(true);
802    let out = match tokio::time::timeout(timeout, cmd.output()).await {
803        Ok(res) => res.context("subprocess spawn")?,
804        Err(_) => anyhow::bail!("subprocess timed out after {:?}", timeout),
805    };
806    if !out.status.success() {
807        anyhow::bail!(
808            "subprocess failed: {}",
809            String::from_utf8_lossy(&out.stderr).trim()
810        );
811    }
812    Ok(String::from_utf8_lossy(&out.stdout).to_string())
813}
814
815// ───── geometry helpers (X11 only; Wayland has no equivalent) ──────
816
817async fn get_focused_window_geometry_x11() -> Option<(i32, i32, u32, u32)> {
818    let wid = run_cmd_stdout(Command::new("xdotool").arg("getactivewindow"))
819        .await
820        .ok()?;
821    let wid = wid.trim();
822    if wid.is_empty() {
823        return None;
824    }
825    get_window_geometry_x11(wid).await
826}
827
828async fn get_window_geometry_x11(wid: &str) -> Option<(i32, i32, u32, u32)> {
829    let out = run_cmd_stdout(Command::new("xdotool").args(["getwindowgeometry", "--shell", wid]))
830        .await
831        .ok()?;
832    let mut x = None;
833    let mut y = None;
834    let mut width = None;
835    let mut height = None;
836    for line in out.lines() {
837        if let Some(v) = line.strip_prefix("X=") {
838            x = v.parse().ok();
839        } else if let Some(v) = line.strip_prefix("Y=") {
840            y = v.parse().ok();
841        } else if let Some(v) = line.strip_prefix("WIDTH=") {
842            width = v.parse().ok();
843        } else if let Some(v) = line.strip_prefix("HEIGHT=") {
844            height = v.parse().ok();
845        }
846    }
847    Some((x?, y?, width?, height?))
848}
849
850async fn parse_monitor_geometry_x11(name: &str) -> Option<(i32, i32, u32, u32)> {
851    let out = run_cmd_stdout(Command::new("xrandr").arg("--query"))
852        .await
853        .ok()?;
854    for line in out.lines() {
855        if !line.contains(" connected") {
856            continue;
857        }
858        let parts: Vec<&str> = line.split_whitespace().collect();
859        if parts.first() != Some(&name) {
860            continue;
861        }
862        for part in &parts[2..] {
863            if let Some((res, offsets)) = part.split_once('+')
864                && let Some((w, h)) = res.split_once('x')
865            {
866                let width = w.parse::<u32>().ok()?;
867                let height = h.parse::<u32>().ok()?;
868                let mut off = offsets.splitn(2, '+');
869                let x = off.next()?.parse::<i32>().ok()?;
870                let y = off.next()?.parse::<i32>().ok()?;
871                return Some((x, y, width, height));
872            }
873        }
874    }
875    None
876}
877
878// ───── PNG inspection (no image crate dep) ─────────────────────────
879
880fn read_png_width(bytes: &[u8]) -> Option<u32> {
881    if bytes.len() > 24 && &bytes[0..8] == b"\x89PNG\r\n\x1a\n" {
882        Some(u32::from_be_bytes([
883            bytes[16], bytes[17], bytes[18], bytes[19],
884        ]))
885    } else {
886        None
887    }
888}
889
890fn read_png_height(bytes: &[u8]) -> Option<u32> {
891    if bytes.len() > 28 && &bytes[0..8] == b"\x89PNG\r\n\x1a\n" {
892        Some(u32::from_be_bytes([
893            bytes[20], bytes[21], bytes[22], bytes[23],
894        ]))
895    } else {
896        None
897    }
898}
899
900/// Downscale the PNG at `path` to at most `max_width` pixels wide,
901/// using ImageMagick `convert` or ffmpeg as a fallback. Returns the
902/// scale factor (original_width / max_width; 1.0 if no scaling was
903/// needed).
904async fn downscale_if_needed(path: &str, max_width: u32) -> Result<f64> {
905    let bytes = tokio::fs::read(path).await?;
906    let original_width = read_png_width(&bytes).unwrap_or(1920);
907    if original_width <= max_width {
908        return Ok(1.0);
909    }
910    let scale_factor = original_width as f64 / max_width as f64;
911    let scaled = format!("{}.scaled.png", path);
912    // #97: time-box the encoders + kill_on_drop. The double `Ok(Ok(..))` means a
913    // timeout (outer Err) OR a spawn error (inner Err) falls through to the next
914    // encoder and finally to the full-resolution fallback below — preserving the
915    // existing graceful degradation rather than hanging the agent loop.
916    let downscale_timeout =
917        std::time::Duration::from_secs(crate::constants::SCREENSHOT_DOWNSCALE_TIMEOUT_SECS);
918
919    let convert = tokio::time::timeout(
920        downscale_timeout,
921        Command::new("convert")
922            .args([path, "-resize", &format!("{}x", max_width), &scaled])
923            .kill_on_drop(true)
924            .output(),
925    )
926    .await;
927    if let Ok(Ok(o)) = convert
928        && o.status.success()
929    {
930        tokio::fs::rename(&scaled, path).await?;
931        return Ok(scale_factor);
932    }
933
934    let ffmpeg = tokio::time::timeout(
935        downscale_timeout,
936        Command::new("ffmpeg")
937            .args([
938                "-y",
939                "-i",
940                path,
941                "-vf",
942                &format!("scale={}:-1", max_width),
943                &scaled,
944            ])
945            .kill_on_drop(true)
946            .output(),
947    )
948    .await;
949    if let Ok(Ok(o)) = ffmpeg
950        && o.status.success()
951    {
952        tokio::fs::rename(&scaled, path).await?;
953        return Ok(scale_factor);
954    }
955
956    let _ = tokio::fs::remove_file(&scaled).await;
957    tracing::warn!(
958        original_width,
959        "neither ImageMagick nor ffmpeg available; sending full-resolution screenshot"
960    );
961    Ok(1.0)
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    #[test]
969    fn registry_lru_evicts_oldest_past_capacity() {
970        let mut r = ScreenshotRegistry::new();
971        for i in 0..(SCREENSHOT_REGISTRY_CAPACITY as u64 + 3) {
972            r.push(ScreenshotMetadata {
973                id: i,
974                scale_factor: 1.0,
975                offset_x: 0,
976                offset_y: 0,
977                width: 0,
978                height: 0,
979                kind: "fullscreen".to_string(),
980            });
981        }
982        assert_eq!(r.len(), SCREENSHOT_REGISTRY_CAPACITY);
983        // First 3 should have been evicted.
984        assert!(r.get(0).is_none());
985        assert!(r.get(1).is_none());
986        assert!(r.get(2).is_none());
987        // Latest remains.
988        assert_eq!(
989            r.latest().unwrap().id,
990            SCREENSHOT_REGISTRY_CAPACITY as u64 + 2
991        );
992    }
993
994    #[test]
995    fn scale_coords_applies_scale_and_offset() {
996        let d = ComputerUseDriver::new(Backend::X11);
997        let id = d.register_screenshot(2.0, 100, 50, 640, 480, "fullscreen".to_string());
998        let (sx, sy) = d.scale_coords(10, 20, Some(id)).unwrap();
999        assert_eq!(sx, 100 + 20);
1000        assert_eq!(sy, 50 + 40);
1001    }
1002
1003    #[test]
1004    fn scale_coords_saturates_on_offset_overflow() {
1005        // #32: a huge model x + positive offset must not panic (debug) or wrap to
1006        // a negative coord (release). width/height = 0 disables the #96 upper
1007        // clamp so the value actually reaches the offset add.
1008        let d = ComputerUseDriver::new(Backend::X11);
1009        let id = d.register_screenshot(1.0, 100, 100, 0, 0, "fullscreen".to_string());
1010        let (sx, sy) = d.scale_coords(i32::MAX, i32::MAX, Some(id)).unwrap();
1011        assert_eq!(sx, i32::MAX);
1012        assert_eq!(sy, i32::MAX);
1013    }
1014
1015    #[test]
1016    fn scale_coords_clamps_negative_into_region() {
1017        // #96: negative model coords clamp to the region's top-left origin.
1018        let d = ComputerUseDriver::new(Backend::X11);
1019        let id = d.register_screenshot(2.0, 100, 50, 640, 480, "region".to_string());
1020        assert_eq!(d.scale_coords(-9999, -1, Some(id)).unwrap(), (100, 50));
1021    }
1022
1023    #[test]
1024    fn scale_coords_clamps_over_max_into_region() {
1025        // #96: coords past the frame clamp to the last in-frame pixel and stay
1026        // inside [offset, offset + region_dim).
1027        let d = ComputerUseDriver::new(Backend::X11);
1028        let id = d.register_screenshot(2.0, 100, 50, 640, 480, "region".to_string());
1029        let (sx, sy) = d.scale_coords(100_000, 100_000, Some(id)).unwrap();
1030        // x: clamp 100000 -> 639; 639*2 + 100 = 1378. y: 479*2 + 50 = 1008.
1031        assert_eq!((sx, sy), (1378, 1008));
1032        // region real size = 1280x960; in-frame bounds [100,1380) x [50,1010).
1033        assert!(sx < 100 + 1280 && sy < 50 + 960);
1034    }
1035
1036    #[test]
1037    fn scale_coords_errors_on_evicted_id() {
1038        let d = ComputerUseDriver::new(Backend::X11);
1039        for _ in 0..(SCREENSHOT_REGISTRY_CAPACITY + 1) {
1040            d.register_screenshot(1.0, 0, 0, 0, 0, "fullscreen".to_string());
1041        }
1042        // id 0 is evicted now.
1043        let err = d.scale_coords(0, 0, Some(0)).unwrap_err();
1044        assert!(
1045            err.contains("evicted"),
1046            "expected eviction message, got: {}",
1047            err
1048        );
1049    }
1050
1051    #[test]
1052    fn scale_coords_errors_with_no_screenshots_yet() {
1053        let d = ComputerUseDriver::new(Backend::X11);
1054        let err = d.scale_coords(10, 20, None).unwrap_err();
1055        assert!(err.contains("No screenshots"));
1056    }
1057
1058    #[test]
1059    fn ensure_alive_fails_on_unsupported_backend() {
1060        let d = ComputerUseDriver::new(Backend::Unsupported);
1061        assert!(d.ensure_alive().is_err());
1062    }
1063
1064    #[cfg(unix)]
1065    #[tokio::test]
1066    async fn run_cmd_stdout_times_out_on_slow_command() {
1067        // #97: a wedged probe must not hang — the seam fires the timeout, drops
1068        // the future (kill_on_drop reaps the child), and bails.
1069        let mut cmd = Command::new("sleep");
1070        cmd.arg("5");
1071        let err = run_cmd_stdout_with_timeout(&mut cmd, std::time::Duration::from_millis(50))
1072            .await
1073            .unwrap_err();
1074        assert!(err.to_string().contains("timed out"), "got: {err}");
1075    }
1076
1077    #[cfg(unix)]
1078    #[tokio::test]
1079    async fn run_cmd_stdout_returns_output_for_fast_command() {
1080        let mut cmd = Command::new("echo");
1081        cmd.arg("hi");
1082        assert_eq!(run_cmd_stdout(&mut cmd).await.unwrap().trim(), "hi");
1083    }
1084}