fix: harden macos ax window fallback

This commit is contained in:
Lahfir 2026-06-05 17:57:39 -07:00
parent a52b7c704a
commit 3b266fdf04
4 changed files with 73 additions and 5 deletions

View file

@ -166,8 +166,12 @@ fn ax_window_for_app(app_info: &AppInfo) -> Option<WindowInfo> {
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<T>(
windows: &[T],
mut count_children: impl FnMut(&T) -> u32,
) -> Option<usize> {
windows.iter().position(|window| count_children(window) > 0)
}
type FocusedWindowIdentity = Option<(Option<String>, Option<i64>)>;
fn focused_window_identity(pid: i32) -> FocusedWindowIdentity {

View file

@ -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(),

View file

@ -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,

View file

@ -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"
));
}