Skip to main content

mac_usernotifications/
action.rs

1//! Notification action buttons and the categories that group them.
2//!
3//! [`Action`] is the public-facing type for defining action buttons.
4//! [`ActionCategory`] is an implementation detail; you do not need to use it
5//! directly. When you add actions to a [`Notification`] via
6//! [`Notification::action`], a category is synthesised and registered
7//! automatically when the notification is sent.
8//!
9//! [`ActionCategory`] is still available for the advanced use case of
10//! **server-defined categories**: when a push notification from your server
11//! references a `categoryIdentifier` that your app must pre-register at
12//! launch (before any push arrives).
13//!
14//! [`Notification`]: crate::Notification
15//! [`Notification::action`]: crate::Notification::action
16
17use std::sync::{Mutex, OnceLock};
18
19use objc2::rc::Retained;
20use objc2_foundation::{NSArray, NSSet, NSString};
21use objc2_user_notifications::{
22    UNNotificationAction, UNNotificationActionOptions, UNNotificationCategory,
23    UNNotificationCategoryOptions, UNTextInputNotificationAction, UNUserNotificationCenter,
24};
25
26use crate::worker;
27
28static CATEGORIES: OnceLock<Mutex<Vec<ActionCategory>>> = OnceLock::new();
29
30fn categories() -> &'static Mutex<Vec<ActionCategory>> {
31    CATEGORIES.get_or_init(|| Mutex::new(Vec::new()))
32}
33
34/// A notification action.
35///
36/// Use [`Action::button`] for a plain button or [`Action::reply`] for a
37/// text-input action. Chain [`Action::requires_authentication`] on either
38/// to require Touch ID / password before the action fires.
39#[derive(Debug, Clone)]
40pub enum Action {
41    /// A plain button action.
42    Button {
43        /// Identifier sent back in [`NotificationResponse::action_identifier`](`crate::NotificationResponse::action_identifier`).
44        identifier: String,
45        /// Button label shown to the user.
46        title: String,
47        /// Require Touch ID / password before firing.
48        requires_authentication: bool,
49    },
50
51    /// A text-input action.
52    ///
53    /// The user's input is delivered via
54    /// [`NotificationResponse::reply_text`](`crate::NotificationResponse::reply_text`).
55    ///
56    /// macOS only renders one `Reply` action correctly per notification.
57    /// If multiple are added, only the first is used.
58    Reply {
59        /// Identifier sent back in [`NotificationResponse::action_identifier`](`crate::NotificationResponse::action_identifier`).
60        identifier: String,
61        /// Label shown in the Options menu.
62        title: String,
63        /// Label on the submit button inside the expanded text field.
64        button_title: String,
65        /// Placeholder text inside the input field.
66        placeholder: String,
67        /// Require Touch ID / password before opening the text field.
68        requires_authentication: bool,
69    },
70}
71
72impl Action {
73    /// Create a plain button action.
74    pub fn button(identifier: impl Into<String>, title: impl Into<String>) -> Self {
75        Self::Button {
76            identifier: identifier.into(),
77            title: title.into(),
78            requires_authentication: false,
79        }
80    }
81
82    /// Create a text-input (reply) action.
83    ///
84    /// - `button_title` is the label on the submit button (e.g. `"Send"`).
85    /// - `placeholder` is the hint text inside the empty input field.
86    pub fn reply(
87        identifier: impl Into<String>,
88        title: impl Into<String>,
89        button_title: impl Into<String>,
90        placeholder: impl Into<String>,
91    ) -> Self {
92        Self::Reply {
93            identifier: identifier.into(),
94            title: title.into(),
95            button_title: button_title.into(),
96            placeholder: placeholder.into(),
97            requires_authentication: false,
98        }
99    }
100
101    /// Require Touch ID / password before the action fires.
102    ///
103    /// Works for both [`Action::Button`] and [`Action::Reply`].
104    pub fn requires_authentication(self) -> Self {
105        match self {
106            Self::Button {
107                identifier, title, ..
108            } => Self::Button {
109                identifier,
110                title,
111                requires_authentication: true,
112            },
113            Self::Reply {
114                identifier,
115                title,
116                button_title,
117                placeholder,
118                ..
119            } => Self::Reply {
120                identifier,
121                title,
122                button_title,
123                placeholder,
124                requires_authentication: true,
125            },
126        }
127    }
128
129    /// The action's identifier.
130    pub(crate) fn identifier(&self) -> &str {
131        match self {
132            Self::Button { identifier, .. } | Self::Reply { identifier, .. } => identifier,
133        }
134    }
135
136    /// Build the Objective-C action object.
137    pub(crate) fn build(&self) -> Retained<UNNotificationAction> {
138        match self {
139            Self::Button {
140                identifier,
141                title,
142                requires_authentication,
143            } => {
144                let mut options = UNNotificationActionOptions::empty();
145                if *requires_authentication {
146                    options |= UNNotificationActionOptions::AuthenticationRequired;
147                }
148                UNNotificationAction::actionWithIdentifier_title_options(
149                    &NSString::from_str(identifier),
150                    &NSString::from_str(title),
151                    options,
152                )
153            }
154            Self::Reply {
155                identifier,
156                title,
157                button_title,
158                placeholder,
159                requires_authentication,
160            } => {
161                let mut options = UNNotificationActionOptions::empty();
162                if *requires_authentication {
163                    options |= UNNotificationActionOptions::AuthenticationRequired;
164                }
165                // UNTextInputNotificationAction is a direct subclass of
166                // UNNotificationAction; into_super() performs the safe upcast.
167                UNTextInputNotificationAction::actionWithIdentifier_title_options_textInputButtonTitle_textInputPlaceholder(
168                    &NSString::from_str(identifier),
169                    &NSString::from_str(title),
170                    options,
171                    &NSString::from_str(button_title),
172                    &NSString::from_str(placeholder),
173                )
174                .into_super()
175            }
176        }
177    }
178}
179
180/// A group of actions registered with macOS.
181///
182/// Pre-register with [`ActionCategory::register`] for server-defined categories,
183/// or use [`Notification::action`] for automatic registration.
184///
185/// [`Notification::action`]: crate::Notification::action
186#[derive(Debug, Clone)]
187pub struct ActionCategory {
188    /// Category identifier.
189    pub identifier: String,
190    pub(crate) actions: Vec<Action>,
191}
192
193fn upsert_category(categories: &mut Vec<ActionCategory>, category: ActionCategory) {
194    match categories
195        .iter()
196        .position(|cat| cat.identifier == category.identifier)
197    {
198        Some(idx) => categories[idx] = category,
199        None => categories.push(category),
200    }
201}
202
203impl ActionCategory {
204    /// Create a new category.
205    pub fn new(identifier: impl Into<String>) -> Self {
206        Self {
207            identifier: identifier.into(),
208            actions: Vec::new(),
209        }
210    }
211
212    /// Create a category from an existing list of actions.
213    pub(crate) fn from_actions(identifier: &str, actions: Vec<Action>) -> Self {
214        Self {
215            identifier: identifier.to_owned(),
216            actions,
217        }
218    }
219
220    /// Add an action to this category.
221    pub fn action(mut self, action: Action) -> Self {
222        self.actions.push(action);
223        self
224    }
225
226    /// Build the `UNNotificationCategory` object.
227    fn build_category(&self) -> Retained<UNNotificationCategory> {
228        let actions: Vec<_> = self.actions.iter().map(|act| act.build()).collect();
229        let actions_array = NSArray::from_retained_slice(&actions);
230        UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
231            &NSString::from_str(&self.identifier),
232            &actions_array,
233            &NSArray::new(),
234            UNNotificationCategoryOptions::CustomDismissAction,
235        )
236    }
237
238    /// Register this category synchronously, merging with all previously registered categories.
239    ///
240    /// If a category with the same identifier was registered before, it is replaced.
241    /// Must only be called from the worker thread.
242    pub(crate) fn register_now(&self) {
243        let mut categories = categories().lock().expect("category registry poisoned");
244
245        upsert_category(&mut categories, self.clone());
246
247        let built: Vec<_> = categories.iter().map(|cat| cat.build_category()).collect();
248        let full_set = NSSet::from_retained_slice(&built);
249        UNUserNotificationCenter::currentNotificationCenter().setNotificationCategories(&full_set);
250        log::debug!(
251            "registered category '{}' ({} total)",
252            self.identifier,
253            categories.len()
254        );
255    }
256
257    /// Register this category with `UNUserNotificationCenter`.
258    ///
259    /// Only needed for server-defined categories. For local notifications,
260    /// use [`Notification::action`] instead.
261    ///
262    /// [`Notification::action`]: crate::Notification::action
263    pub fn register(self) {
264        worker::dispatch(move || self.register_now());
265    }
266}
267
268#[cfg(test)]
269mod test_upsert {
270    use super::{Action, ActionCategory, upsert_category};
271
272    fn cat(id: &str) -> ActionCategory {
273        ActionCategory::new(id)
274    }
275
276    fn cat_with_action(id: &str, action_id: &str) -> ActionCategory {
277        ActionCategory::new(id).action(Action::button(action_id, action_id))
278    }
279
280    #[test]
281    fn appends_when_identifier_is_new() {
282        let mut cats = vec![cat("a"), cat("b")];
283        upsert_category(&mut cats, cat("c"));
284        assert_eq!(cats.len(), 3);
285        assert_eq!(cats[2].identifier, "c");
286    }
287
288    #[test]
289    fn replaces_without_changing_length() {
290        let mut cats = vec![cat("a"), cat_with_action("b", "old"), cat("c")];
291        upsert_category(&mut cats, cat_with_action("b", "new"));
292        assert_eq!(cats.len(), 3, "length must not change on replace");
293        assert_eq!(cats[1].actions[0].identifier(), "new");
294    }
295
296    #[test]
297    fn replaces_at_correct_position_leaving_others_undisturbed() {
298        let mut cats = vec![cat("a"), cat("b"), cat("c")];
299        upsert_category(&mut cats, cat_with_action("a", "x"));
300        assert_eq!(cats[0].actions[0].identifier(), "x");
301        assert_eq!(cats[1].identifier, "b");
302        assert_eq!(cats[2].identifier, "c");
303    }
304
305    #[test]
306    fn first_insert_into_empty_vec() {
307        let mut cats = vec![];
308        upsert_category(&mut cats, cat("a"));
309        assert_eq!(cats.len(), 1);
310        assert_eq!(cats[0].identifier, "a");
311    }
312}
313
314#[cfg(test)]
315mod test_action {
316    use super::Action;
317
318    #[test]
319    fn button_requires_authentication_sets_flag() {
320        let action = Action::button("id", "Confirm").requires_authentication();
321        assert!(matches!(
322            action,
323            Action::Button {
324                requires_authentication: true,
325                ..
326            }
327        ));
328    }
329
330    #[test]
331    fn reply_requires_authentication_sets_flag() {
332        let action = Action::reply("id", "Reply", "Send", "hint").requires_authentication();
333        assert!(matches!(
334            action,
335            Action::Reply {
336                requires_authentication: true,
337                ..
338            }
339        ));
340    }
341
342    #[test]
343    fn identifier_accessible_on_both_variants() {
344        assert_eq!(Action::button("btn-id", "OK").identifier(), "btn-id");
345        assert_eq!(
346            Action::reply("reply-id", "Reply", "Send", "hint").identifier(),
347            "reply-id"
348        );
349    }
350}