mod application_services;
mod core_foundation;
mod core_graphics;
use std::{collections::HashSet, ffi::OsString, os::unix::ffi::OsStringExt, path::PathBuf, sync::Arc};
use application_services::{AXUIAttribute, AXUIElement};
use core_graphics::{CGEvent, CGEventSource, CGMouseType, CGPoint, CGWindowList};
use libc::c_void;
use crate::*;
#[derive(Debug)]
struct UIAutomationMacOS;
impl Backend for UIAutomationMacOS {
fn name(&self) -> &'static str {
"macOS"
}
fn get_mouse_position(&self) -> UIResult<Position> {
let Some(source) = CGEventSource::combined_state() else {
return Err(UIErrorKind::AllocationFailed { resource: "CGEventSource" }.into());
};
let Some(point) = CGEvent::from_state(source) else {
return Err(UIErrorKind::AllocationFailed { resource: "CGEvent" }.into());
};
Ok(point.mouse_location().into())
}
fn set_mouse_position(&self, position: Position) -> UIResult<()> {
let Some(point) = CGEvent::mouse_move(position.into()) else {
return Err(UIErrorKind::AllocationFailed{ resource: "CGEvent" }.into());
};
point.post();
Ok(())
}
fn applications(&self, this: Arc<dyn Backend>) -> UIResult<Vec<Application>> {
let apps = CGWindowList::create()?
.map(|x| x.owner_pid().unwrap())
.collect::<HashSet<u32>>()
.iter()
.flat_map(|pid| AXUIElement::create_application(*pid))
.flat_map(|app| {
if !app.window_count().is_ok_and(|count| count != 0) {
return None;
}
let owner = get_application_owner(&app);
let name = UIElementMacOS { element: app }.title().unwrap_or_default();
Some(Application {
backend: this.clone(),
name,
id: ApplicationId(1337),
owner,
})
})
.collect();
Ok(apps)
}
fn windows(&self, application: &Application) -> UIResult<Vec<Element>> {
let Some(pid) = application.owner.pid() else {
return Err(UIErrorKind::CannotInspectSystemWindow.into());
};
let element = UIElementMacOS {
element: AXUIElement::create_application(pid)?,
};
element.children_if(|x| x.role().ok() == Some(Role::Window))
}
}
fn get_application_owner(element: &AXUIElement) -> ApplicationOwner {
let Ok(pid) = element.get_pid() else {
return ApplicationOwner::System;
};
let pid = pid as u32;
ApplicationOwner::Process {
pid,
path: get_process_path(pid)
}
}
fn get_process_path(pid: u32) -> PathBuf {
let mut path = PathBuf::new();
let mut buf = Vec::new();
buf.resize(libc::PROC_PIDPATHINFO_MAXSIZE as usize, 0);
let buffersize = buf.len() as _;
let buffer = buf.as_mut_ptr() as *mut c_void;
let result = unsafe { libc::proc_pidpath(pid as i32, buffer, buffersize) };
if result != 0 {
buf.resize(result as usize, 0);
path = PathBuf::from(OsString::from_vec(buf));
}
path
}
pub(super) fn create_macos_backend() -> UIResult<impl Backend> {
Ok(UIAutomationMacOS)
}
#[derive(Debug)]
struct UIElementMacOS {
element: AXUIElement,
}
impl UIElementMacOS {
fn children_if<P: Fn(&Self) -> bool>(&self, predicate: P) -> UIResult<Vec<Element>> {
let mut children = Vec::new();
self.element.children(|element| {
let element = UIElementMacOS {
element,
};
if !predicate(&element) {
return;
}
children.push(Element {
backend: Arc::new(element),
});
})?;
Ok(children)
}
}
impl ElementBackend for UIElementMacOS {
fn role(&self) -> UIResult<Role> {
let role = self.element.attribute_string(AXUIAttribute::Role)?;
Ok(match role.as_str() {
"AXApplication" => Role::Application,
"AXBrowser" => Role::HierarchyVertical,
"AXButton" | "AXMenuButton" | "AXPopUpButton" => Role::Button,
"AXCell" => Role::TableCell,
"AXCheckBox" => Role::Checkbox,
"AXColumn" => Role::TableColumn,
"AXGenericElement" => Role::Generic,
"AXGroup" => Role::Panel,
"AXImage" => Role::Image,
"AXLink" => Role::Link,
"AXList" => Role::List,
"AXMenu" => Role::Menu,
"AXMenuBar" => Role::MenuBar,
"AXMenuBarItem" | "AXMenuItem" => Role::MenuItem,
"AXOutline" => Role::HierarchyHorizontal,
"AXRadioButton" => Role::RadioButton,
"AXRadioGroup" => Role::RadioGroup,
"AXRow" => Role::TableRow,
"AXScrollArea" => Role::ScrollArea,
"AXScrollBar" => Role::ScrollBar,
"AXSlider" => Role::InputSlider,
"AXSplitGroup" => Role::SplitGroup,
"AXSplitter" => Role::Splitter,
"AXStaticText" | "AXHeading" => Role::Label,
"AXTabGroup" => Role::TabGroup,
"AXTable" => Role::Table,
"AXTextArea" => Role::TextEditMultiline,
"AXTextField" | "AXComboBox" => Role::TextEditSingleline,
"AXToolbar" => Role::Toolbar,
"AXUnknown" => Role::Unknown(String::new()),
"AXValueIndicator" => Role::ValueIndicator,
"AXWebArea" => Role::WebView,
"AXWindow" => Role::Window,
_ => Role::Unknown(role),
})
}
fn children(&self) -> UIResult<Vec<Element>> {
self.children_if(|_| true)
}
fn description(&self) -> UIResult<String> {
self.element.attribute_string(AXUIAttribute::Description)
}
fn title(&self) -> UIResult<String> {
self.element.attribute_string(AXUIAttribute::Title)
}
fn url(&self) -> UIResult<String> {
self.element.attribute_url(AXUIAttribute::Url)
}
fn position(&self) -> UIResult<Position> {
Ok(self.element.attribute_point(AXUIAttribute::Position)?.into())
}
fn size(&self) -> UIResult<Size> {
Ok(self.element.attribute_size(AXUIAttribute::Size)?.into())
}
fn rect(&self) -> UIResult<Rect> {
Ok(Rect::new(self.position()?, self.size()?))
}
fn click(&self, request: ClickRequest) -> UIResult<()> {
let rect = self.rect()?;
let position = match request.position() {
ClickPosition::Center => rect.middle(),
ClickPosition::Origin => rect.position(),
};
let button: CGMouseType = request.button().into();
let point: CGPoint = position.into();
let Some(event) = CGEvent::mouse_move(point) else {
return Err(UIErrorKind::AllocationFailed{ resource: "CGEvent(move) inside click" }.into());
};
event.post();
let Some(event) = CGEvent::mouse_click(button, true, point) else {
return Err(UIErrorKind::AllocationFailed{ resource: "CGEvent(down) inside click" }.into());
};
event.post();
let Some(event) = CGEvent::mouse_click(button, false, point) else {
return Err(UIErrorKind::AllocationFailed{ resource: "CGEvent(up) inside click" }.into());
};
event.post();
Ok(())
}
}