ui-automation 0.1.1

Control the User Interface, cross-platform.
Documentation
// Copyright (C) 2024 Tristan Gerritsen <tristan@thewoosh.org>
// All Rights Reserved.

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> {
        // println!("Role: {:?}", self.element.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(())
    }
}