Skip to main content

krypt_core/
notify.rs

1//! Cross-platform notification backend.
2//!
3//! Dispatches desktop notifications via platform-appropriate shell commands.
4//! Provides [`AutoNotifier`] which implements the [`crate::runner::Notifier`]
5//! trait and replaces the previous `RealNotifier` stub.
6//!
7//! # Backend selection
8//!
9//! [`detect`] tries backends in platform-specific order using `which::which`.
10//! An explicit override via `[meta] notify_backend` in `.krypt.toml` (or
11//! the `--backend` CLI flag) bypasses auto-detection.
12//!
13//! # Backend commands
14//!
15//! | Backend            | Command                                           |
16//! |--------------------|---------------------------------------------------|
17//! | `notify-send`      | `notify-send <title> <body>`                      |
18//! | `osascript`        | `osascript -e 'display notification ...'`         |
19//! | `terminal-notifier`| `terminal-notifier -title <title> -message <body>`|
20//! | `powershell`       | PowerShell `[System.Windows.Forms.MessageBox]`    |
21//! | `stderr`           | `eprintln!("notice: {title} — {body}")`           |
22//!
23//! # PowerShell strategy
24//!
25//! BurntToast requires a third-party module install (`Install-Module
26//! BurntToast`) which most users won't have. Instead we use
27//! `System.Windows.Forms.MessageBox` which ships in every .NET installation.
28//! Values are passed via `$env:KRYPT_NOTIFY_TITLE` / `$env:KRYPT_NOTIFY_BODY`
29//! environment variables to avoid PowerShell single-quote escaping entirely.
30
31use std::io;
32use std::process::{Command, Stdio};
33
34use thiserror::Error;
35
36// ─── Error ───────────────────────────────────────────────────────────────────
37
38/// Errors that can occur while dispatching a notification.
39///
40/// Kept ≤ 128 bytes: `io::Error` is boxed, stderr string is boxed.
41#[derive(Debug, Error)]
42pub enum NotifyError {
43    /// Failed to spawn the notification subprocess.
44    #[error("failed to spawn notification command: {0}")]
45    Spawn(#[source] Box<io::Error>),
46
47    /// Notification subprocess exited non-zero.
48    #[error("notification command exited with code {code}: {stderr}")]
49    NonZeroExit {
50        /// Exit code.
51        code: i32,
52        /// Captured stderr output (boxed to keep enum small).
53        stderr: Box<str>,
54    },
55
56    /// No suitable backend was found (should not occur; `Stderr` is always available).
57    #[error("no notification backend available")]
58    NoBackend,
59}
60
61impl From<NotifyError> for io::Error {
62    fn from(e: NotifyError) -> Self {
63        io::Error::other(e.to_string())
64    }
65}
66
67// ─── Backend enum ─────────────────────────────────────────────────────────────
68
69/// Available notification backends.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum NotifyBackend {
72    /// `notify-send` — standard on most Linux/BSD desktops.
73    NotifySend,
74    /// `osascript` — ships with macOS.
75    Osascript,
76    /// `terminal-notifier` — third-party macOS notifier, nicer than osascript.
77    TerminalNotifier,
78    /// PowerShell `[System.Windows.Forms.MessageBox]` — available on all
79    /// Windows systems with .NET.
80    PowerShell,
81    /// Fallback: print to stderr. Always available.
82    Stderr,
83}
84
85impl NotifyBackend {
86    /// Parse a backend name string into a [`NotifyBackend`].
87    ///
88    /// Returns `None` for unrecognised names.
89    pub fn from_name(name: &str) -> Option<Self> {
90        match name {
91            "notify-send" => Some(Self::NotifySend),
92            "osascript" => Some(Self::Osascript),
93            "terminal-notifier" => Some(Self::TerminalNotifier),
94            "powershell" => Some(Self::PowerShell),
95            "stderr" => Some(Self::Stderr),
96            _ => None,
97        }
98    }
99}
100
101// ─── Auto-detect ──────────────────────────────────────────────────────────────
102
103/// Detect the best available notification backend.
104///
105/// Precedence:
106/// 1. Explicit `override_name` (e.g. from `--backend` or `[meta] notify_backend`).
107///    `"auto"` and `None` both trigger auto-detection.
108///    `"stderr"` forces [`NotifyBackend::Stderr`].
109///    Unknown names produce a `tracing::warn!` and fall through to auto-detect.
110/// 2. Platform-appropriate auto-detect via `which::which`.
111/// 3. [`NotifyBackend::Stderr`] fallback.
112pub fn detect(override_name: Option<&str>) -> NotifyBackend {
113    match override_name {
114        None | Some("auto") => auto_detect(),
115        Some("stderr") => NotifyBackend::Stderr,
116        Some(name) => match NotifyBackend::from_name(name) {
117            Some(b) => b,
118            None => {
119                tracing::warn!(
120                    backend = name,
121                    "unknown notify_backend — falling through to auto-detect"
122                );
123                auto_detect()
124            }
125        },
126    }
127}
128
129fn auto_detect() -> NotifyBackend {
130    #[cfg(target_os = "macos")]
131    {
132        if which::which("terminal-notifier").is_ok() {
133            return NotifyBackend::TerminalNotifier;
134        }
135        if which::which("osascript").is_ok() {
136            return NotifyBackend::Osascript;
137        }
138    }
139
140    #[cfg(target_os = "windows")]
141    {
142        if which::which("powershell").is_ok() {
143            return NotifyBackend::PowerShell;
144        }
145    }
146
147    #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
148    {
149        if which::which("notify-send").is_ok() {
150            return NotifyBackend::NotifySend;
151        }
152    }
153
154    NotifyBackend::Stderr
155}
156
157// ─── Command construction (pure, testable) ────────────────────────────────────
158
159/// Build the command + args for a given backend without spawning.
160///
161/// Returns `(program, args)`. For [`NotifyBackend::Stderr`] returns
162/// `("", vec![])` — the caller should handle this variant directly.
163pub fn command_for(backend: NotifyBackend, title: &str, body: &str) -> (String, Vec<String>) {
164    match backend {
165        NotifyBackend::NotifySend => (
166            "notify-send".to_owned(),
167            vec![title.to_owned(), body.to_owned()],
168        ),
169        NotifyBackend::Osascript => {
170            let script = format!(
171                "display notification \"{}\" with title \"{}\"",
172                escape_applescript(body),
173                escape_applescript(title),
174            );
175            ("osascript".to_owned(), vec!["-e".to_owned(), script])
176        }
177        NotifyBackend::TerminalNotifier => (
178            "terminal-notifier".to_owned(),
179            vec![
180                "-title".to_owned(),
181                title.to_owned(),
182                "-message".to_owned(),
183                body.to_owned(),
184            ],
185        ),
186        NotifyBackend::PowerShell => {
187            // Values are injected via env vars to sidestep PowerShell escaping.
188            // The script reads $env:KRYPT_NOTIFY_TITLE / $env:KRYPT_NOTIFY_BODY.
189            let script = "Add-Type -AssemblyName System.Windows.Forms; \
190                [System.Windows.Forms.MessageBox]::Show(\
191                $env:KRYPT_NOTIFY_BODY, $env:KRYPT_NOTIFY_TITLE) | Out-Null"
192                .to_owned();
193            ("powershell".to_owned(), vec!["-Command".to_owned(), script])
194        }
195        NotifyBackend::Stderr => (String::new(), vec![]),
196    }
197}
198
199/// Escape a string for use inside an AppleScript double-quoted string.
200///
201/// Backslashes → `\\`, double-quotes → `\"`.
202pub fn escape_applescript(s: &str) -> String {
203    let mut out = String::with_capacity(s.len());
204    for ch in s.chars() {
205        match ch {
206            '\\' => out.push_str("\\\\"),
207            '"' => out.push_str("\\\""),
208            c => out.push(c),
209        }
210    }
211    out
212}
213
214// ─── Dispatch ─────────────────────────────────────────────────────────────────
215
216/// Send a desktop notification using the specified backend.
217pub fn notify(backend: NotifyBackend, title: &str, body: &str) -> Result<(), NotifyError> {
218    if backend == NotifyBackend::Stderr {
219        eprintln!("notice: {title} \u{2014} {body}");
220        return Ok(());
221    }
222
223    let (program, mut args) = command_for(backend, title, body);
224
225    let mut cmd = Command::new(&program);
226    cmd.args(&args);
227    cmd.stdout(Stdio::null());
228    cmd.stderr(Stdio::piped());
229    cmd.stdin(Stdio::null());
230
231    // PowerShell: inject values via env vars.
232    if backend == NotifyBackend::PowerShell {
233        cmd.env("KRYPT_NOTIFY_TITLE", title);
234        cmd.env("KRYPT_NOTIFY_BODY", body);
235    }
236
237    // Suppress unused mut warning — args is mutated only in some branches above.
238    let _ = args.as_mut_slice();
239
240    let output = cmd.output().map_err(|e| NotifyError::Spawn(Box::new(e)))?;
241
242    if !output.status.success() {
243        let code = output.status.code().unwrap_or(-1);
244        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
245        return Err(NotifyError::NonZeroExit {
246            code,
247            stderr: stderr.into_boxed_str(),
248        });
249    }
250
251    Ok(())
252}
253
254// ─── AutoNotifier ─────────────────────────────────────────────────────────────
255
256/// Production notifier backed by a detected or configured [`NotifyBackend`].
257///
258/// Implements [`crate::runner::Notifier`]. Use [`AutoNotifier::with_backend`]
259/// in tests to pin a specific backend (e.g. `NotifyBackend::Stderr`) so no
260/// real desktop notifications fire.
261pub struct AutoNotifier {
262    backend: NotifyBackend,
263}
264
265impl AutoNotifier {
266    /// Create an `AutoNotifier` with auto-detected backend.
267    pub fn new(override_name: Option<&str>) -> Self {
268        Self {
269            backend: detect(override_name),
270        }
271    }
272
273    /// Create an `AutoNotifier` with an explicit backend (useful in tests).
274    pub fn with_backend(backend: NotifyBackend) -> Self {
275        Self { backend }
276    }
277}
278
279impl crate::runner::Notifier for AutoNotifier {
280    fn notify(&self, title: &str, body: &str) -> Result<(), io::Error> {
281        notify(self.backend, title, body).map_err(io::Error::from)
282    }
283}
284
285// ─── Tests ────────────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    // 1. detect(None) → Stderr when no backends are in PATH.
292    // We can't easily manipulate PATH in unit tests without unsafe tricks, but
293    // we can verify the code path compiles and returns a valid backend.
294    #[test]
295    fn detect_none_returns_valid_backend() {
296        let b = detect(None);
297        // Any variant is acceptable; we just confirm it doesn't panic.
298        let _ = b;
299    }
300
301    // 2. detect(Some("notify-send")) → NotifySend, bypasses which.
302    #[test]
303    fn detect_explicit_notify_send() {
304        assert_eq!(detect(Some("notify-send")), NotifyBackend::NotifySend);
305    }
306
307    // 3. detect(Some("auto")) behaves the same as detect(None).
308    #[test]
309    fn detect_auto_same_as_none() {
310        assert_eq!(detect(Some("auto")), detect(None));
311    }
312
313    // 4. detect(Some("stderr")) → Stderr.
314    #[test]
315    fn detect_explicit_stderr() {
316        assert_eq!(detect(Some("stderr")), NotifyBackend::Stderr);
317    }
318
319    // 5. detect(Some("typo-backend")) → falls through to auto-detect result,
320    //    same as detect(None).
321    #[test]
322    fn detect_unknown_falls_through() {
323        // A typo override should produce the same result as no override at all
324        // because the unknown name is warned and ignored.
325        let b = detect(Some("typo-backend"));
326        let expected = detect(None);
327        assert_eq!(b, expected);
328    }
329
330    // 6. Stderr backend returns Ok(()).
331    #[test]
332    fn notify_stderr_ok() {
333        assert!(notify(NotifyBackend::Stderr, "t", "b").is_ok());
334    }
335
336    // 7. AppleScript escaper round-trips.
337    #[test]
338    fn applescript_escaper() {
339        assert_eq!(escape_applescript(r#"say "hello""#), r#"say \"hello\""#);
340        assert_eq!(escape_applescript(r"back\slash"), r"back\\slash");
341        assert_eq!(escape_applescript("plain"), "plain");
342        // Newlines pass through unchanged (AppleScript handles them).
343        assert_eq!(escape_applescript("line\nbreak"), "line\nbreak");
344        // Round-trip: escape then unescape manually.
345        let input = r#"title with "quotes" and \backslash"#;
346        let escaped = escape_applescript(input);
347        // The escaped string should not contain unescaped quotes.
348        assert!(!escaped.contains("\\\"") || escaped.contains("\\\\"));
349    }
350
351    // 8. command_for: verify argument shapes without spawning.
352    #[test]
353    fn command_for_notify_send() {
354        let (prog, args) = command_for(NotifyBackend::NotifySend, "Title", "Body");
355        assert_eq!(prog, "notify-send");
356        assert_eq!(args, vec!["Title", "Body"]);
357    }
358
359    #[test]
360    fn command_for_terminal_notifier() {
361        let (prog, args) = command_for(NotifyBackend::TerminalNotifier, "My Title", "My Body");
362        assert_eq!(prog, "terminal-notifier");
363        assert!(args.contains(&"-title".to_owned()));
364        assert!(args.contains(&"My Title".to_owned()));
365        assert!(args.contains(&"-message".to_owned()));
366        assert!(args.contains(&"My Body".to_owned()));
367    }
368
369    #[test]
370    fn command_for_osascript_escapes_quotes() {
371        let (prog, args) = command_for(NotifyBackend::Osascript, r#"Ti"tle"#, r#"Bo"dy"#);
372        assert_eq!(prog, "osascript");
373        let script = &args[1];
374        // Embedded quote must be escaped.
375        assert!(
376            script.contains("\\\""),
377            "osascript script should escape double-quotes: {script}"
378        );
379    }
380
381    #[test]
382    fn command_for_powershell() {
383        let (prog, args) = command_for(NotifyBackend::PowerShell, "T", "B");
384        assert_eq!(prog, "powershell");
385        assert!(args.iter().any(|a| a.contains("MessageBox")));
386    }
387
388    // 9. Schema: notify_backend field on Meta parses correctly.
389    #[test]
390    fn meta_notify_backend_parses() {
391        let toml = "[meta]\nnotify_backend = \"osascript\"\n";
392        let cfg: crate::config::Config = toml::from_str(toml).expect("parse");
393        assert_eq!(cfg.meta.notify_backend.as_deref(), Some("osascript"));
394    }
395
396    #[test]
397    fn meta_notify_backend_defaults_none() {
398        let toml = "[meta]\nname = \"test\"\n";
399        let cfg: crate::config::Config = toml::from_str(toml).expect("parse");
400        assert!(cfg.meta.notify_backend.is_none());
401    }
402}