Skip to main content

macos_agent/
targets.rs

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}