1use std::sync::{Arc, Mutex};
7
8use log::{debug, info, warn};
9use notify_rust::{Notification, Timeout, Urgency};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NotificationResult {
14 Accepted,
16 Denied,
18}
19
20pub type YesNoResult = NotificationResult;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum PromptKind {
25 UserPresence,
26 UserVerification,
27 YesNo,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum NotificationActionMode {
32 Explicit,
33 Default,
34 DunstDefault,
35}
36
37impl NotificationActionMode {
38 fn uses_default_action(self) -> bool {
39 matches!(self, Self::Default | Self::DunstDefault)
40 }
41}
42
43fn action_mode_for_server(
51 server_name: &str,
52 server_version: &str,
53 prompt_kind: PromptKind,
54) -> NotificationActionMode {
55 let server_name = server_name.to_lowercase();
56
57 match (server_name.as_str(), server_version) {
58 ("notify-osd", "1.0") | ("mako", "0.0.0") | ("quickshell", "") => {
59 NotificationActionMode::Default
60 }
61 ("dunst", _) if prompt_kind == PromptKind::UserPresence => {
62 NotificationActionMode::DunstDefault
63 }
64 _ => NotificationActionMode::Explicit,
65 }
66}
67
68fn notification_action_mode(prompt_kind: PromptKind) -> NotificationActionMode {
69 notify_rust::get_server_information()
70 .map(|server| {
71 let mode = action_mode_for_server(&server.name, &server.version, prompt_kind);
72 debug!(
73 "Notification server: {} (version: {})",
74 server.name, server.version
75 );
76
77 match mode {
78 NotificationActionMode::Default => {
79 info!("Detected {} - using default action mode", server.name);
80 }
81 NotificationActionMode::DunstDefault => {
82 info!("Detected Dunst - using UP-specific default action mode");
83 }
84 NotificationActionMode::Explicit => {}
85 }
86
87 mode
88 })
89 .unwrap_or_else(|e| {
90 warn!("Failed to get notification server info: {}", e);
91 NotificationActionMode::Explicit
92 })
93}
94
95fn action_is_accepted(
96 action: &str,
97 affirmative_action: &str,
98 action_mode: NotificationActionMode,
99) -> bool {
100 if action == affirmative_action {
101 return true;
102 }
103
104 action == "default" && action_mode.uses_default_action()
105}
106
107fn show_confirmation_notification(
108 operation: &str,
109 relying_party: Option<&str>,
110 user: Option<&str>,
111 timeout_seconds: u32,
112 prompt_kind: PromptKind,
113) -> Result<NotificationResult, String> {
114 debug_assert!(prompt_kind != PromptKind::YesNo);
115
116 let action_mode = notification_action_mode(prompt_kind);
117
118 let mut message = format!("Operation: {}", operation);
119 if let Some(rp) = relying_party {
120 message.push_str(&format!("\nRelying Party: {}", rp));
121 }
122 if let Some(user) = user {
123 message.push_str(&format!("\nUser: {}", user));
124 }
125
126 if action_mode == NotificationActionMode::DunstDefault {
127 message.push_str(
128 "\n\nDunst: confirm by invoking this notification's action \
129 (middle-click by default). Closing the notification denies the request.",
130 );
131 }
132
133 let summary = match prompt_kind {
134 PromptKind::UserPresence => "👆 User Presence Required",
135 PromptKind::UserVerification => "🔒 User Verification Required",
136 PromptKind::YesNo => unreachable!("yes/no prompts use show_yes_no_notification"),
137 };
138
139 info!("Showing {} notification", summary);
140
141 let action_result = Arc::new(Mutex::new(None));
142 let action_result_clone = action_result.clone();
143
144 let mut notification = Notification::new();
145 notification
146 .summary(summary)
147 .body(&message)
148 .icon("security-high")
149 .timeout(Timeout::Milliseconds(timeout_seconds * 1000))
150 .urgency(Urgency::Critical);
151
152 if action_mode.uses_default_action() {
153 notification.action("default", "");
154 } else {
155 notification.action("approve", "Accept");
156 notification.action("deny", "Deny");
157 }
158
159 let handle = notification
160 .show()
161 .map_err(|e| format!("Failed to show notification: {}", e))?;
162
163 handle.wait_for_action(|action| {
164 debug!("User action received: {}", action);
165 let mut result = action_result_clone
166 .lock()
167 .expect("Failed to lock action result");
168 *result = Some(action.to_string());
169 });
170
171 let action = action_result
172 .lock()
173 .expect("Failed to lock action result")
174 .clone()
175 .unwrap_or_else(|| "__closed".to_string());
176
177 if action_is_accepted(&action, "approve", action_mode) {
178 info!("Notification accepted");
179 Ok(NotificationResult::Accepted)
180 } else {
181 if action != "deny" && action != "__closed" {
182 debug!("Unknown action '{}' - treating as denied", action);
183 }
184 info!("Notification denied or closed");
185 Ok(NotificationResult::Denied)
186 }
187}
188
189pub fn show_user_presence_notification(
195 operation: &str,
196 relying_party: Option<&str>,
197 user: Option<&str>,
198 timeout_seconds: u32,
199) -> Result<NotificationResult, String> {
200 show_confirmation_notification(
201 operation,
202 relying_party,
203 user,
204 timeout_seconds,
205 PromptKind::UserPresence,
206 )
207}
208
209pub fn show_verification_notification(
214 operation: &str,
215 relying_party: Option<&str>,
216 user: Option<&str>,
217 timeout_seconds: u32,
218) -> Result<NotificationResult, String> {
219 show_confirmation_notification(
220 operation,
221 relying_party,
222 user,
223 timeout_seconds,
224 PromptKind::UserVerification,
225 )
226}
227
228pub fn show_yes_no_notification(title: &str, question: &str) -> Result<YesNoResult, String> {
239 info!("Showing yes/no notification: {}", title);
240
241 let action_mode = notification_action_mode(PromptKind::YesNo);
242
243 let action_result = Arc::new(Mutex::new(None));
244 let action_result_clone = action_result.clone();
245
246 let mut notification = Notification::new();
247 notification
248 .summary(title)
249 .body(question)
250 .icon("dialog-question")
251 .timeout(Timeout::Never)
252 .urgency(Urgency::Critical);
253
254 if action_mode.uses_default_action() {
255 notification.action("default", "");
256 } else {
257 notification.action("yes", "Yes");
258 notification.action("no", "No");
259 }
260
261 let handle = notification
262 .show()
263 .map_err(|e| format!("Failed to show notification: {}", e))?;
264
265 handle.wait_for_action(|action| {
266 debug!("User action received: {}", action);
267 let mut result = action_result_clone
268 .lock()
269 .expect("Failed to lock action result");
270 *result = Some(action.to_string());
271 });
272
273 let action = action_result
274 .lock()
275 .expect("Failed to lock action result")
276 .clone()
277 .unwrap_or_else(|| "__closed".to_string());
278
279 if action_is_accepted(&action, "yes", action_mode) {
280 info!("User answered yes");
281 Ok(YesNoResult::Accepted)
282 } else {
283 if action != "no" && action != "__closed" {
284 debug!("Unknown action '{}' - treating as no", action);
285 }
286 info!("User answered no or closed notification");
287 Ok(YesNoResult::Denied)
288 }
289}
290
291pub fn show_info_notification(title: &str, message: &str) -> Result<(), String> {
302 info!("Showing info notification: {}", title);
303
304 Notification::new()
305 .summary(title)
306 .body(message)
307 .icon("dialog-information")
308 .timeout(Timeout::Milliseconds(5000))
309 .show()
310 .map_err(|e| format!("Failed to show notification: {}", e))?;
311
312 Ok(())
313}
314
315pub fn show_error_notification(title: &str, error_message: &str) -> Result<(), String> {
326 warn!("Showing error notification: {}", title);
327
328 Notification::new()
329 .summary(title)
330 .body(error_message)
331 .icon("dialog-error")
332 .timeout(Timeout::Milliseconds(8000))
333 .show()
334 .map_err(|e| format!("Failed to show notification: {}", e))?;
335
336 Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_notification_result_equality() {
345 assert_eq!(NotificationResult::Accepted, NotificationResult::Accepted);
346 assert_eq!(NotificationResult::Denied, NotificationResult::Denied);
347 assert_ne!(NotificationResult::Accepted, NotificationResult::Denied);
348 }
349
350 #[test]
351 fn test_dunst_default_action_is_up_only() {
352 assert_eq!(
353 action_mode_for_server("dunst", "1.13.0", PromptKind::UserPresence),
354 NotificationActionMode::DunstDefault
355 );
356 assert_eq!(
357 action_mode_for_server("Dunst", "1.13.0", PromptKind::UserPresence),
358 NotificationActionMode::DunstDefault
359 );
360 assert_eq!(
361 action_mode_for_server("dunst", "1.13.0", PromptKind::UserVerification),
362 NotificationActionMode::Explicit
363 );
364 assert_eq!(
365 action_mode_for_server("dunst", "1.13.0", PromptKind::YesNo),
366 NotificationActionMode::Explicit
367 );
368 }
369
370 #[test]
371 fn test_legacy_default_action_servers_are_preserved() {
372 for (name, version) in [("notify-osd", "1.0"), ("mako", "0.0.0"), ("quickshell", "")] {
373 for prompt_kind in [
374 PromptKind::UserPresence,
375 PromptKind::UserVerification,
376 PromptKind::YesNo,
377 ] {
378 assert_eq!(
379 action_mode_for_server(name, version, prompt_kind),
380 NotificationActionMode::Default
381 );
382 }
383 }
384 }
385
386 #[test]
387 fn test_unknown_servers_use_explicit_actions() {
388 assert_eq!(
389 action_mode_for_server("gnome-shell", "47", PromptKind::UserPresence),
390 NotificationActionMode::Explicit
391 );
392 }
393
394 #[test]
395 fn test_default_action_acceptance_is_mode_specific() {
396 assert!(action_is_accepted(
397 "default",
398 "approve",
399 NotificationActionMode::DunstDefault
400 ));
401 assert!(action_is_accepted(
402 "default",
403 "approve",
404 NotificationActionMode::Default
405 ));
406 assert!(!action_is_accepted(
407 "default",
408 "approve",
409 NotificationActionMode::Explicit
410 ));
411 assert!(action_is_accepted(
412 "approve",
413 "approve",
414 NotificationActionMode::Explicit
415 ));
416 assert!(!action_is_accepted(
417 "deny",
418 "approve",
419 NotificationActionMode::DunstDefault
420 ));
421 assert!(!action_is_accepted(
422 "__closed",
423 "approve",
424 NotificationActionMode::DunstDefault
425 ));
426 }
427
428 #[test]
429 fn test_notification_action_mode_doesnt_panic() {
430 let _ = notification_action_mode(PromptKind::UserPresence);
431 }
432}