From 53d697d52248c3fa06797787b1eb549ac2766533 Mon Sep 17 00:00:00 2001 From: Lahfir Date: Fri, 27 Feb 2026 10:51:13 -0800 Subject: [PATCH] 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. --- crates/macos/src/notifications/actions.rs | 160 ++++++++++++++++++++-- crates/macos/src/notifications/list.rs | 29 +++- 2 files changed, 172 insertions(+), 17 deletions(-) diff --git a/crates/macos/src/notifications/actions.rs b/crates/macos/src/notifications/actions.rs index da70d13..0b8e3cd 100644 --- a/crates/macos/src/notifications/actions.rs +++ b/crates/macos/src/notifications/actions.rs @@ -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 { + let session = NcSession::open()?; + let result = dismiss_impl(index, app_filter); + session.close()?; + result +} + +pub fn notification_action(index: usize, action_name: &str) -> Result { + 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 { + 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 { + 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 { - 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 { - Err(AdapterError::not_supported( - "notification_action (not yet implemented)", - )) +#[cfg(not(target_os = "macos"))] +fn action_impl(_index: usize, _action_name: &str) -> Result { + Err(AdapterError::not_supported("notification_action")) } diff --git a/crates/macos/src/notifications/list.rs b/crates/macos/src/notifications/list.rs index c10408f..4e49283 100644 --- a/crates/macos/src/notifications/list.rs +++ b/crates/macos/src/notifications/list.rs @@ -16,6 +16,20 @@ pub fn list_notifications( #[cfg(target_os = "macos")] fn list_from_nc(filter: &NotificationFilter) -> Result, 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, 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, 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, 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, limit: usize, index: &mut usize, - out: &mut Vec, + out: &mut Vec, 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;