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;