Skip to main content

computer_use/
target.rs

1//! Application and window selectors.
2//!
3//! Resolution runs against a table the backend filled in. Unit tests pass a
4//! fake table and never call AppKit or CoreGraphics.
5
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ComputerError;
11
12/// How the caller named an application.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum AppSelector {
15    /// Process id.
16    Pid(i32),
17    /// Bundle identifier such as `com.apple.TextEdit`.
18    BundleId(String),
19    /// Localized name. May contain quotes and spaces.
20    Name(String),
21    /// Filesystem path of a `.app` bundle or an executable.
22    Path(PathBuf),
23}
24
25/// How the caller named a window.
26///
27/// A bare number is a z-order index, with 0 in front. A window id uses the
28/// `id:` prefix so it is not confused with that index. Any other string is a title.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum WindowSelector {
31    /// Index in the listed z-order. 0 is front.
32    Index(u32),
33    /// `kCGWindowNumber`.
34    Id(u32),
35    /// Window title.
36    Title(String),
37}
38
39/// How macOS classifies a process. Helpers are accessory or prohibited.
40///
41/// `list-apps` omits this field. Missing values deserialize as [`ActivationPolicy::Regular`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum ActivationPolicy {
45    /// Foreground app (`NSApplicationActivationPolicyRegular`).
46    #[default]
47    Regular,
48    /// Menu-bar or agent app.
49    Accessory,
50    /// Process that does not normally activate. Helper processes use this.
51    Prohibited,
52    /// The platform did not report a policy.
53    Unknown,
54}
55
56/// One running application, as returned by `list_apps`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct AppInfo {
59    /// Localized name.
60    pub name: String,
61    /// Bundle identifier, when the process has one.
62    pub bundle_id: Option<String>,
63    /// Process id.
64    pub pid: i32,
65    /// Canonical executable path, when it could be read.
66    pub path: Option<String>,
67    /// Whether the app is frontmost.
68    pub active: bool,
69    /// Whether the app is hidden.
70    pub hidden: bool,
71    /// Activation policy. Omitted from `list-apps` JSON.
72    #[serde(default, skip_serializing)]
73    pub activation_policy: ActivationPolicy,
74}
75
76/// Rectangle in logical screen points.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub struct WindowBounds {
79    /// Left edge.
80    pub x: i32,
81    /// Top edge.
82    pub y: i32,
83    /// Width. Zero when the platform omitted the frame.
84    pub width: u32,
85    /// Height. Zero when the platform omitted the frame.
86    pub height: u32,
87}
88
89/// One window, as returned by `list_windows`.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct WindowInfo {
92    /// Platform window id.
93    pub id: u32,
94    /// Owning process id.
95    pub pid: i32,
96    /// Owner name.
97    pub app: String,
98    /// Window title. Empty when Screen Recording hides it.
99    pub title: String,
100    /// Z-order index in this result. 0 is front.
101    pub z: u32,
102    /// Screen bounds in logical points.
103    pub bounds: WindowBounds,
104    /// Window layer. Normal windows are 0.
105    pub layer: i32,
106    /// Whether the window was listed as on screen.
107    pub on_screen: bool,
108    /// Whether an accessibility window was joined to this row by bounds.
109    pub ax_matched: bool,
110}
111
112/// A window row before z-order indexes are assigned.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ListedWindow {
115    /// Platform window id.
116    pub id: u32,
117    /// Owning process id.
118    pub pid: i32,
119    /// Owner name.
120    pub app: String,
121    /// Window title.
122    pub title: String,
123    /// Screen bounds.
124    pub bounds: WindowBounds,
125    /// Window layer.
126    pub layer: i32,
127    /// On-screen flag from the window server.
128    pub on_screen: bool,
129}
130
131/// Windows plus an optional note about how they were collected.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub struct WindowList {
134    /// Windows in z-order, index 0 in front.
135    pub windows: Vec<WindowInfo>,
136    /// Why the list is incomplete, when it is.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub note: Option<String>,
139}
140
141/// Result of resolving an app and an optional window.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ResolvedTarget {
144    /// Process id.
145    pub pid: i32,
146    /// Bundle identifier.
147    pub bundle_id: Option<String>,
148    /// Localized name.
149    pub display_name: String,
150    /// Canonical executable path.
151    pub exe_path: Option<PathBuf>,
152    /// Window id, when a window was requested or chosen.
153    pub window_id: Option<u32>,
154    /// Window title.
155    pub window_title: Option<String>,
156    /// Window bounds.
157    pub bounds: Option<WindowBounds>,
158}
159
160/// An application that was launched or activated.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct LaunchResult {
163    /// Localized name.
164    pub name: String,
165    /// Bundle identifier.
166    pub bundle_id: Option<String>,
167    /// Process id after launch.
168    pub pid: i32,
169    /// Canonical executable path.
170    pub path: Option<String>,
171    /// Window id when `activate` resolved one.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub window_id: Option<u32>,
174    /// Window title when `activate` resolved one.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub window_title: Option<String>,
177}
178
179/// `quit` result.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
181pub struct QuitResult {
182    /// Localized name.
183    pub name: String,
184    /// Bundle identifier.
185    pub bundle_id: Option<String>,
186    /// Process id the terminate request was sent to.
187    pub pid: i32,
188    /// Whether the platform accepted the request. The process may still be exiting.
189    pub accepted: bool,
190}
191
192/// The GUI app that owns this process for TCC prompts.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct ResponsibleApp {
195    /// Localized name.
196    pub name: String,
197    /// Bundle identifier.
198    pub bundle_id: Option<String>,
199    /// Process id of that app, not of this binary.
200    pub pid: i32,
201    /// Bundle path.
202    pub path: Option<String>,
203}
204
205/// One missing permission and the Settings pane that grants it.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct PermissionFix {
208    /// Stable id: `accessibility`, `screen_recording`, `post_events`, or `input_monitoring`.
209    pub permission: String,
210    /// `x-apple.systempreferences:` URL for that pane.
211    pub url: String,
212    /// App or binary the user enables in that pane.
213    pub target: String,
214    /// One-line instruction.
215    pub instruction: String,
216}
217
218/// Binary identity `doctor` reports so the user can see which TCC subject is active.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct DoctorIdentity {
221    /// Path of the running executable.
222    pub path: Option<String>,
223    /// `NSBundle` main bundle identifier. Null for a bare binary.
224    pub bundle_id: Option<String>,
225    /// True when this process is `Computer Use.app`.
226    pub bundled: bool,
227    /// `signed`, `unsigned`, `adhoc`, `unknown`, or null when this OS does not sign the binary.
228    pub signature: Option<String>,
229    /// Apple team id when the signature has one.
230    pub team_id: Option<String>,
231    /// GUI app TCC will attribute this process to, when it could be found.
232    pub responsible: Option<ResponsibleApp>,
233}
234
235/// `doctor` report. Null probes mean this OS does not check them yet.
236///
237/// The response envelope's `ok` means the report was produced (exit 0).
238/// [`Self::permissions_ok`] is the permission aggregate.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
240pub struct DoctorReport {
241    /// True when every required permission this OS checks is granted.
242    pub permissions_ok: bool,
243    /// Required permissions that are not granted. Null probes are omitted.
244    pub missing: Vec<String>,
245    /// Accessibility trust. Null until the platform probe exists.
246    pub accessibility: Option<bool>,
247    /// Screen Recording preflight. Same value as `screen_recording`.
248    pub screen_capture: Option<bool>,
249    /// Screen Recording preflight. Same value as `screen_capture`.
250    pub screen_recording: Option<bool>,
251    /// `CGPreflightPostEventAccess`. Null off macOS.
252    pub post_events: Option<bool>,
253    /// `CGPreflightListenEventAccess`. Not the P6 event tap.
254    pub input_monitoring: Option<bool>,
255    /// P6 event tap created in this process. Null when it was not attempted.
256    pub input_monitor: Option<bool>,
257    /// Running binary's bundle id. Null for a bare binary. Same as `identity.bundle_id`.
258    pub bundle_id: Option<String>,
259    /// Path, bundle, signature, and the TCC responsible app.
260    pub identity: DoctorIdentity,
261    /// Parent GUI app that must be toggled in Settings, when it could be found.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub responsible_app: Option<ResponsibleApp>,
264    /// Structured fix for each entry in `missing`, in the same order.
265    pub fix: Vec<PermissionFix>,
266    /// One-line instructions. Same order and text as `fix[].instruction`.
267    pub fixes: Vec<String>,
268    /// Linux `x11`, `wayland`, or `x11+wayland`. Null when it does not apply or is unset.
269    pub session: Option<String>,
270    /// Linux AT-SPI bus address is present. Not a connection. Null when not checked.
271    pub atspi_bus: Option<bool>,
272    /// Linux XDG portal support is compiled in. Compile-time capability, not a
273    /// running portal. Null when not checked.
274    pub portal: Option<bool>,
275    /// Windows UIAccess on this process. Best effort. Null when not read.
276    pub uiaccess: Option<bool>,
277    /// Windows integrity label. Best effort. Null when not read.
278    pub integrity: Option<String>,
279    /// Readable summary of this report.
280    pub text: String,
281    /// Extra context. Omitted when empty.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub note: Option<String>,
284}
285
286impl DoctorIdentity {
287    pub(crate) fn blank() -> Self {
288        Self {
289            path: None,
290            bundle_id: None,
291            bundled: false,
292            signature: None,
293            team_id: None,
294            responsible: None,
295        }
296    }
297}
298
299impl DoctorReport {
300    pub(crate) fn blank() -> Self {
301        Self {
302            permissions_ok: true,
303            missing: Vec::new(),
304            accessibility: None,
305            screen_capture: None,
306            screen_recording: None,
307            post_events: None,
308            input_monitoring: None,
309            input_monitor: None,
310            bundle_id: None,
311            identity: DoctorIdentity::blank(),
312            responsible_app: None,
313            fix: Vec::new(),
314            fixes: Vec::new(),
315            session: None,
316            atspi_bus: None,
317            portal: None,
318            uiaccess: None,
319            integrity: None,
320            text: String::new(),
321            note: None,
322        }
323    }
324
325    /// Report used on platforms whose probes land in a later phase.
326    pub fn pending(phase: &str) -> Self {
327        let mut report = Self::blank();
328        let note = format!("accessibility and screen-capture probes land in {phase}");
329        report.text = note.clone();
330        report.note = Some(note);
331        report
332    }
333}
334
335/// Parse `name`, `bundle id`, `pid`, or a path.
336pub fn parse_app_selector(text: &str) -> Result<AppSelector, ComputerError> {
337    let text = text.trim();
338    if text.is_empty() {
339        return Err(ComputerError::InvalidArguments(
340            "app selector is empty".into(),
341        ));
342    }
343    if looks_like_path(text) {
344        return Ok(AppSelector::Path(PathBuf::from(expand_tilde(text))));
345    }
346    if text.chars().all(|ch| ch.is_ascii_digit()) {
347        let pid = text
348            .parse::<i32>()
349            .map_err(|_| ComputerError::InvalidArguments(format!("pid is out of range: {text}")))?;
350        return Ok(AppSelector::Pid(pid));
351    }
352    if is_bundle_id(text) {
353        return Ok(AppSelector::BundleId(text.to_string()));
354    }
355    Ok(AppSelector::Name(text.to_string()))
356}
357
358/// Parse a window title, z-order index, `index:N`, or `id:N`.
359pub fn parse_window_selector(text: &str) -> Result<WindowSelector, ComputerError> {
360    let text = text.trim();
361    if text.is_empty() {
362        return Err(ComputerError::InvalidArguments(
363            "window selector is empty".into(),
364        ));
365    }
366    if let Some(rest) = text
367        .strip_prefix("id:")
368        .or_else(|| text.strip_prefix("ID:"))
369    {
370        return Ok(WindowSelector::Id(parse_u32(rest, "window id")?));
371    }
372    if let Some(rest) = text
373        .strip_prefix("index:")
374        .or_else(|| text.strip_prefix("INDEX:"))
375    {
376        return Ok(WindowSelector::Index(parse_u32(rest, "window index")?));
377    }
378    if text.chars().all(|ch| ch.is_ascii_digit()) {
379        return Ok(WindowSelector::Index(parse_u32(text, "window index")?));
380    }
381    Ok(WindowSelector::Title(text.to_string()))
382}
383
384/// True when `frame` is within 2 points of `bounds` on each edge and size.
385pub fn bounds_match_frame(bounds: WindowBounds, frame: crate::tree::NodeFrame) -> bool {
386    (f64::from(bounds.x) - frame.x).abs() <= 2.0
387        && (f64::from(bounds.y) - frame.y).abs() <= 2.0
388        && (f64::from(bounds.width) - frame.width).abs() <= 2.0
389        && (f64::from(bounds.height) - frame.height).abs() <= 2.0
390}
391
392/// Assign z-order from the CG array order. Index 0 is the front window.
393pub fn assign_z_order(entries: Vec<ListedWindow>) -> Vec<WindowInfo> {
394    entries
395        .into_iter()
396        .enumerate()
397        .map(|(z, entry)| WindowInfo {
398            id: entry.id,
399            pid: entry.pid,
400            app: entry.app,
401            title: entry.title,
402            z: z as u32,
403            bounds: entry.bounds,
404            layer: entry.layer,
405            on_screen: entry.on_screen,
406            ax_matched: false,
407        })
408        .collect()
409}
410
411/// Keep one process and renumber z-order so 0 is that app's front window.
412pub fn filter_windows_for_pid(windows: &[WindowInfo], pid: i32) -> Vec<WindowInfo> {
413    windows
414        .iter()
415        .filter(|window| window.pid == pid)
416        .enumerate()
417        .map(|(z, window)| {
418            let mut copy = window.clone();
419            copy.z = z as u32;
420            copy
421        })
422        .collect()
423}
424
425/// Resolve `selector` against `apps`.
426pub fn resolve_app<'a>(
427    apps: &'a [AppInfo],
428    selector: &AppSelector,
429) -> Result<&'a AppInfo, ComputerError> {
430    if let AppSelector::Name(name) = selector {
431        let exact: Vec<&AppInfo> = apps
432            .iter()
433            .filter(|app| app.name.eq_ignore_ascii_case(name))
434            .collect();
435        if !exact.is_empty() {
436            return pick_one(&exact, name);
437        }
438    }
439    let matches: Vec<&AppInfo> = apps
440        .iter()
441        .filter(|app| app_matches(app, selector))
442        .collect();
443    if matches.is_empty() {
444        return Err(ComputerError::AppNotFound(selector_label(selector)));
445    }
446    pick_one(&matches, &selector_label(selector))
447}
448
449/// Resolve an optional window inside an already filtered list.
450pub fn resolve_window<'a>(
451    windows: &'a [WindowInfo],
452    selector: &WindowSelector,
453) -> Result<&'a WindowInfo, ComputerError> {
454    let found = match selector {
455        WindowSelector::Index(index) => windows.iter().find(|window| window.z == *index),
456        WindowSelector::Id(id) => windows.iter().find(|window| window.id == *id),
457        WindowSelector::Title(title) => {
458            let exact = windows
459                .iter()
460                .find(|window| window.title.eq_ignore_ascii_case(title));
461            if exact.is_some() {
462                exact
463            } else {
464                let needle = title.to_ascii_lowercase();
465                windows
466                    .iter()
467                    .find(|window| window.title.to_ascii_lowercase().contains(&needle))
468            }
469        }
470    };
471    found.ok_or_else(|| ComputerError::WindowNotFound(window_label(selector)))
472}
473
474/// Resolve an app and, when requested, one of its windows.
475pub fn resolve(
476    apps: &[AppInfo],
477    windows: &[WindowInfo],
478    app: &AppSelector,
479    window: Option<&WindowSelector>,
480) -> Result<ResolvedTarget, ComputerError> {
481    let app = resolve_app(apps, app)?;
482    let owned = filter_windows_for_pid(windows, app.pid);
483    let chosen = match window {
484        Some(selector) => Some(resolve_window(&owned, selector)?.clone()),
485        None => None,
486    };
487    Ok(ResolvedTarget {
488        pid: app.pid,
489        bundle_id: app.bundle_id.clone(),
490        display_name: app.name.clone(),
491        exe_path: app.path.as_ref().map(PathBuf::from),
492        window_id: chosen.as_ref().map(|item| item.id),
493        window_title: chosen.as_ref().map(|item| item.title.clone()),
494        bounds: chosen.as_ref().map(|item| item.bounds),
495    })
496}
497
498/// Canonicalize when the path exists. Otherwise keep the original path.
499pub fn canonical_exe_path(path: &Path) -> PathBuf {
500    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
501}
502
503fn pick_one<'a>(matches: &[&'a AppInfo], label: &str) -> Result<&'a AppInfo, ComputerError> {
504    let regular: Vec<&AppInfo> = matches
505        .iter()
506        .copied()
507        .filter(|app| app.activation_policy == ActivationPolicy::Regular)
508        .collect();
509    let pool: &[&AppInfo] = if regular.is_empty() {
510        matches
511    } else {
512        &regular
513    };
514    if pool.len() == 1 {
515        return Ok(pool[0]);
516    }
517    let active: Vec<&AppInfo> = pool.iter().copied().filter(|app| app.active).collect();
518    if active.len() == 1 {
519        return Ok(active[0]);
520    }
521    let pids: Vec<String> = pool.iter().map(|app| app.pid.to_string()).collect();
522    Err(ComputerError::InvalidArguments(format!(
523        "app selector {label:?} matched {} processes ({})",
524        pool.len(),
525        pids.join(", ")
526    )))
527}
528
529fn app_matches(app: &AppInfo, selector: &AppSelector) -> bool {
530    match selector {
531        AppSelector::Pid(pid) => app.pid == *pid,
532        AppSelector::BundleId(bundle_id) => app
533            .bundle_id
534            .as_deref()
535            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(bundle_id)),
536        AppSelector::Name(name) => {
537            app.name.eq_ignore_ascii_case(name) || path_names_app(app.path.as_deref(), name)
538        }
539        AppSelector::Path(path) => paths_match(app.path.as_deref(), path),
540    }
541}
542
543fn path_names_app(path: Option<&str>, name: &str) -> bool {
544    let Some(path) = path else {
545        return false;
546    };
547    let wanted_app = format!("{name}.app");
548    Path::new(path).components().any(|component| {
549        let text = component.as_os_str().to_string_lossy();
550        text.eq_ignore_ascii_case(&wanted_app)
551            || text.eq_ignore_ascii_case(name)
552                && Path::new(path)
553                    .file_name()
554                    .and_then(|file| file.to_str())
555                    .is_some_and(|file| file.eq_ignore_ascii_case(name))
556    })
557}
558
559fn paths_match(stored: Option<&str>, wanted: &Path) -> bool {
560    let Some(stored) = stored else {
561        return false;
562    };
563    let stored_path = Path::new(stored);
564    stored_path == wanted || canonical_exe_path(stored_path) == canonical_exe_path(wanted)
565}
566
567fn looks_like_path(text: &str) -> bool {
568    text.contains('/')
569        || text.contains('\\')
570        || text.to_ascii_lowercase().ends_with(".app")
571        || text.to_ascii_lowercase().ends_with(".exe")
572}
573
574fn is_bundle_id(text: &str) -> bool {
575    let mut labels = text.split('.');
576    let Some(first) = labels.next() else {
577        return false;
578    };
579    if !is_bundle_label(first) {
580        return false;
581    }
582    let mut count = 1;
583    for label in labels {
584        if !is_bundle_label(label) {
585            return false;
586        }
587        count += 1;
588    }
589    count >= 2
590}
591
592fn is_bundle_label(text: &str) -> bool {
593    !text.is_empty()
594        && text
595            .chars()
596            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
597}
598
599fn expand_tilde(text: &str) -> String {
600    let Some(rest) = text.strip_prefix("~/") else {
601        return text.to_string();
602    };
603    let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else {
604        return text.to_string();
605    };
606    Path::new(&home).join(rest).to_string_lossy().into_owned()
607}
608
609fn parse_u32(text: &str, name: &str) -> Result<u32, ComputerError> {
610    text.parse::<u32>()
611        .map_err(|_| ComputerError::InvalidArguments(format!("{name} is not an integer: {text}")))
612}
613
614fn selector_label(selector: &AppSelector) -> String {
615    match selector {
616        AppSelector::Pid(pid) => pid.to_string(),
617        AppSelector::BundleId(bundle_id) => bundle_id.clone(),
618        AppSelector::Name(name) => name.clone(),
619        AppSelector::Path(path) => path.display().to_string(),
620    }
621}
622
623fn window_label(selector: &WindowSelector) -> String {
624    match selector {
625        WindowSelector::Index(index) => index.to_string(),
626        WindowSelector::Id(id) => format!("id:{id}"),
627        WindowSelector::Title(title) => title.clone(),
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    fn apps() -> Vec<AppInfo> {
636        vec![
637            AppInfo {
638                name: "Notes".into(),
639                bundle_id: Some("com.apple.Notes".into()),
640                pid: 10,
641                path: Some("/System/Applications/Notes.app/Contents/MacOS/Notes".into()),
642                active: true,
643                hidden: false,
644                activation_policy: ActivationPolicy::Regular,
645            },
646            AppInfo {
647                name: "Notes".into(),
648                bundle_id: Some("com.example.Notes".into()),
649                pid: 11,
650                path: Some("/Applications/Notes.app/Contents/MacOS/Notes".into()),
651                active: false,
652                hidden: false,
653                activation_policy: ActivationPolicy::Regular,
654            },
655            AppInfo {
656                name: r#"Foo "Bar""#.into(),
657                bundle_id: Some("dev.example.foo-bar".into()),
658                pid: 412,
659                path: Some(r#"/Applications/Foo "Bar".app/Contents/MacOS/Foo"#.into()),
660                active: false,
661                hidden: false,
662                activation_policy: ActivationPolicy::Regular,
663            },
664            AppInfo {
665                name: "Terminal".into(),
666                bundle_id: Some("com.apple.Terminal".into()),
667                pid: 20,
668                path: Some("/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal".into()),
669                active: false,
670                hidden: false,
671                activation_policy: ActivationPolicy::Regular,
672            },
673        ]
674    }
675
676    fn windows() -> Vec<WindowInfo> {
677        assign_z_order(vec![
678            ListedWindow {
679                id: 100,
680                pid: 10,
681                app: "Notes".into(),
682                title: "Shopping".into(),
683                bounds: WindowBounds {
684                    x: 0,
685                    y: 0,
686                    width: 800,
687                    height: 600,
688                },
689                layer: 0,
690                on_screen: true,
691            },
692            ListedWindow {
693                id: 200,
694                pid: 20,
695                app: "Terminal".into(),
696                title: "zsh".into(),
697                bounds: WindowBounds {
698                    x: 10,
699                    y: 10,
700                    width: 400,
701                    height: 200,
702                },
703                layer: 0,
704                on_screen: true,
705            },
706            ListedWindow {
707                id: 101,
708                pid: 10,
709                app: "Notes".into(),
710                title: "Hello".into(),
711                bounds: WindowBounds {
712                    x: 20,
713                    y: 30,
714                    width: 100,
715                    height: 50,
716                },
717                layer: 3,
718                on_screen: true,
719            },
720        ])
721    }
722
723    #[test]
724    fn parser_accepts_pid_bundle_path_and_a_name_with_a_quote() {
725        assert_eq!(parse_app_selector("412").unwrap(), AppSelector::Pid(412));
726        assert_eq!(
727            parse_app_selector("com.apple.TextEdit").unwrap(),
728            AppSelector::BundleId("com.apple.TextEdit".into())
729        );
730        assert_eq!(
731            parse_app_selector(r#"Foo "Bar""#).unwrap(),
732            AppSelector::Name(r#"Foo "Bar""#.into())
733        );
734        assert!(matches!(
735            parse_app_selector("/Applications/Notes.app").unwrap(),
736            AppSelector::Path(_)
737        ));
738        assert!(parse_app_selector("   ").is_err());
739        assert!(parse_app_selector("com..apple").is_err() || !is_bundle_id("com..apple"));
740        assert!(!is_bundle_id(r#"Foo "Bar""#));
741    }
742
743    #[test]
744    fn resolve_matches_quote_name_bundle_and_pid_against_a_fake_table() {
745        let apps = apps();
746        let quoted = parse_app_selector(r#"Foo "Bar""#).unwrap();
747        let resolved = resolve_app(&apps, &quoted).unwrap();
748        assert_eq!(resolved.pid, 412);
749        assert_eq!(resolved.name, r#"Foo "Bar""#);
750
751        let bundle = parse_app_selector("com.apple.Terminal").unwrap();
752        assert_eq!(resolve_app(&apps, &bundle).unwrap().pid, 20);
753
754        let pid = parse_app_selector("10").unwrap();
755        assert_eq!(
756            resolve_app(&apps, &pid).unwrap().bundle_id.as_deref(),
757            Some("com.apple.Notes")
758        );
759
760        assert!(resolve_app(&apps, &AppSelector::Pid(999)).is_err());
761    }
762
763    #[test]
764    fn ambiguous_name_prefers_the_active_app() {
765        let apps = apps();
766        let notes = AppSelector::Name("Notes".into());
767        assert_eq!(resolve_app(&apps, &notes).unwrap().pid, 10);
768
769        let mut both_inactive = apps.clone();
770        both_inactive[0].active = false;
771        let error = resolve_app(&both_inactive, &notes).unwrap_err();
772        assert_eq!(error.code(), "invalid_arguments");
773        assert!(error.to_string().contains("10"));
774    }
775
776    #[test]
777    fn ambiguous_name_prefers_a_regular_app_over_a_helper() {
778        let mut table = apps();
779        table.push(AppInfo {
780            name: "Google Chrome".into(),
781            bundle_id: Some("com.google.Chrome".into()),
782            pid: 1,
783            path: None,
784            active: false,
785            hidden: false,
786            activation_policy: ActivationPolicy::Regular,
787        });
788        table.push(AppInfo {
789            name: "Google Chrome".into(),
790            bundle_id: Some("com.google.Chrome.helper".into()),
791            pid: 3,
792            path: None,
793            active: true,
794            hidden: false,
795            activation_policy: ActivationPolicy::Prohibited,
796        });
797        let resolved = resolve_app(&table, &AppSelector::Name("Google Chrome".into())).unwrap();
798        assert_eq!(resolved.pid, 1);
799    }
800
801    #[test]
802    fn bounds_match_allows_two_points() {
803        let bounds = WindowBounds {
804            x: 10,
805            y: 20,
806            width: 100,
807            height: 80,
808        };
809        assert!(bounds_match_frame(
810            bounds,
811            crate::tree::NodeFrame {
812                x: 12.0,
813                y: 18.0,
814                width: 101.0,
815                height: 80.0,
816            }
817        ));
818        assert!(!bounds_match_frame(
819            bounds,
820            crate::tree::NodeFrame {
821                x: 14.0,
822                y: 20.0,
823                width: 100.0,
824                height: 80.0,
825            }
826        ));
827    }
828
829    #[test]
830    fn z_order_is_the_array_index_and_filters_renumber() {
831        let listed = windows();
832        assert_eq!(listed[0].z, 0);
833        assert_eq!(listed[0].id, 100);
834        assert_eq!(listed[1].z, 1);
835        assert_eq!(listed[2].z, 2);
836        assert!(!listed[0].ax_matched);
837
838        let notes = filter_windows_for_pid(&listed, 10);
839        assert_eq!(notes.len(), 2);
840        assert_eq!(notes[0].z, 0);
841        assert_eq!(notes[0].id, 100);
842        assert_eq!(notes[1].z, 1);
843        assert_eq!(notes[1].id, 101);
844
845        let target = resolve(
846            &apps(),
847            &listed,
848            &AppSelector::Name("Notes".into()),
849            Some(&WindowSelector::Index(1)),
850        )
851        .unwrap();
852        assert_eq!(target.pid, 10);
853        assert_eq!(target.window_id, Some(101));
854        assert_eq!(target.window_title.as_deref(), Some("Hello"));
855    }
856
857    #[test]
858    fn window_selector_distinguishes_index_id_and_title() {
859        assert_eq!(
860            parse_window_selector("0").unwrap(),
861            WindowSelector::Index(0)
862        );
863        assert_eq!(
864            parse_window_selector("id:200").unwrap(),
865            WindowSelector::Id(200)
866        );
867        assert_eq!(
868            parse_window_selector("index:2").unwrap(),
869            WindowSelector::Index(2)
870        );
871        let listed = windows();
872        assert_eq!(
873            resolve_window(&listed, &WindowSelector::Id(200))
874                .unwrap()
875                .title,
876            "zsh"
877        );
878        assert_eq!(
879            resolve_window(&listed, &WindowSelector::Title("hello".into()))
880                .unwrap()
881                .id,
882            101
883        );
884        assert!(resolve_window(&listed, &WindowSelector::Title("missing".into())).is_err());
885        assert!(parse_window_selector("id:nope").is_err());
886    }
887
888    #[cfg(unix)]
889    #[test]
890    fn canonical_exe_path_resolves_a_symlink() {
891        let dir = std::env::temp_dir().join(format!(
892            "computer-use-target-{}-{}",
893            std::process::id(),
894            std::time::SystemTime::now()
895                .duration_since(std::time::UNIX_EPOCH)
896                .map(|duration| duration.as_nanos())
897                .unwrap_or(0)
898        ));
899        let _ = std::fs::remove_dir_all(&dir);
900        std::fs::create_dir_all(&dir).unwrap();
901        let real = dir.join("Notes");
902        std::fs::write(&real, b"x").unwrap();
903        let link = dir.join("alias");
904        std::os::unix::fs::symlink(&real, &link).unwrap();
905        let canonical = canonical_exe_path(&link);
906        assert_eq!(
907            canonical.file_name().and_then(|name| name.to_str()),
908            Some("Notes")
909        );
910        let _ = std::fs::remove_dir_all(&dir);
911    }
912
913    #[test]
914    fn pending_doctor_names_the_phase() {
915        let report = DoctorReport::pending("P13");
916        assert!(report.accessibility.is_none());
917        assert!(report.screen_capture.is_none());
918        assert!(report.input_monitor.is_none());
919        assert!(report.note.unwrap().contains("P13"));
920    }
921}