Skip to main content

macos_agent/
screen_record_adapter.rs

1use std::path::Path;
2
3use screen_record::select::{SelectionArgs, select_window};
4
5use crate::error::CliError;
6
7pub(crate) use screen_record::types::{AppInfo, ShareableContent, WindowInfo};
8
9#[derive(Debug, Clone, Copy)]
10pub struct ImageCropRegion {
11    pub x: u32,
12    pub y: u32,
13    pub width: u32,
14    pub height: u32,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ScreenshotFormat {
19    Png,
20    Jpg,
21    Webp,
22}
23
24#[derive(Debug, Clone, Default)]
25pub struct WindowSelection {
26    pub window_id: Option<u32>,
27    pub active_window: bool,
28    pub app: Option<String>,
29    pub window_name: Option<String>,
30}
31
32pub fn resolve_window(
33    windows: &[WindowInfo],
34    selection: &WindowSelection,
35) -> Result<WindowInfo, CliError> {
36    let args = SelectionArgs {
37        window_id: selection.window_id,
38        app: selection.app.clone(),
39        window_name: selection.window_name.clone(),
40        active_window: selection.active_window,
41    };
42
43    select_window(windows, &args).map_err(map_error)
44}
45
46pub fn fetch_shareable_macos() -> Result<ShareableContent, CliError> {
47    #[cfg(target_os = "macos")]
48    {
49        screen_record::macos::shareable::fetch_shareable().map_err(map_error)
50    }
51
52    #[cfg(not(target_os = "macos"))]
53    {
54        Err(CliError::unsupported_platform())
55    }
56}
57
58pub fn capture_window_screenshot_macos(
59    window: &WindowInfo,
60    path: &Path,
61    format: ScreenshotFormat,
62) -> Result<(), CliError> {
63    #[cfg(target_os = "macos")]
64    {
65        screen_record::macos::screenshot::screenshot_window(
66            window,
67            path,
68            to_screen_record_format(format),
69        )
70        .map_err(map_error)
71    }
72
73    #[cfg(not(target_os = "macos"))]
74    {
75        let _ = window;
76        let _ = path;
77        let _ = format;
78        Err(CliError::unsupported_platform())
79    }
80}
81
82pub fn test_shareable_content() -> ShareableContent {
83    screen_record::test_mode::shareable_content()
84}
85
86pub fn test_screenshot_fixture(path: &Path, format: ScreenshotFormat) -> Result<(), CliError> {
87    screen_record::test_mode::screenshot_fixture(path, to_screen_record_format(format))
88        .map_err(map_error)
89}
90
91pub fn map_error(err: screen_record::error::CliError) -> CliError {
92    if err.exit_code() == 2 {
93        CliError::usage(err.to_string())
94    } else {
95        CliError::runtime(err.to_string())
96    }
97}
98
99pub fn crop_image(input: &Path, output: &Path, region: ImageCropRegion) -> Result<(), CliError> {
100    let image = image::open(input).map_err(|err| {
101        CliError::runtime(format!("failed to decode screenshot for cropping: {err}"))
102    })?;
103
104    let max_width = image.width();
105    let max_height = image.height();
106    if max_width == 0 || max_height == 0 {
107        return Err(CliError::runtime("cannot crop empty screenshot image"));
108    }
109
110    let x = region.x.min(max_width.saturating_sub(1));
111    let y = region.y.min(max_height.saturating_sub(1));
112    let width = region.width.max(1).min(max_width.saturating_sub(x));
113    let height = region.height.max(1).min(max_height.saturating_sub(y));
114
115    let cropped = image.crop_imm(x, y, width, height);
116    cropped
117        .save(output)
118        .map_err(|err| CliError::runtime(format!("failed to write cropped screenshot: {err}")))
119}
120
121fn to_screen_record_format(format: ScreenshotFormat) -> screen_record::cli::ImageFormat {
122    match format {
123        ScreenshotFormat::Png => screen_record::cli::ImageFormat::Png,
124        ScreenshotFormat::Jpg => screen_record::cli::ImageFormat::Jpg,
125        ScreenshotFormat::Webp => screen_record::cli::ImageFormat::Webp,
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use std::fs;
132
133    use pretty_assertions::assert_eq;
134    use tempfile::TempDir;
135
136    use super::{
137        ImageCropRegion, ScreenshotFormat, crop_image, map_error, test_screenshot_fixture,
138    };
139
140    #[test]
141    fn map_error_preserves_usage_and_runtime_exit_code() {
142        let usage = screen_record::error::CliError::usage("bad selector");
143        let runtime = screen_record::error::CliError::runtime("capture failed");
144
145        let mapped_usage = map_error(usage);
146        assert_eq!(mapped_usage.exit_code(), 2);
147        assert!(mapped_usage.to_string().contains("bad selector"));
148
149        let mapped_runtime = map_error(runtime);
150        assert_eq!(mapped_runtime.exit_code(), 1);
151        assert!(mapped_runtime.to_string().contains("capture failed"));
152    }
153
154    #[test]
155    fn crop_image_writes_bounded_output() {
156        let temp = TempDir::new().expect("tempdir");
157        let input = temp.path().join("in.png");
158        let output = temp.path().join("out.png");
159
160        let image = image::DynamicImage::new_rgba8(100, 80);
161        image.save(&input).expect("save input");
162
163        crop_image(
164            &input,
165            &output,
166            ImageCropRegion {
167                x: 10,
168                y: 12,
169                width: 20,
170                height: 16,
171            },
172        )
173        .expect("crop should succeed");
174
175        let cropped = image::open(&output).expect("open output");
176        assert_eq!(cropped.width(), 20);
177        assert_eq!(cropped.height(), 16);
178    }
179
180    #[test]
181    fn crop_image_reports_decode_error_for_non_image_input() {
182        let temp = TempDir::new().expect("tempdir");
183        let input = temp.path().join("not-image.txt");
184        let output = temp.path().join("out.png");
185        fs::write(&input, "not an image").expect("write invalid image payload");
186
187        let err = crop_image(
188            &input,
189            &output,
190            ImageCropRegion {
191                x: 0,
192                y: 0,
193                width: 1,
194                height: 1,
195            },
196        )
197        .expect_err("invalid image should fail decode");
198        assert!(
199            err.to_string()
200                .contains("failed to decode screenshot for cropping")
201        );
202    }
203
204    #[test]
205    fn test_screenshot_fixture_supports_jpg_and_webp_formats() {
206        let temp = TempDir::new().expect("tempdir");
207        let jpg = temp.path().join("shot.jpg");
208        let webp = temp.path().join("shot.webp");
209
210        test_screenshot_fixture(&jpg, ScreenshotFormat::Jpg).expect("jpg screenshot fixture");
211        test_screenshot_fixture(&webp, ScreenshotFormat::Webp).expect("webp screenshot fixture");
212
213        assert!(jpg.exists());
214        assert!(webp.exists());
215    }
216
217    #[cfg(not(target_os = "macos"))]
218    #[test]
219    fn macos_only_screen_record_functions_return_unsupported_on_non_macos() {
220        use nils_test_support::{EnvGuard, GlobalStateLock};
221
222        use super::{
223            capture_window_screenshot_macos, fetch_shareable_macos, test_shareable_content,
224        };
225
226        let lock = GlobalStateLock::new();
227        let _test_mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
228
229        let err = fetch_shareable_macos().expect_err("non-macos should be unsupported");
230        assert_eq!(err.exit_code(), 2);
231        assert!(
232            err.to_string()
233                .to_ascii_lowercase()
234                .contains("only supported on macos")
235        );
236
237        let temp = TempDir::new().expect("tempdir");
238        let out = temp.path().join("shot.png");
239        let window = test_shareable_content()
240            .windows
241            .into_iter()
242            .next()
243            .expect("test window");
244        let err = capture_window_screenshot_macos(&window, &out, ScreenshotFormat::Png)
245            .expect_err("non-macos screenshot should be unsupported");
246        assert_eq!(err.exit_code(), 2);
247        assert!(
248            err.to_string()
249                .to_ascii_lowercase()
250                .contains("only supported on macos")
251        );
252    }
253}