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}