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 objc2_core_foundation::{
CFArray, CFBoolean, CFNumber, CFRetained, CFString, CFType, CGPoint, CGRect, CGSize,
kCFBooleanFalse, kCFBooleanTrue,
};
use std::collections::HashMap;
use std::ptr::null_mut;
use std::sync::{LazyLock, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
use stdext::function_name;
use tracing::{Level, debug, instrument, trace, warn};
use super::skylight::{
_AXUIElementGetWindow, _SLPSSetFrontProcessWithOptions, AXUIElementCopyAttributeValue,
AXUIElementPerformAction, AXUIElementSetAttributeValue, SLPSPostEventRecordTo,
SLSWindowIteratorAdvance,
};
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()));
#[derive(Debug)]
pub enum WindowPadding {
Vertical(i32),
Horizontal(i32),
}
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 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 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> {
let ptr = NonNull::new(element_ref).ok_or(Error::InvalidInput(format!(
"{}: nullptr passed as element.",
function_name!()
)))?;
let mut window_id: WinID = 0;
unsafe { _AXUIElementGetWindow(ptr.as_ptr(), &mut window_id) }.to_result(function_name!())?;
if window_id == 0 {
return Err(Error::InvalidInput(format!(
"{}: Unable to get window id from element {element_ref:?}.",
function_name!()
)));
}
Ok(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>>>,
}
impl WindowOS {
#[instrument(level = Level::TRACE, ret)]
pub fn new(element: &CFRetained<AXUIWrapper>) -> 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(),
};
if window.is_unknown() {
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() {
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_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) {
let Ok(pid) = self.pid() else { 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(),
);
}
counts.insert(pid, 1);
}
}
fn reenable_enhanced_ui(&self) {
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 -= 1;
if *count > 0 {
return;
}
counts.remove(&pid);
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 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> {
self.ax_element.title()
}
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();
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::retain(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;
}
self.reenable_enhanced_ui();
}
#[instrument(level = Level::TRACE)]
fn resize(&mut self, size: Size) {
if self.frame.size() == size {
trace!("already correct size.");
return;
}
self.disable_enhanced_ui();
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(position) = AXUIWrapper::retain(size_ref) {
unsafe {
AXUIElementSetAttributeValue(
self.ax_element.as_ptr(),
CFString::from_static_str(kAXSizeAttribute).as_ref(),
position.as_ref(),
)
};
self.frame.max = self.frame.min + size;
}
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::retain(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::retain(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) };
}
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)
})
}
}