use accessibility_sys::{
AXUIElementCreateApplication, AXUIElementRef, AXValueCreate, AXValueGetValue,
kAXFloatingWindowSubrole, kAXPositionAttribute, kAXRaiseAction, kAXSizeAttribute,
kAXStandardWindowSubrole, kAXUnknownSubrole, kAXValueTypeCGPoint, kAXValueTypeCGSize,
kAXWindowRole,
};
use bevy::ecs::component::Component;
use bevy::math::IRect;
use core::ptr::NonNull;
use derive_more::{DerefMut, with_trait::Deref};
use mockall::automock;
use objc2_core_foundation::{
CFArray, CFBoolean, CFNumber, CFRetained, CFString, CFType, CGPoint, CGRect, CGSize,
kCFBooleanFalse, kCFBooleanTrue,
};
use std::collections::{HashMap, HashSet};
use std::ptr::null_mut;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock, RwLock};
use std::thread;
use std::time::Duration;
use stdext::function_name;
use stdext::sync::rw_lock::RwLockExt;
use tracing::{Level, debug, instrument, trace, warn};
use super::skylight::{
_AXUIElementGetWindow, _SLPSSetFrontProcessWithOptions, AXUIElementCopyAttributeValue,
AXUIElementPerformAction, AXUIElementSetAttributeValue, SLPSPostEventRecordTo,
SLSWindowIteratorAdvance,
};
use crate::config::Config;
use crate::errors::{Error, Result};
use crate::manager::{Origin, Size, irect_from};
use crate::platform::{Pid, ProcessSerialNumber, WinID, macos_major_version};
use crate::util::{AXUIAttributes, AXUIWrapper, MacResult};
static ENHANCED_UI_REFCOUNT: LazyLock<Mutex<HashMap<Pid, usize>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static ENHANCED_UI_ABSENT: LazyLock<RwLock<HashSet<Pid>>> =
LazyLock::new(|| RwLock::new(HashSet::new()));
fn resize_staging_origin(
previous_frame: IRect,
actual_frame: IRect,
target_width: i32,
) -> Option<Origin> {
let actual_width = actual_frame.width();
(actual_width > previous_frame.width() && actual_width < target_width).then(|| {
actual_frame
.min
.with_x(actual_frame.min.x - (target_width - actual_width))
})
}
#[derive(Debug)]
pub enum WindowPadding {
Vertical(i32),
Horizontal(i32),
}
#[automock]
pub trait WindowApi: Send + Sync {
fn id(&self) -> WinID;
fn frame(&self) -> IRect;
fn element(&self) -> Option<CFRetained<AXUIWrapper>>;
fn title(&self) -> Result<String>;
fn invalidate_title(&self);
fn identifier(&self) -> Result<String>;
fn child_role(&self) -> Result<bool>;
fn role(&self) -> Result<String>;
fn subrole(&self) -> Result<String>;
fn is_minimized(&self) -> bool;
fn is_full_screen(&self) -> bool;
fn reposition(&mut self, origin: Origin);
fn resize(&mut self, size: Size);
fn update_frame(&mut self) -> Result<IRect>;
fn focus_without_raise(
&self,
psn: ProcessSerialNumber,
currently_focused: &Window,
focused_psn: ProcessSerialNumber,
);
fn focus_with_raise(&self, psn: ProcessSerialNumber);
fn raise_without_focus(&self);
fn pid(&self) -> Result<Pid>;
fn set_padding(&mut self, padding: WindowPadding);
fn horizontal_padding(&self) -> i32;
fn vertical_padding(&self) -> i32;
fn border_radius(&self) -> Option<f64>;
}
#[derive(Component, Deref, DerefMut)]
pub struct Window(Box<dyn WindowApi>);
impl Window {
pub fn new(window: Box<dyn WindowApi>) -> Self {
Window(window)
}
}
pub fn ax_window_id(element_ref: AXUIElementRef) -> Result<WinID> {
try_ax_window_id(element_ref).ok_or_else(|| {
Error::InvalidInput(format!(
"{}: Unable to get window id from element {element_ref:?}.",
function_name!()
))
})
}
pub fn try_ax_window_id(element_ref: AXUIElementRef) -> Option<WinID> {
let ptr = NonNull::new(element_ref)?;
let mut window_id: WinID = 0;
if unsafe { _AXUIElementGetWindow(ptr.as_ptr(), &mut window_id) } != 0 || window_id == 0 {
return None;
}
Some(window_id)
}
const CPS_USER_GENERATED: u32 = 0x200;
#[derive(Debug)]
pub struct WindowOS {
id: WinID,
ax_element: CFRetained<AXUIWrapper>,
frame: IRect,
vertical_padding: i32,
horizontal_padding: i32,
border_radius: OnceLock<Option<f64>>,
pid: OnceLock<Result<Pid>>,
app_reference: OnceLock<Option<CFRetained<AXUIWrapper>>>,
enhanced_ui_absent: AtomicBool,
title: RwLock<Option<String>>,
}
impl WindowOS {
#[instrument(level = Level::TRACE, ret)]
pub fn new(element: &CFRetained<AXUIWrapper>) -> Result<Self> {
Self::new_with_config(element, &Config::default(), None)
}
#[instrument(level = Level::TRACE, ret)]
pub fn new_with_config(
element: &CFRetained<AXUIWrapper>,
config: &Config,
bundle_id: Option<&str>,
) -> Result<Self> {
let id = ax_window_id(element.as_ptr())?;
let window = Self {
id,
ax_element: element.clone(),
frame: IRect::default(),
vertical_padding: 0,
horizontal_padding: 0,
border_radius: OnceLock::new(),
pid: OnceLock::new(),
app_reference: OnceLock::new(),
enhanced_ui_absent: AtomicBool::new(false),
title: RwLock::new(None),
};
let forced = window.is_forced_manage(config, bundle_id);
if window.is_unknown() && !forced {
return Err(Error::invalid_window(&format!(
"Ignoring AXUnknown window, id: {}, role {}, subrole {}",
window.id(),
window.role().unwrap_or_default(),
window.subrole().unwrap_or_default(),
)));
}
if !window.is_real() && !forced {
return Err(Error::invalid_window(&format!(
"Ignoring non-real window, id: {}, role {}, subrole {}",
window.id(),
window.role().unwrap_or_default(),
window.subrole().unwrap_or_default(),
)));
}
trace!(
"created {} title: {} role: {} subrole: {}",
window.id(),
window.title().unwrap_or_default(),
window.role().unwrap_or_default(),
window.subrole().unwrap_or_default(),
);
Ok(window)
}
fn is_forced_manage(&self, config: &Config, bundle_id: Option<&str>) -> bool {
let Ok(title) = self.title() else {
return false;
};
config
.find_window_properties(&title, bundle_id.unwrap_or_default())
.iter()
.any(|params| params.manage.is_some_and(|manage| manage))
}
fn is_unknown(&self) -> bool {
self.subrole()
.is_ok_and(|subrole| subrole.eq(kAXUnknownSubrole))
}
fn is_real(&self) -> bool {
let role = self.role().ok();
let subrole = self.subrole().ok();
subrole.as_deref() == Some(kAXStandardWindowSubrole)
|| (role.as_deref() == Some(kAXWindowRole)
&& subrole.as_deref() == Some(kAXFloatingWindowSubrole))
}
fn app_reference(&self) -> Option<CFRetained<AXUIWrapper>> {
self.app_reference
.get_or_init(|| {
self.pid()
.map(|pid| unsafe { AXUIElementCreateApplication(pid) })
.and_then(AXUIWrapper::from_retained)
.inspect_err(|err| warn!("error getting app reference: {err}"))
.ok()
})
.clone()
}
fn disable_enhanced_ui(&self) {
if self.enhanced_ui_absent.load(Ordering::Relaxed) {
return;
}
let Ok(pid) = self.pid() else { return };
if ENHANCED_UI_ABSENT
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(&pid)
{
self.enhanced_ui_absent.store(true, Ordering::Relaxed);
return;
}
{
let mut counts = ENHANCED_UI_REFCOUNT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(count) = counts.get_mut(&pid) {
*count += 1;
return;
}
}
let Some(app_element) = self.app_reference() else {
return;
};
let attr = CFString::from_static_str("AXEnhancedUserInterface");
let enabled = app_element
.get_attribute::<CFBoolean>(&attr)
.is_ok_and(|v| CFBoolean::value(&v));
if enabled {
unsafe {
AXUIElementSetAttributeValue(
app_element.as_ptr(),
attr.as_ref(),
kCFBooleanFalse.unwrap(),
);
}
*ENHANCED_UI_REFCOUNT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(pid)
.or_insert(0) += 1;
} else {
ENHANCED_UI_ABSENT
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(pid);
self.enhanced_ui_absent.store(true, Ordering::Relaxed);
}
}
fn reenable_enhanced_ui(&self) {
if self.enhanced_ui_absent.load(Ordering::Relaxed) {
return;
}
let Ok(pid) = self.pid() else { return };
let mut counts = ENHANCED_UI_REFCOUNT
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(count) = counts.get_mut(&pid) else {
return;
};
*count = count.saturating_sub(1);
if *count > 0 {
return;
}
counts.remove(&pid);
drop(counts);
if let Some(app_element) = self.app_reference() {
let attr = CFString::from_static_str("AXEnhancedUserInterface");
unsafe {
AXUIElementSetAttributeValue(
app_element.as_ptr(),
attr.as_ref(),
kCFBooleanTrue.unwrap(),
);
}
}
}
fn set_ax_position(&mut self, origin: Origin) {
let mut point = CGPoint::new(
f64::from(origin.x + self.horizontal_padding),
f64::from(origin.y + self.vertical_padding),
);
let position_ref = unsafe {
AXValueCreate(
kAXValueTypeCGPoint,
NonNull::from(&mut point).as_ptr().cast(),
)
};
if let Ok(position) = AXUIWrapper::from_retained(position_ref) {
unsafe {
AXUIElementSetAttributeValue(
self.ax_element.as_ptr(),
CFString::from_static_str(kAXPositionAttribute).as_ref(),
position.as_ref(),
)
};
let size = self.frame.size();
self.frame.min = origin;
self.frame.max = origin + size;
}
}
fn set_ax_size(&mut self, size: Size) {
let width_padding = 2 * self.horizontal_padding;
let height_padding = 2 * self.vertical_padding;
let mut cgsize = CGSize::new(
f64::from(size.x - width_padding),
f64::from(size.y - height_padding),
);
let size_ref = unsafe {
AXValueCreate(
kAXValueTypeCGSize,
NonNull::from(&mut cgsize).as_ptr().cast(),
)
};
if let Ok(size_value) = AXUIWrapper::from_retained(size_ref) {
unsafe {
AXUIElementSetAttributeValue(
self.ax_element.as_ptr(),
CFString::from_static_str(kAXSizeAttribute).as_ref(),
size_value.as_ref(),
)
};
self.frame.max = self.frame.min + size;
}
}
fn make_key_window(&self, psn: &ProcessSerialNumber) {
if macos_major_version() == 14 {
debug!("make_key_window: skipped on macOS 14 (Sonoma) to prevent crash");
return;
}
let window_id = self.id();
let mut event_bytes = [0u8; 0xf8];
event_bytes[0x04] = 0xf8;
event_bytes[0x3a] = 0x10;
event_bytes[0x3c..0x40].copy_from_slice(&window_id.to_ne_bytes());
event_bytes[0x20..0x30].fill(0xff);
event_bytes[0x08] = 0x01;
unsafe { SLPSPostEventRecordTo(psn, event_bytes.as_ptr().cast()) };
event_bytes[0x08] = 0x02;
unsafe { SLPSPostEventRecordTo(psn, event_bytes.as_ptr().cast()) };
}
}
impl WindowApi for WindowOS {
fn id(&self) -> WinID {
self.id
}
fn frame(&self) -> IRect {
self.frame
}
fn element(&self) -> Option<CFRetained<AXUIWrapper>> {
Some(self.ax_element.clone())
}
fn title(&self) -> Result<String> {
if let Some(cached) = self.title.force_read().clone() {
return Ok(cached);
}
let title = self.ax_element.title()?;
*self.title.force_write() = Some(title.clone());
Ok(title)
}
fn invalidate_title(&self) {
self.title.force_write().take();
}
fn identifier(&self) -> Result<String> {
self.ax_element.identifier()
}
fn child_role(&self) -> Result<bool> {
let role = self.role()?;
Ok(["AXSheet", "AXDrawer"]
.iter()
.any(|axrole| axrole.eq(&role)))
}
fn role(&self) -> Result<String> {
self.ax_element.role()
}
fn subrole(&self) -> Result<String> {
self.ax_element.subrole()
}
#[instrument(level = Level::DEBUG, ret)]
fn is_minimized(&self) -> bool {
self.ax_element.minimized().is_ok_and(|minimized| minimized)
}
fn is_full_screen(&self) -> bool {
self.ax_element.full_screen().unwrap_or(false)
}
#[instrument(level = Level::TRACE)]
fn reposition(&mut self, origin: Origin) {
if self.frame.min == origin {
trace!("already in position.");
return;
}
self.disable_enhanced_ui();
self.set_ax_position(origin);
self.reenable_enhanced_ui();
}
#[instrument(level = Level::TRACE)]
fn resize(&mut self, size: Size) {
if self.frame.size() == size {
trace!("already correct size.");
return;
}
let previous_frame = self.frame;
let target_origin = previous_frame.min;
self.disable_enhanced_ui();
self.set_ax_size(size);
let mut previous_observed_frame = previous_frame;
let mut staged = false;
for attempt in 1..=3 {
let Ok(actual_frame) = self.update_frame() else {
break;
};
let Some(staging_origin) =
resize_staging_origin(previous_observed_frame, actual_frame, size.x)
else {
break;
};
debug!(
attempt,
requested_width = size.x,
actual_width = actual_frame.width(),
staging_x = staging_origin.x,
"retrying partially constrained AX resize from an offscreen origin"
);
staged = true;
previous_observed_frame = actual_frame;
self.set_ax_position(staging_origin);
self.set_ax_size(size);
}
if staged {
if let Ok(final_frame) = self.update_frame() {
debug!(
requested_width = size.x,
actual_width = final_frame.width(),
"completed staged AX resize"
);
}
self.set_ax_position(target_origin);
}
self.reenable_enhanced_ui();
}
fn update_frame(&mut self) -> Result<IRect> {
let window_ref = self.ax_element.as_ptr();
let position = unsafe {
let mut position_ref: *mut CFType = null_mut();
AXUIElementCopyAttributeValue(
window_ref,
CFString::from_static_str(kAXPositionAttribute).as_ref(),
&mut position_ref,
)
.to_result(function_name!())?;
AXUIWrapper::from_retained(position_ref)?
};
let size = unsafe {
let mut size_ref: *mut CFType = null_mut();
AXUIElementCopyAttributeValue(
window_ref,
CFString::from_static_str(kAXSizeAttribute).as_ref(),
&mut size_ref,
)
.to_result(function_name!())?;
AXUIWrapper::from_retained(size_ref)?
};
let mut frame = CGRect::default();
unsafe {
AXValueGetValue(
position.as_ptr(),
kAXValueTypeCGPoint,
NonNull::from(&mut frame.origin).as_ptr().cast(),
);
AXValueGetValue(
size.as_ptr(),
kAXValueTypeCGSize,
NonNull::from(&mut frame.size).as_ptr().cast(),
);
}
self.frame = irect_from(frame);
self.frame.min.x -= self.horizontal_padding;
self.frame.min.y -= self.vertical_padding;
self.frame.max.x += self.horizontal_padding;
self.frame.max.y += self.vertical_padding;
Ok(self.frame)
}
#[instrument(level = Level::DEBUG, skip(currently_focused))]
fn focus_without_raise(
&self,
psn: ProcessSerialNumber,
currently_focused: &Window,
focused_psn: ProcessSerialNumber,
) {
let window_id = self.id();
debug!("{window_id}");
if focused_psn == psn {
let mut event_bytes = [0u8; 0xf8];
event_bytes[0x04] = 0xf8;
event_bytes[0x08] = 0x0d;
event_bytes[0x8a] = 0x02;
event_bytes[0x3c..0x40].copy_from_slice(¤tly_focused.id().to_ne_bytes());
unsafe {
SLPSPostEventRecordTo(&focused_psn, event_bytes.as_ptr().cast());
}
thread::sleep(Duration::from_millis(20));
event_bytes[0x8a] = 0x01;
event_bytes[0x3c..0x40].copy_from_slice(&window_id.to_ne_bytes());
unsafe {
SLPSPostEventRecordTo(&psn, event_bytes.as_ptr().cast());
}
}
unsafe {
_SLPSSetFrontProcessWithOptions(&psn, window_id, CPS_USER_GENERATED);
}
self.make_key_window(&psn);
}
#[instrument(level = Level::DEBUG)]
fn focus_with_raise(&self, psn: ProcessSerialNumber) {
let window_id = self.id();
unsafe {
_SLPSSetFrontProcessWithOptions(&psn, window_id, CPS_USER_GENERATED);
}
self.make_key_window(&psn);
let element_ref = self.ax_element.as_ptr();
let action = CFString::from_static_str(kAXRaiseAction);
unsafe { AXUIElementPerformAction(element_ref, &action) };
}
#[instrument(level = Level::DEBUG)]
fn raise_without_focus(&self) {
let element_ref = self.ax_element.as_ptr();
let action = CFString::from_static_str(kAXRaiseAction);
unsafe { AXUIElementPerformAction(element_ref, &action) };
}
fn pid(&self) -> Result<Pid> {
self.pid
.get_or_init(|| {
let pid: Pid = unsafe {
NonNull::new_unchecked(self.ax_element.as_ptr::<Pid>())
.byte_add(0x10)
.read()
};
(pid != 0).then_some(pid).ok_or(Error::InvalidInput(format!(
"can not get pid from {:?}.",
self.ax_element
)))
})
.clone()
}
fn set_padding(&mut self, padding: WindowPadding) {
match padding {
WindowPadding::Vertical(padding) => self.vertical_padding = padding,
WindowPadding::Horizontal(padding) => self.horizontal_padding = padding,
}
}
fn horizontal_padding(&self) -> i32 {
self.horizontal_padding
}
fn vertical_padding(&self) -> i32 {
self.vertical_padding
}
#[allow(clippy::cast_precision_loss)]
fn border_radius(&self) -> Option<f64> {
*self.border_radius.get_or_init(|| {
let iterator = super::window_iterator_for_id(self.id)?;
if !unsafe { SLSWindowIteratorAdvance(&raw const *iterator) } {
return None;
}
let radii_ref = unsafe {
let s = c"SLSWindowIteratorGetCornerRadii";
let p = libc::dlsym(libc::RTLD_DEFAULT, s.as_ptr());
if p.is_null() {
return None;
}
let f: unsafe extern "C" fn(*const CFType) -> *mut CFArray<CFNumber> =
std::mem::transmute(p);
f(&raw const *iterator)
};
let radii: CFRetained<CFArray<CFNumber>> =
unsafe { CFRetained::from_raw(NonNull::new(radii_ref)?) };
if radii.is_empty() {
return None;
}
radii.get(0)?.as_i64().map(|v| v as f64)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stages_partially_applied_width_growth() {
let previous = IRect::new(-400, 40, 400, 640);
let actual = IRect::new(-400, 40, 2416, 640);
assert_eq!(
resize_staging_origin(previous, actual, 4112),
Some(Origin::new(-1696, 40))
);
let nearly_complete = IRect::new(-2056, 40, 2016, 640);
assert_eq!(
resize_staging_origin(actual, nearly_complete, 4112),
Some(Origin::new(-2096, 40))
);
}
#[test]
fn does_not_stage_fixed_size_or_completed_resizes() {
let fixed = IRect::new(0, 40, 230, 448);
assert_eq!(resize_staging_origin(fixed, fixed, 4112), None);
let previous = IRect::new(0, 40, 800, 640);
let completed = IRect::new(0, 40, 4112, 640);
assert_eq!(resize_staging_origin(previous, completed, 4112), None);
}
}