Skip to main content

passless_rs/
notification.rs

1//! Desktop notification handling for user presence and verification
2//!
3//! This module provides desktop notification support with compatibility for
4//! different notification servers (notify-osd, mako, Dunst, etc.).
5
6use std::sync::{Arc, Mutex};
7
8use log::{debug, info, warn};
9use notify_rust::{Notification, Timeout, Urgency};
10
11/// Result of user interaction via notification
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NotificationResult {
14    /// User approved the operation
15    Accepted,
16    /// User denied the operation
17    Denied,
18}
19
20/// Result of a yes/no question via notification
21pub 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
43/// Determine how actions should be exposed for a notification server.
44///
45/// Legacy compatibility servers use a default action for all prompt types.
46/// Dunst gets that behavior only for CTAP user presence: its actions are
47/// supported but normally invoked through Dunst's interaction model instead
48/// of visible buttons. User verification deliberately keeps explicit actions
49/// so the UP compatibility path cannot silently weaken UV semantics.
50fn 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
189/// Show a CTAP user-presence notification and wait for response.
190///
191/// User presence is a consent gesture (for example, touching a hardware key),
192/// so notification activation may be used as the explicit gesture on daemons
193/// such as Dunst that do not expose action buttons directly.
194pub 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
209/// Show a user-verification notification and wait for response.
210///
211/// Unlike user presence, Dunst activation is not treated as verification: the
212/// user must invoke the explicit Accept action through Dunst's action UI.
213pub 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
228/// Show a yes/no question notification and wait for response
229///
230/// # Arguments
231///
232/// * `title` - Title of the notification
233/// * `question` - The question to ask
234///
235/// # Returns
236///
237/// Result indicating whether the user answered yes (Accepted) or no (Denied)
238pub 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
291/// Show an informational notification (no user response needed)
292///
293/// # Arguments
294///
295/// * `title` - Title of the notification
296/// * `message` - The message to display
297///
298/// # Returns
299///
300/// Ok if notification was shown successfully
301pub 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
315/// Show an error notification
316///
317/// # Arguments
318///
319/// * `title` - Title of the notification
320/// * `error_message` - The error message to display
321///
322/// # Returns
323///
324/// Ok if notification was shown successfully
325pub 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}