diff --git a/crates/macos/src/system/window_inventory.rs b/crates/macos/src/system/window_inventory.rs index bdaf3c8..44040b0 100644 --- a/crates/macos/src/system/window_inventory.rs +++ b/crates/macos/src/system/window_inventory.rs @@ -166,8 +166,12 @@ fn ax_window_for_app(app_info: &AppInfo) -> Option { let window = focused_window_element(&app) .or_else(|| crate::tree::copy_element_attr(&app, "AXMainWindow")) .or_else(|| { - crate::tree::copy_ax_array(&app, "AXWindows") - .and_then(|windows| windows.into_iter().next()) + let windows = crate::tree::copy_ax_array(&app, "AXWindows")?; + child_bearing_window_index(&windows, |window| { + crate::tree::element::count_children(window, None) + }) + .map(|index| windows[index].clone()) + .or_else(|| windows.into_iter().next()) })?; if crate::tree::copy_string_attr(&window, "AXRole").as_deref() != Some("AXWindow") { return None; @@ -195,6 +199,13 @@ fn ax_window_info( } } +fn child_bearing_window_index( + windows: &[T], + mut count_children: impl FnMut(&T) -> u32, +) -> Option { + windows.iter().position(|window| count_children(window) > 0) +} + type FocusedWindowIdentity = Option<(Option, Option)>; fn focused_window_identity(pid: i32) -> FocusedWindowIdentity { diff --git a/crates/macos/src/system/window_inventory_tests.rs b/crates/macos/src/system/window_inventory_tests.rs index d312763..ed697b3 100644 --- a/crates/macos/src/system/window_inventory_tests.rs +++ b/crates/macos/src/system/window_inventory_tests.rs @@ -213,6 +213,23 @@ fn ax_window_info_uses_resolved_app_identity() { assert_eq!(window.id, "w-7"); } +#[test] +fn child_bearing_window_index_prefers_first_window_with_children() { + let windows = [0, 7, 3]; + + assert_eq!( + child_bearing_window_index(&windows, |count| *count), + Some(1) + ); +} + +#[test] +fn child_bearing_window_index_returns_none_when_all_windows_are_empty() { + let windows = [0, 0]; + + assert_eq!(child_bearing_window_index(&windows, |count| *count), None); +} + fn app(name: &str, pid: i32) -> AppInfo { AppInfo { name: name.to_string(), diff --git a/crates/macos/src/tree/builder.rs b/crates/macos/src/tree/builder.rs index 610ee56..162cdef 100644 --- a/crates/macos/src/tree/builder.rs +++ b/crates/macos/src/tree/builder.rs @@ -23,7 +23,10 @@ pub fn window_element_for(pid: i32, win_title: &str) -> AXElement { if let Some(windows) = copy_ax_array(&app, kAXWindowsAttribute) { for win in &windows { let title = copy_string_attr(win, kAXTitleAttribute); - if title.as_deref() == Some(win_title) { + if title + .as_deref() + .is_some_and(|title| window_titles_are_exact_match(title, win_title)) + { return win.clone(); } } @@ -31,7 +34,7 @@ pub fn window_element_for(pid: i32, win_title: &str) -> AXElement { let title = copy_string_attr(win, kAXTitleAttribute); if title .as_deref() - .is_some_and(|t| !t.is_empty() && (t.contains(win_title) || win_title.contains(t))) + .is_some_and(|title| window_titles_are_partial_match(title, win_title)) { return win.clone(); } @@ -49,6 +52,18 @@ pub fn window_element_for(pid: i32, win_title: &str) -> AXElement { app } +#[cfg(target_os = "macos")] +fn window_titles_are_exact_match(candidate_title: &str, requested_title: &str) -> bool { + !candidate_title.is_empty() && !requested_title.is_empty() && candidate_title == requested_title +} + +#[cfg(target_os = "macos")] +fn window_titles_are_partial_match(candidate_title: &str, requested_title: &str) -> bool { + !candidate_title.is_empty() + && !requested_title.is_empty() + && (candidate_title.contains(requested_title) || requested_title.contains(candidate_title)) +} + #[cfg(target_os = "macos")] pub fn build_subtree( el: &AXElement, diff --git a/crates/macos/src/tree/builder_tests.rs b/crates/macos/src/tree/builder_tests.rs index 5951cc8..31c93d0 100644 --- a/crates/macos/src/tree/builder_tests.rs +++ b/crates/macos/src/tree/builder_tests.rs @@ -1,4 +1,7 @@ -use super::{child_attributes, redact_secure_value}; +use super::{ + child_attributes, redact_secure_value, window_titles_are_exact_match, + window_titles_are_partial_match, +}; #[test] fn test_browser_children_use_columns() { @@ -27,3 +30,25 @@ fn test_secure_text_value_is_redacted() { Some("visible".into()) ); } + +#[test] +fn window_title_matching_rejects_empty_titles() { + assert!(!window_titles_are_exact_match("", "")); + assert!(!window_titles_are_exact_match("Inbox", "")); + assert!(!window_titles_are_exact_match("", "Inbox")); + assert!(!window_titles_are_partial_match("Inbox", "")); + assert!(!window_titles_are_partial_match("", "Inbox")); +} + +#[test] +fn window_title_matching_accepts_exact_and_truncated_titles() { + assert!(window_titles_are_exact_match("Inbox", "Inbox")); + assert!(window_titles_are_partial_match( + "noy4/agent-desktop: Native desktop automation", + "noy4/agent-desktop" + )); + assert!(window_titles_are_partial_match( + "noy4/agent-desktop: Native desk...", + "noy4/agent-desktop: Native desk" + )); +}