1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use crate::cli::{ImageFormat, ListWindowsArgs};
5use crate::error::CliError;
6use crate::model::{AppRow, WindowRow};
7use crate::screen_record_adapter::{
8 self, AppInfo, ImageCropRegion, ScreenshotFormat, ShareableContent, WindowInfo, WindowSelection,
9};
10use crate::test_mode;
11
12#[derive(Debug, Clone, Default)]
13pub struct TargetSelector {
14 pub window_id: Option<u32>,
15 pub active_window: bool,
16 pub app: Option<String>,
17 pub window_name: Option<String>,
18}
19
20pub fn list_windows(args: &ListWindowsArgs) -> Result<Vec<WindowRow>, CliError> {
21 let content = fetch_shareable_content()?;
22 let mut windows = content.windows;
23
24 if let Some(app) = args.app.as_deref() {
25 windows.retain(|window| contains_case_insensitive(&window.owner_name, app));
26 }
27 if let Some(name) = args.window_name.as_deref() {
28 windows.retain(|window| contains_case_insensitive(&window.title, name));
29 }
30 if args.on_screen_only {
31 windows.retain(|window| window.on_screen);
32 }
33
34 windows.sort_by(|a, b| {
35 a.owner_name
36 .cmp(&b.owner_name)
37 .then_with(|| a.title.cmp(&b.title))
38 .then_with(|| a.id.cmp(&b.id))
39 });
40
41 Ok(windows.iter().map(WindowRow::from).collect())
42}
43
44pub fn list_apps() -> Result<Vec<AppRow>, CliError> {
45 let content = fetch_shareable_content()?;
46 let mut unique: BTreeMap<(String, i32, String), AppInfo> = BTreeMap::new();
47 for app in content.apps {
48 unique.insert((app.name.clone(), app.pid, app.bundle_id.clone()), app);
49 }
50
51 Ok(unique
52 .into_values()
53 .map(|app| AppRow::from(&app))
54 .collect::<Vec<_>>())
55}
56
57pub fn resolve_window(selector: &TargetSelector) -> Result<WindowInfo, CliError> {
58 let content = fetch_shareable_content()?;
59 let args = WindowSelection {
60 window_id: selector.window_id,
61 active_window: selector.active_window,
62 app: selector.app.clone(),
63 window_name: selector.window_name.clone(),
64 };
65
66 screen_record_adapter::resolve_window(&content.windows, &args)
67}
68
69pub fn window_present(selector: &TargetSelector) -> Result<bool, CliError> {
70 let content = fetch_shareable_content()?;
71 if let Some(window_id) = selector.window_id {
72 return Ok(content.windows.iter().any(|window| window.id == window_id));
73 }
74
75 if selector.active_window {
76 return Ok(content.windows.iter().any(|window| window.active));
77 }
78
79 if let Some(app) = selector.app.as_deref() {
80 let mut windows = content
81 .windows
82 .iter()
83 .filter(|window| contains_case_insensitive(&window.owner_name, app));
84
85 if let Some(window_name) = selector.window_name.as_deref() {
86 return Ok(windows.any(|window| contains_case_insensitive(&window.title, window_name)));
87 }
88
89 return Ok(windows.next().is_some());
90 }
91
92 Ok(false)
93}
94
95pub fn app_active_by_name(app_name: &str) -> Result<bool, CliError> {
96 let content = fetch_shareable_content()?;
97 Ok(content
98 .windows
99 .iter()
100 .any(|window| window.active && contains_case_insensitive(&window.owner_name, app_name)))
101}
102
103pub fn app_active_by_bundle_id(bundle_id: &str) -> Result<bool, CliError> {
104 let content = fetch_shareable_content()?;
105
106 let app_name = content
107 .apps
108 .iter()
109 .find(|app| app.bundle_id.eq_ignore_ascii_case(bundle_id))
110 .map(|app| app.name.clone());
111
112 match app_name {
113 Some(name) => app_active_by_name(&name),
114 None => Ok(false),
115 }
116}
117
118pub fn app_name_for_bundle_id(bundle_id: &str) -> Result<Option<String>, CliError> {
119 let content = fetch_shareable_content()?;
120 Ok(content
121 .apps
122 .iter()
123 .find(|app| app.bundle_id.eq_ignore_ascii_case(bundle_id))
124 .map(|app| app.name.clone()))
125}
126
127pub fn capture_screenshot(
128 path: &Path,
129 window: &WindowInfo,
130 format: ImageFormat,
131) -> Result<(), CliError> {
132 if let Some(parent) = path.parent() {
133 std::fs::create_dir_all(parent).map_err(|err| {
134 CliError::runtime(format!("failed to create output directory: {err}"))
135 })?;
136 }
137
138 let format = to_screenshot_format(format);
139
140 if test_mode::enabled() {
141 return screen_record_adapter::test_screenshot_fixture(path, format);
142 }
143
144 screen_record_adapter::capture_window_screenshot_macos(window, path, format)
145}
146
147pub fn capture_screenshot_region(
148 path: &Path,
149 window: &WindowInfo,
150 format: ImageFormat,
151 region: &crate::model::AxFrame,
152) -> Result<(), CliError> {
153 if let Some(parent) = path.parent() {
154 std::fs::create_dir_all(parent).map_err(|err| {
155 CliError::runtime(format!("failed to create output directory: {err}"))
156 })?;
157 }
158
159 if test_mode::enabled() {
160 return screen_record_adapter::test_screenshot_fixture(path, to_screenshot_format(format));
161 }
162
163 let crop_region = crop_region_for_window(window, region)?;
164 let mut full_path = PathBuf::from(path);
165 full_path.set_extension("full.png");
166
167 capture_screenshot(&full_path, window, format)?;
168 let result = screen_record_adapter::crop_image(&full_path, path, crop_region);
169 let _ = std::fs::remove_file(&full_path);
170 result
171}
172
173pub fn extension_format(path: &Path) -> Option<ImageFormat> {
174 let ext = path.extension()?.to_string_lossy().to_ascii_lowercase();
175 match ext.as_str() {
176 "png" => Some(ImageFormat::Png),
177 "jpg" | "jpeg" => Some(ImageFormat::Jpg),
178 "webp" => Some(ImageFormat::Webp),
179 _ => None,
180 }
181}
182
183fn to_screenshot_format(format: ImageFormat) -> ScreenshotFormat {
184 match format {
185 ImageFormat::Png => ScreenshotFormat::Png,
186 ImageFormat::Jpg => ScreenshotFormat::Jpg,
187 ImageFormat::Webp => ScreenshotFormat::Webp,
188 }
189}
190
191fn crop_region_for_window(
192 window: &WindowInfo,
193 region: &crate::model::AxFrame,
194) -> Result<ImageCropRegion, CliError> {
195 let window_left = window.bounds.x as f64;
196 let window_top = window.bounds.y as f64;
197 let window_right = window_left + (window.bounds.width.max(1) as f64);
198 let window_bottom = window_top + (window.bounds.height.max(1) as f64);
199
200 let left = region.x.max(window_left).min(window_right);
201 let top = region.y.max(window_top).min(window_bottom);
202 let right = (region.x + region.width).max(window_left).min(window_right);
203 let bottom = (region.y + region.height)
204 .max(window_top)
205 .min(window_bottom);
206
207 if right <= left || bottom <= top {
208 return Err(CliError::runtime(
209 "selector frame is outside the target window bounds",
210 ));
211 }
212
213 let x = (left - window_left).floor().max(0.0) as u32;
214 let y = (top - window_top).floor().max(0.0) as u32;
215 let width = (right - left).ceil().max(1.0) as u32;
216 let height = (bottom - top).ceil().max(1.0) as u32;
217 Ok(ImageCropRegion {
218 x,
219 y,
220 width,
221 height,
222 })
223}
224
225fn fetch_shareable_content() -> Result<ShareableContent, CliError> {
226 if test_mode::enabled() {
227 Ok(screen_record_adapter::test_shareable_content())
228 } else {
229 screen_record_adapter::fetch_shareable_macos()
230 }
231}
232
233fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
234 haystack
235 .to_ascii_lowercase()
236 .contains(&needle.to_ascii_lowercase())
237}
238
239#[cfg(test)]
240mod tests {
241 use std::path::PathBuf;
242
243 use nils_test_support::{EnvGuard, GlobalStateLock};
244 use tempfile::TempDir;
245
246 use super::{
247 TargetSelector, app_active_by_bundle_id, app_name_for_bundle_id, capture_screenshot,
248 capture_screenshot_region, extension_format, list_apps, list_windows, resolve_window,
249 window_present,
250 };
251 use crate::cli::{ImageFormat, ListWindowsArgs};
252
253 #[test]
254 fn extension_format_supports_expected_values() {
255 assert_eq!(
256 extension_format(&PathBuf::from("a.png")),
257 Some(ImageFormat::Png)
258 );
259 assert_eq!(
260 extension_format(&PathBuf::from("a.jpeg")),
261 Some(ImageFormat::Jpg)
262 );
263 assert_eq!(
264 extension_format(&PathBuf::from("a.webp")),
265 Some(ImageFormat::Webp)
266 );
267 assert_eq!(extension_format(&PathBuf::from("a.txt")), None);
268 }
269
270 #[test]
271 fn list_windows_is_sorted_and_filtered_in_test_mode() {
272 let lock = GlobalStateLock::new();
273 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
274
275 let rows = list_windows(&ListWindowsArgs {
276 app: Some("Terminal".to_string()),
277 window_name: None,
278 on_screen_only: true,
279 })
280 .expect("list windows");
281
282 let ids = rows.iter().map(|row| row.window_id).collect::<Vec<_>>();
283 assert_eq!(ids, vec![101, 100]);
284 }
285
286 #[test]
287 fn resolve_window_by_window_id() {
288 let lock = GlobalStateLock::new();
289 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
290
291 let target = resolve_window(&TargetSelector {
292 window_id: Some(100),
293 ..TargetSelector::default()
294 })
295 .expect("resolve by id");
296
297 assert_eq!(target.id, 100);
298 }
299
300 #[test]
301 fn list_apps_is_deterministic() {
302 let lock = GlobalStateLock::new();
303 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
304
305 let rows = list_apps().expect("list apps");
306 let names = rows
307 .iter()
308 .map(|row| row.app_name.clone())
309 .collect::<Vec<_>>();
310 assert_eq!(names, vec!["Finder".to_string(), "Terminal".to_string()]);
311 }
312
313 #[test]
314 fn window_present_and_app_activity_cover_selector_variants() {
315 let lock = GlobalStateLock::new();
316 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
317
318 assert!(
319 window_present(&TargetSelector {
320 window_id: Some(100),
321 ..TargetSelector::default()
322 })
323 .expect("window id exists")
324 );
325
326 assert!(
327 window_present(&TargetSelector {
328 active_window: true,
329 ..TargetSelector::default()
330 })
331 .expect("active window exists")
332 );
333
334 assert!(
335 window_present(&TargetSelector {
336 app: Some("Terminal".to_string()),
337 window_name: Some("Docs".to_string()),
338 ..TargetSelector::default()
339 })
340 .expect("app/window selector exists")
341 );
342
343 assert!(
344 !window_present(&TargetSelector {
345 app: Some("Safari".to_string()),
346 ..TargetSelector::default()
347 })
348 .expect("missing app should be false")
349 );
350
351 assert!(app_active_by_bundle_id("com.apple.Terminal").expect("bundle exists"));
352 assert!(!app_active_by_bundle_id("com.example.missing").expect("bundle missing"));
353 assert_eq!(
354 app_name_for_bundle_id("com.apple.Terminal").expect("bundle name"),
355 Some("Terminal".to_string())
356 );
357 assert_eq!(
358 app_name_for_bundle_id("com.example.missing").expect("missing bundle name"),
359 None
360 );
361 }
362
363 #[test]
364 fn capture_screenshot_uses_test_fixture_in_test_mode() {
365 let lock = GlobalStateLock::new();
366 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
367
368 let target = resolve_window(&TargetSelector {
369 window_id: Some(100),
370 ..TargetSelector::default()
371 })
372 .expect("resolve target");
373
374 let temp = TempDir::new().expect("tempdir");
375 let path = temp.path().join("capture.png");
376 capture_screenshot(&path, &target, ImageFormat::Png).expect("capture screenshot");
377 assert!(path.is_file(), "screenshot file should exist");
378 assert!(std::fs::metadata(&path).expect("metadata").len() > 0);
379 }
380
381 #[test]
382 fn capture_screenshot_region_crops_fixture_in_test_mode() {
383 let lock = GlobalStateLock::new();
384 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
385
386 let target = resolve_window(&TargetSelector {
387 window_id: Some(100),
388 ..TargetSelector::default()
389 })
390 .expect("resolve target");
391
392 let temp = TempDir::new().expect("tempdir");
393 let path = temp.path().join("capture-region.png");
394 capture_screenshot_region(
395 &path,
396 &target,
397 ImageFormat::Png,
398 &crate::model::AxFrame {
399 x: target.bounds.x as f64 + 10.0,
400 y: target.bounds.y as f64 + 12.0,
401 width: 30.0,
402 height: 24.0,
403 },
404 )
405 .expect("capture screenshot region");
406 assert!(path.is_file(), "region screenshot file should exist");
407 assert!(std::fs::metadata(&path).expect("metadata").len() > 0);
408 }
409
410 #[test]
411 fn screen_record_error_mapping_preserves_usage_and_runtime() {
412 let usage = screen_record::error::CliError::usage("bad selector");
413 let runtime = screen_record::error::CliError::runtime("capture failed");
414
415 let mapped_usage = crate::screen_record_adapter::map_error(usage);
416 assert_eq!(mapped_usage.exit_code(), 2);
417 assert!(mapped_usage.to_string().contains("bad selector"));
418
419 let mapped_runtime = crate::screen_record_adapter::map_error(runtime);
420 assert_eq!(mapped_runtime.exit_code(), 1);
421 assert!(mapped_runtime.to_string().contains("capture failed"));
422 }
423}