feat(macos): implement dismiss and notification action commands

Dismiss synthesizes mouse hover to reveal close button (Sequoia), then
finds and AXPress it. Falls back to AXPress on notification group.
Notification action finds matching AXButton by name and presses it.
Both reuse shared list_entries for AX element access.
This commit is contained in:
Lahfir 2026-02-27 10:51:13 -08:00
parent 53549a384e
commit 53d697d522
2 changed files with 172 additions and 17 deletions

View file

@ -1,21 +1,159 @@
use agent_desktop_core::{
action::ActionResult, error::AdapterError, notification::NotificationInfo,
action::ActionResult,
error::{AdapterError, ErrorCode},
notification::{NotificationFilter, NotificationInfo},
};
use super::NcSession;
pub fn dismiss_notification(
index: usize,
app_filter: Option<&str>,
) -> Result<NotificationInfo, AdapterError> {
let session = NcSession::open()?;
let result = dismiss_impl(index, app_filter);
session.close()?;
result
}
pub fn notification_action(index: usize, action_name: &str) -> Result<ActionResult, AdapterError> {
let session = NcSession::open()?;
let result = action_impl(index, action_name);
session.close()?;
result
}
#[cfg(target_os = "macos")]
fn dismiss_impl(index: usize, app_filter: Option<&str>) -> Result<NotificationInfo, AdapterError> {
use crate::actions::ax_helpers::try_ax_action;
use crate::tree::{copy_ax_array, copy_string_attr};
use accessibility_sys::{kAXChildrenAttribute, kAXRoleAttribute};
let filter = build_filter(app_filter);
let entries = super::list::list_entries(&filter)?;
let entry = entries
.into_iter()
.find(|e| e.info.index == index)
.ok_or_else(|| AdapterError::notification_not_found(index))?;
let info = entry.info;
hover_over(&entry.element)?;
std::thread::sleep(std::time::Duration::from_millis(200));
let children = copy_ax_array(&entry.element, kAXChildrenAttribute).unwrap_or_default();
let close_btn = children.iter().find(|c| {
if copy_string_attr(c, kAXRoleAttribute).as_deref() != Some("AXButton") {
return false;
}
let name = copy_string_attr(c, "AXTitle")
.or_else(|| copy_string_attr(c, "AXDescription"))
.unwrap_or_default()
.to_lowercase();
name.contains("close") || name.contains("clear") || name.contains("dismiss")
});
if let Some(btn) = close_btn {
if !try_ax_action(btn, "AXPress") {
return Err(AdapterError::new(
ErrorCode::ActionFailed,
"Failed to press close button on notification",
));
}
} else if !try_ax_action(&entry.element, "AXPress") {
return Err(AdapterError::new(
ErrorCode::ActionFailed,
"No close button found and AXPress on notification group failed",
));
}
Ok(info)
}
#[cfg(target_os = "macos")]
fn action_impl(index: usize, action_name: &str) -> Result<ActionResult, AdapterError> {
use crate::actions::ax_helpers::try_ax_action;
use crate::tree::{copy_ax_array, copy_string_attr};
use accessibility_sys::{kAXChildrenAttribute, kAXRoleAttribute};
let filter = NotificationFilter::default();
let entries = super::list::list_entries(&filter)?;
let entry = entries
.into_iter()
.find(|e| e.info.index == index)
.ok_or_else(|| AdapterError::notification_not_found(index))?;
let children = copy_ax_array(&entry.element, kAXChildrenAttribute).unwrap_or_default();
let action_lower = action_name.to_lowercase();
let action_btn = children.iter().find(|c| {
if copy_string_attr(c, kAXRoleAttribute).as_deref() != Some("AXButton") {
return false;
}
let name = copy_string_attr(c, "AXTitle")
.or_else(|| copy_string_attr(c, "AXDescription"))
.unwrap_or_default();
name.to_lowercase() == action_lower
});
let btn = action_btn.ok_or_else(|| {
AdapterError::new(
ErrorCode::ActionFailed,
format!(
"Action '{}' not found on notification {}",
action_name, index
),
)
})?;
if !try_ax_action(btn, "AXPress") {
return Err(AdapterError::new(
ErrorCode::ActionFailed,
format!(
"Failed to press '{}' button on notification {}",
action_name, index
),
));
}
Ok(ActionResult::new(action_name))
}
#[cfg(target_os = "macos")]
fn hover_over(el: &crate::tree::AXElement) -> Result<(), AdapterError> {
use crate::tree::read_bounds;
use agent_desktop_core::action::{MouseButton, MouseEvent, MouseEventKind, Point};
let bounds = read_bounds(el)
.ok_or_else(|| AdapterError::internal("Cannot read notification bounds for hover"))?;
crate::input::mouse::synthesize_mouse(MouseEvent {
kind: MouseEventKind::Move,
point: Point {
x: bounds.x + bounds.width / 2.0,
y: bounds.y + bounds.height / 2.0,
},
button: MouseButton::Left,
})
}
fn build_filter(app_filter: Option<&str>) -> NotificationFilter {
NotificationFilter {
app: app_filter.map(String::from),
..Default::default()
}
}
#[cfg(not(target_os = "macos"))]
fn dismiss_impl(
_index: usize,
_app_filter: Option<&str>,
) -> Result<NotificationInfo, AdapterError> {
Err(AdapterError::not_supported(
"dismiss_notification (not yet implemented)",
))
Err(AdapterError::not_supported("dismiss_notification"))
}
pub fn notification_action(
_index: usize,
_action_name: &str,
) -> Result<ActionResult, AdapterError> {
Err(AdapterError::not_supported(
"notification_action (not yet implemented)",
))
#[cfg(not(target_os = "macos"))]
fn action_impl(_index: usize, _action_name: &str) -> Result<ActionResult, AdapterError> {
Err(AdapterError::not_supported("notification_action"))
}

View file

@ -16,6 +16,20 @@ pub fn list_notifications(
#[cfg(target_os = "macos")]
fn list_from_nc(filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, AdapterError> {
let entries = list_entries(filter)?;
Ok(entries.into_iter().map(|e| e.info).collect())
}
#[cfg(target_os = "macos")]
pub(super) struct NotificationEntry {
pub info: NotificationInfo,
pub element: crate::tree::AXElement,
}
#[cfg(target_os = "macos")]
pub(super) fn list_entries(
filter: &NotificationFilter,
) -> Result<Vec<NotificationEntry>, AdapterError> {
use crate::tree::{copy_ax_array, element_for_pid};
use accessibility_sys::kAXChildrenAttribute;
@ -32,7 +46,7 @@ fn list_from_nc(filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, Ad
let text_filter = filter.text.as_deref().map(|s| s.to_lowercase());
let limit = filter.limit.unwrap_or(usize::MAX);
let mut notifications = Vec::new();
let mut entries = Vec::new();
let mut index: usize = 1;
for window in &windows {
@ -43,15 +57,15 @@ fn list_from_nc(filter: &NotificationFilter) -> Result<Vec<NotificationInfo>, Ad
&text_filter,
limit,
&mut index,
&mut notifications,
&mut entries,
0,
);
if notifications.len() >= limit {
if entries.len() >= limit {
break;
}
}
Ok(notifications)
Ok(entries)
}
#[cfg(target_os = "macos")]
@ -61,7 +75,7 @@ fn collect_notifications(
text_filter: &Option<String>,
limit: usize,
index: &mut usize,
out: &mut Vec<NotificationInfo>,
out: &mut Vec<NotificationEntry>,
depth: u8,
) {
use crate::tree::{copy_ax_array, copy_string_attr};
@ -82,7 +96,10 @@ fn collect_notifications(
if is_notification_group(role.as_deref(), &children) {
if let Some(info) = extract_notification(el, &children, *index) {
if matches_filters(&info, app_filter, text_filter) {
out.push(info);
out.push(NotificationEntry {
info,
element: el.clone(),
});
}
*index += 1;
continue;