1use 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#[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 pub width: u32,
49 pub height: u32,
50 pub kind: String,
54}
55
56#[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#[derive(Debug, Clone)]
100pub enum ScreenshotSpec {
101 Fullscreen,
102 Focused,
103 Monitor(String),
104 Region(i32, i32, u32, u32),
106 Window(String),
107}
108
109#[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
124pub struct ComputerUseDriver {
126 backend: Backend,
127 registry: Mutex<ScreenshotRegistry>,
128 file_counter: AtomicU64,
132 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 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 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 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 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 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 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 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 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 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 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 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
373const CURSOR_LANDED_TOLERANCE_PX: i32 = 5;
376
377impl ComputerUseDriver {
380 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 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 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 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 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 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 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 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
611struct TempFileGuard(PathBuf);
614
615impl Drop for TempFileGuard {
616 fn drop(&mut self) {
617 let _ = std::fs::remove_file(&self.0);
618 }
619}
620
621async fn dispatch_capture(
624 backend: Backend,
625 spec: &ScreenshotSpec,
626 out_path: &str,
627 token: &CancellationToken,
628) -> Result<(i32, i32, String)> {
629 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 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 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
762pub(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
794async 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
815async 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
878fn 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
900async 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 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 assert!(r.get(0).is_none());
985 assert!(r.get(1).is_none());
986 assert!(r.get(2).is_none());
987 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 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 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 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 assert_eq!((sx, sy), (1378, 1008));
1032 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 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 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}