1use std::io;
32use std::process::{Command, Stdio};
33
34use thiserror::Error;
35
36#[derive(Debug, Error)]
42pub enum NotifyError {
43 #[error("failed to spawn notification command: {0}")]
45 Spawn(#[source] Box<io::Error>),
46
47 #[error("notification command exited with code {code}: {stderr}")]
49 NonZeroExit {
50 code: i32,
52 stderr: Box<str>,
54 },
55
56 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum NotifyBackend {
72 NotifySend,
74 Osascript,
76 TerminalNotifier,
78 PowerShell,
81 Stderr,
83}
84
85impl NotifyBackend {
86 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
101pub 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
157pub 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 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
199pub 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
214pub 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 if backend == NotifyBackend::PowerShell {
233 cmd.env("KRYPT_NOTIFY_TITLE", title);
234 cmd.env("KRYPT_NOTIFY_BODY", body);
235 }
236
237 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
254pub struct AutoNotifier {
262 backend: NotifyBackend,
263}
264
265impl AutoNotifier {
266 pub fn new(override_name: Option<&str>) -> Self {
268 Self {
269 backend: detect(override_name),
270 }
271 }
272
273 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#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
295 fn detect_none_returns_valid_backend() {
296 let b = detect(None);
297 let _ = b;
299 }
300
301 #[test]
303 fn detect_explicit_notify_send() {
304 assert_eq!(detect(Some("notify-send")), NotifyBackend::NotifySend);
305 }
306
307 #[test]
309 fn detect_auto_same_as_none() {
310 assert_eq!(detect(Some("auto")), detect(None));
311 }
312
313 #[test]
315 fn detect_explicit_stderr() {
316 assert_eq!(detect(Some("stderr")), NotifyBackend::Stderr);
317 }
318
319 #[test]
322 fn detect_unknown_falls_through() {
323 let b = detect(Some("typo-backend"));
326 let expected = detect(None);
327 assert_eq!(b, expected);
328 }
329
330 #[test]
332 fn notify_stderr_ok() {
333 assert!(notify(NotifyBackend::Stderr, "t", "b").is_ok());
334 }
335
336 #[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 assert_eq!(escape_applescript("line\nbreak"), "line\nbreak");
344 let input = r#"title with "quotes" and \backslash"#;
346 let escaped = escape_applescript(input);
347 assert!(!escaped.contains("\\\"") || escaped.contains("\\\\"));
349 }
350
351 #[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 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 #[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}