1use 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#[derive(Debug, Clone)]
40pub enum Action {
41 Button {
43 identifier: String,
45 title: String,
47 requires_authentication: bool,
49 },
50
51 Reply {
59 identifier: String,
61 title: String,
63 button_title: String,
65 placeholder: String,
67 requires_authentication: bool,
69 },
70}
71
72impl Action {
73 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 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 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 pub(crate) fn identifier(&self) -> &str {
131 match self {
132 Self::Button { identifier, .. } | Self::Reply { identifier, .. } => identifier,
133 }
134 }
135
136 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::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#[derive(Debug, Clone)]
187pub struct ActionCategory {
188 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 pub fn new(identifier: impl Into<String>) -> Self {
206 Self {
207 identifier: identifier.into(),
208 actions: Vec::new(),
209 }
210 }
211
212 pub(crate) fn from_actions(identifier: &str, actions: Vec<Action>) -> Self {
214 Self {
215 identifier: identifier.to_owned(),
216 actions,
217 }
218 }
219
220 pub fn action(mut self, action: Action) -> Self {
222 self.actions.push(action);
223 self
224 }
225
226 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 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 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}