#![allow(
dead_code,
reason = "unused when the mock-loader feature selects the DLL backend instead"
)]
use super::ax;
use super::permissions::{self, Permission};
use crate::options::{ShowState, TitleMatchMode, WinState};
use crate::selector::Criterion;
use crate::{Point, Rect, Selector, Size};
use objc2_app_kit::NSRunningApplication;
use objc2_application_services::AXUIElement;
use objc2_core_foundation::{CFDictionary, CFNumber, CFRetained, CFString, CGPoint, CGSize};
use objc2_core_graphics::{
CGWindowListCopyWindowInfo, CGWindowListOption, kCGNullWindowID, kCGWindowBounds,
kCGWindowLayer, kCGWindowOwnerPID,
};
use objc2_foundation::MainThreadMarker;
use std::time::Duration;
fn title_of(element: &objc2_application_services::AXUIElement) -> String {
ax::string_attribute(element, ax::ATTR_TITLE)
.unwrap_or_default()
.trim_end_matches('\0')
.to_owned()
}
const AX_TIMEOUT_SECS: f32 = 0.25;
pub(crate) struct Window {
pub(crate) element: CFRetained<AXUIElement>,
pub(crate) pid: i32,
pub(crate) title: String,
}
impl Window {
pub(crate) fn position(&self) -> Option<Point> {
ax::point_attribute(&self.element, ax::ATTR_POSITION)
.map(|p| Point::new(p.x as i32, p.y as i32))
}
pub(crate) fn size(&self) -> Option<Size> {
ax::size_attribute(&self.element, ax::ATTR_SIZE)
.map(|s| Size::new(s.width as i32, s.height as i32))
}
pub(crate) fn rect(&self) -> Option<Rect> {
let p = self.position()?;
let s = self.size()?;
Some(Rect::new(p.x, p.y, s.w, s.h))
}
#[cfg(test)]
pub(crate) fn for_test(pid: i32, title: &str) -> Self {
Self {
element: ax::app_element(pid),
pid,
title: title.to_owned(),
}
}
}
pub(crate) fn all_windows() -> crate::Result<Vec<Window>> {
permissions::require(Permission::Accessibility)?;
let mut out = Vec::new();
for pid in running_pids() {
let app = ax::app_element(pid);
ax::set_timeout(&app, AX_TIMEOUT_SECS);
for element in ax::element_array(&app, ax::ATTR_WINDOWS) {
let title = title_of(&element);
out.push(Window {
element,
pid,
title,
});
}
}
Ok(out)
}
fn running_pids() -> Vec<i32> {
let Some(list) = CGWindowListCopyWindowInfo(
CGWindowListOption::OptionAll | CGWindowListOption::ExcludeDesktopElements,
kCGNullWindowID,
) else {
return Vec::new();
};
let mut pids: Vec<i32> = Vec::new();
for i in 0..list.count() {
let raw = unsafe { list.value_at_index(i) };
if raw.is_null() {
continue;
}
let entry = unsafe { &*raw.cast::<CFDictionary>() };
if let Some(pid) = number_in(entry, unsafe { kCGWindowOwnerPID }) {
let pid = pid as i32;
if pid > 0 && !pids.contains(&pid) {
pids.push(pid);
}
}
}
pids
}
pub(crate) fn bundle_id(pid: i32) -> Option<String> {
let app = NSRunningApplication::runningApplicationWithProcessIdentifier(pid)?;
app.bundleIdentifier().map(|s| s.to_string())
}
pub(crate) fn active_window() -> crate::Result<Option<Window>> {
permissions::require(Permission::Accessibility)?;
let Some(app) = objc2_app_kit::NSWorkspace::sharedWorkspace().frontmostApplication() else {
return Ok(None);
};
let pid = app.processIdentifier();
let app_element = ax::app_element(pid);
ax::set_timeout(&app_element, AX_TIMEOUT_SECS);
let Some(value) = ax::attribute(&app_element, ax::ATTR_FOCUSED_WINDOW) else {
return Ok(None);
};
let Some(element) = value.downcast_ref::<AXUIElement>() else {
return Ok(None);
};
let element = unsafe { CFRetained::retain(std::ptr::NonNull::from(element)) };
let title = title_of(&element);
Ok(Some(Window {
element,
pid,
title,
}))
}
pub(crate) fn title_matches(title: &str, wanted: &str, mode: TitleMatchMode, ci: bool) -> bool {
let (t, w) = if ci {
(title.to_lowercase(), wanted.to_lowercase())
} else {
(title.to_owned(), wanted.to_owned())
};
match mode {
TitleMatchMode::StartsWith => t.starts_with(&w),
TitleMatchMode::Substring => t.contains(&w),
TitleMatchMode::Exact => t == w,
TitleMatchMode::Advanced => false,
}
}
pub(crate) fn regexp_matches(title: &str, pattern: &str) -> bool {
let cleaned = pattern.replace("(.*)", "\u{0}").replace(".*", "\u{0}");
let parts: Vec<&str> = cleaned.split('\u{0}').collect();
let mut rest = title;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !rest.starts_with(part) {
return false;
}
rest = &rest[part.len()..];
} else if let Some(at) = rest.find(part) {
rest = &rest[at + part.len()..];
} else {
return false;
}
}
true
}
pub(crate) fn matches(
window: &Window,
selector: &Selector,
mode: TitleMatchMode,
ci: bool,
) -> bool {
if let Some(bare) = selector.bare_title() {
return title_matches(&window.title, bare, mode, ci);
}
let Some(criteria) = selector.criteria() else {
return false;
};
criteria.iter().all(|c| match c {
Criterion::Title(t) => title_matches(&window.title, t, mode, ci),
Criterion::RegexpTitle(p) => regexp_matches(&window.title, p),
Criterion::Class(c) => {
bundle_id(window.pid).is_some_and(|b| b.eq_ignore_ascii_case(c))
|| ax::string_attribute(&window.element, ax::ATTR_ROLE)
.is_some_and(|r| r.eq_ignore_ascii_case(c))
|| ax::string_attribute(&window.element, ax::ATTR_SUBROLE)
.is_some_and(|r| r.eq_ignore_ascii_case(c))
}
Criterion::RegexpClass(p) => bundle_id(window.pid).is_some_and(|b| regexp_matches(&b, p)),
Criterion::Pid(p) => window.pid as u32 == *p,
Criterion::X(v) => window.position().is_some_and(|p| p.x == *v),
Criterion::Y(v) => window.position().is_some_and(|p| p.y == *v),
Criterion::W(v) => window.size().is_some_and(|s| s.w == *v),
Criterion::H(v) => window.size().is_some_and(|s| s.h == *v),
Criterion::Active | Criterion::All | Criterion::Last => true,
Criterion::Handle(_) | Criterion::Instance(_) => true,
})
}
pub(crate) fn find(
selector: &Selector,
mode: TitleMatchMode,
ci: bool,
) -> crate::Result<Option<Window>> {
if selector.is_active() {
return active_window();
}
let mut windows = all_windows()?;
let position = windows.iter().position(|w| matches(w, selector, mode, ci));
Ok(position.map(|i| windows.swap_remove(i)))
}
pub(crate) fn activate(window: &Window) -> bool {
if let Some(app) = NSRunningApplication::runningApplicationWithProcessIdentifier(window.pid) {
app.activateWithOptions(objc2_app_kit::NSApplicationActivationOptions::empty());
}
ax::perform(&window.element, ax::ACTION_RAISE)
}
pub(crate) fn is_active(window: &Window) -> bool {
if !ax::bool_attribute(&ax::app_element(window.pid), ax::ATTR_FRONTMOST).unwrap_or(false) {
return false;
}
ax::bool_attribute(&window.element, ax::ATTR_FOCUSED).unwrap_or(false)
|| ax::bool_attribute(&window.element, ax::ATTR_MAIN).unwrap_or(false)
}
pub(crate) fn set_rect(window: &Window, r: Rect) -> bool {
let moved = ax::set_point(
&window.element,
ax::ATTR_POSITION,
CGPoint {
x: f64::from(r.x),
y: f64::from(r.y),
},
);
let sized = ax::set_size(
&window.element,
ax::ATTR_SIZE,
CGSize {
width: f64::from(r.w),
height: f64::from(r.h),
},
);
moved && sized
}
pub(crate) fn maximize(window: &Window) -> bool {
let Some(frame) = visible_frame() else {
return false;
};
set_rect(window, frame)
}
fn visible_frame() -> Option<Rect> {
if let Some(mtm) = MainThreadMarker::new() {
if let Some(screen) = objc2_app_kit::NSScreen::mainScreen(mtm) {
let (full, visible) = (screen.frame(), screen.visibleFrame());
let y = full.size.height - (visible.origin.y + visible.size.height);
return Some(Rect::new(
visible.origin.x as i32,
y as i32,
visible.size.width as i32,
visible.size.height as i32,
));
}
}
let bounds = objc2_core_graphics::CGDisplayBounds(objc2_core_graphics::CGMainDisplayID());
let menu_bar = menu_bar_height();
Some(Rect::new(
bounds.origin.x as i32,
bounds.origin.y as i32 + menu_bar,
bounds.size.width as i32,
bounds.size.height as i32 - menu_bar,
))
}
fn menu_bar_height() -> i32 {
const MENU_BAR_LAYER: i64 = 24;
let Some(list) =
CGWindowListCopyWindowInfo(CGWindowListOption::OptionOnScreenOnly, kCGNullWindowID)
else {
return 0;
};
for i in 0..list.count() {
let raw = unsafe { list.value_at_index(i) };
if raw.is_null() {
continue;
}
let entry = unsafe { &*raw.cast::<CFDictionary>() };
if number_in(entry, unsafe { kCGWindowLayer }) != Some(MENU_BAR_LAYER) {
continue;
}
let raw_bounds = unsafe { entry.value(std::ptr::from_ref(kCGWindowBounds).cast()) };
if raw_bounds.is_null() {
continue;
}
let bounds = unsafe { &*raw_bounds.cast::<CFDictionary>() };
let y = number_in(bounds, &CFString::from_str("Y"));
let height = number_in(bounds, &CFString::from_str("Height"));
if y == Some(0) {
if let Some(h) = height {
return h as i32;
}
}
}
0
}
fn number_in(dict: &CFDictionary, key: &CFString) -> Option<i64> {
let value = unsafe { dict.value(std::ptr::from_ref(key).cast()) };
if value.is_null() {
return None;
}
let number = unsafe { &*value.cast::<CFNumber>() };
let mut out: i64 = 0;
let ok = unsafe {
number.value(
objc2_core_foundation::CFNumberType::SInt64Type,
std::ptr::from_mut(&mut out).cast::<std::ffi::c_void>(),
)
};
ok.then_some(out)
}
pub(crate) fn close(window: &Window) -> bool {
for child in ax::element_array(&window.element, ax::ATTR_CHILDREN) {
if ax::string_attribute(&child, ax::ATTR_SUBROLE).as_deref() == Some(SUBROLE_CLOSE_BUTTON) {
return ax::perform(&child, ax::ACTION_PRESS);
}
}
false
}
const SUBROLE_CLOSE_BUTTON: &str = "AXCloseButton";
pub(crate) fn set_show_state(window: &Window, state: ShowState) -> bool {
match state {
ShowState::Maximize => maximize(window),
ShowState::Minimize | ShowState::ShowMinimized | ShowState::ShowMinNoActive => {
ax::set_bool(&window.element, ax::ATTR_MINIMIZED, true)
}
ShowState::ShowNormal | ShowState::Show | ShowState::Restore => {
let restored = ax::set_bool(&window.element, ax::ATTR_MINIMIZED, false);
activate(window);
restored
}
ShowState::ShowNoActivate | ShowState::ShowNa => {
ax::set_bool(&window.element, ax::ATTR_MINIMIZED, false)
}
ShowState::Hide => false,
}
}
const MAX_DEPTH: usize = 12;
pub(crate) fn text_of(window: &Window) -> String {
let mut out = Vec::new();
collect(&window.element, 0, &mut |element| {
let text = ax::string_attribute(element, ax::ATTR_VALUE)
.or_else(|| ax::string_attribute(element, ax::ATTR_TITLE));
if let Some(text) = text {
if !text.is_empty() {
out.push(text);
}
}
});
out.join("\n")
}
pub(crate) fn roles_of(window: &Window) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
collect(&window.element, 0, &mut |element| {
if let Some(role) = ax::string_attribute(element, ax::ATTR_ROLE) {
seen.insert(role);
}
});
seen.into_iter().collect()
}
fn collect(element: &AXUIElement, depth: usize, f: &mut impl FnMut(&AXUIElement)) {
if depth > MAX_DEPTH {
return;
}
f(element);
for child in ax::element_array(element, ax::ATTR_CHILDREN) {
collect(&child, depth + 1, f);
}
}
pub(crate) fn state(window: &Window) -> WinState {
let mut s = WinState::EXISTS | WinState::ENABLED;
if ax::bool_attribute(&window.element, ax::ATTR_MINIMIZED).unwrap_or(false) {
s |= WinState::MINIMIZED;
} else {
s |= WinState::VISIBLE;
}
if is_active(window) {
s |= WinState::ACTIVE;
}
s
}
pub(crate) fn is_app_responsive(pid: i32, timeout: Duration) -> bool {
let app = ax::app_element(pid);
ax::set_timeout(&app, timeout.as_secs_f32().max(0.05));
ax::attribute(&app, ax::ATTR_FOCUSED_WINDOW).is_some()
|| ax::attribute(&app, ax::ATTR_ROLE).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_titles_match_by_prefix_by_default() {
assert!(title_matches(
"Untitled - TextEdit",
"Untitled",
TitleMatchMode::StartsWith,
false
));
assert!(!title_matches(
"Untitled - TextEdit",
"TextEdit",
TitleMatchMode::StartsWith,
false
));
}
#[test]
fn the_other_match_modes_behave_as_named() {
let t = "Untitled - TextEdit";
assert!(title_matches(
t,
"TextEdit",
TitleMatchMode::Substring,
false
));
assert!(!title_matches(t, "Untitled", TitleMatchMode::Exact, false));
assert!(title_matches(t, t, TitleMatchMode::Exact, false));
assert!(!title_matches(t, t, TitleMatchMode::Advanced, false));
}
#[test]
fn case_insensitivity_is_opt_in() {
assert!(!title_matches(
"Untitled",
"untitled",
TitleMatchMode::Exact,
false
));
assert!(title_matches(
"Untitled",
"untitled",
TitleMatchMode::Exact,
true
));
}
#[test]
fn the_regexp_patterns_production_automation_uses_all_work() {
assert!(regexp_matches(
"Acme - NORTHWIND - Quality Certificate",
"Acme - NORTHWIND(.*)Certificate(.*)"
));
assert!(regexp_matches(
"Acme - Invoice Issue - Outbound",
"Acme - (.*)Invoice Issue(.*)"
));
assert!(regexp_matches("DevTools - localhost", "DevTools - (.*)"));
assert!(!regexp_matches(
"Something else",
"Acme - (.*)Certificate(.*)"
));
assert!(!regexp_matches(
"Acme - Invoice",
"Acme - (.*)Certificate(.*)"
));
}
#[test]
fn a_regexp_without_a_leading_wildcard_anchors_at_the_start() {
assert!(regexp_matches("Acme Invoices", "Acme(.*)"));
assert!(!regexp_matches("The Acme Invoices", "Acme(.*)"));
}
#[test]
fn matching_requires_every_criterion_not_merely_one() {
let selector = Selector::from("[TITLE:Untitled;PID:1]");
let criteria = selector.criteria().expect("advanced selector");
assert_eq!(criteria.len(), 2);
}
}
#[cfg(test)]
mod live {
use super::*;
fn windows_or_skip() -> Vec<Window> {
match all_windows() {
Ok(w) => w,
Err(e) => panic!("grant Accessibility to this terminal first: {e}"),
}
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn enumerates_real_windows_with_titles_and_bundle_ids() {
let windows = windows_or_skip();
println!("{} windows", windows.len());
for w in &windows {
println!(
" pid {:<7} {:<34} {:?}",
w.pid,
bundle_id(w.pid).unwrap_or_default(),
w.title
);
}
assert!(
!windows.is_empty(),
"no windows at all — is the screen locked?"
);
assert!(
windows.iter().any(|w| !w.title.is_empty()),
"every window came back untitled, which means AX is not answering"
);
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn the_matcher_finds_a_real_window_by_every_supported_criterion() {
let windows = windows_or_skip();
let target = windows
.iter()
.find(|w| !w.title.is_empty() && bundle_id(w.pid).is_some())
.expect("at least one titled window from a bundled application");
let bundle = bundle_id(target.pid).expect("checked above");
println!("target: {:?} from {bundle}", target.title);
let exact = Selector::from(format!("[TITLE:{}]", target.title).as_str());
assert!(
matches(target, &exact, TitleMatchMode::Exact, false),
"TITLE did not match its own window"
);
let prefix: String = target.title.chars().take(4).collect();
assert!(
matches(
target,
&Selector::title(&prefix),
TitleMatchMode::StartsWith,
false
),
"a {} character prefix did not match {:?}",
prefix.len(),
target.title
);
let by_class = Selector::from(format!("[CLASS:{bundle}]").as_str());
assert!(
matches(target, &by_class, TitleMatchMode::StartsWith, false),
"CLASS did not match the bundle id"
);
let by_regexp = Selector::from(format!("[REGEXPTITLE:{prefix}(.*)]").as_str());
assert!(
matches(target, &by_regexp, TitleMatchMode::StartsWith, false),
"REGEXPTITLE did not match"
);
let by_pid = Selector::from(format!("[PID:{}]", target.pid).as_str());
assert!(matches(target, &by_pid, TitleMatchMode::StartsWith, false));
let wrong = Selector::from("[TITLE:definitely not a real window title]");
assert!(!matches(target, &wrong, TitleMatchMode::Exact, false));
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn the_frontmost_window_reports_itself_as_active() {
let active = active_window()
.expect("accessibility grant")
.expect("something is frontmost");
println!("active: pid {} {:?}", active.pid, active.title);
assert!(
is_active(&active),
"the focused window is not seen as active"
);
assert!(state(&active).contains(WinState::ACTIVE | WinState::EXISTS));
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn a_window_reports_a_position_and_a_plausible_size() {
let windows = windows_or_skip();
let w = windows
.iter()
.find(|w| w.rect().is_some_and(|r| r.w > 0 && r.h > 0))
.expect("at least one window with a real frame");
let r = w.rect().expect("checked above");
println!("{:?} at {r:?}", w.title);
assert!(r.w < 20_000 && r.h < 20_000, "implausible size {r:?}");
assert!(r.x > -20_000 && r.y > -20_000, "implausible origin {r:?}");
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn the_visible_frame_excludes_the_menu_bar() {
let Some(frame) = visible_frame() else {
println!("not on the main thread; skipped");
return;
};
println!("visible frame: {frame:?}");
assert!(frame.w > 0 && frame.h > 0);
assert!(
frame.y > 0,
"visible frame starts at the very top: {frame:?}"
);
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn a_windows_text_and_roles_can_be_read() {
let windows = windows_or_skip();
let w = windows
.iter()
.find(|w| !roles_of(w).is_empty())
.expect("at least one window with an accessibility tree");
let roles = roles_of(w);
println!("{:?} contains roles {roles:?}", w.title);
assert!(
roles.iter().any(|r| r == "AXWindow"),
"a window's own role should be AXWindow, got {roles:?}"
);
let text = text_of(w);
println!(
"first 200 chars of text: {:?}",
text.chars().take(200).collect::<String>()
);
}
#[test]
#[ignore = "needs a real desktop and the Accessibility grant"]
fn a_live_application_is_responsive_and_a_dead_pid_is_not() {
let active = active_window()
.expect("accessibility grant")
.expect("something is frontmost");
assert!(
is_app_responsive(active.pid, Duration::from_millis(500)),
"the frontmost application did not answer"
);
assert!(!is_app_responsive(999_999, Duration::from_millis(500)));
}
}