use std::io;
use std::process::{Command, Stdio};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum NotifyError {
#[error("failed to spawn notification command: {0}")]
Spawn(#[source] Box<io::Error>),
#[error("notification command exited with code {code}: {stderr}")]
NonZeroExit {
code: i32,
stderr: Box<str>,
},
#[error("no notification backend available")]
NoBackend,
}
impl From<NotifyError> for io::Error {
fn from(e: NotifyError) -> Self {
io::Error::other(e.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotifyBackend {
NotifySend,
Osascript,
TerminalNotifier,
PowerShell,
Stderr,
}
impl NotifyBackend {
pub fn from_name(name: &str) -> Option<Self> {
match name {
"notify-send" => Some(Self::NotifySend),
"osascript" => Some(Self::Osascript),
"terminal-notifier" => Some(Self::TerminalNotifier),
"powershell" => Some(Self::PowerShell),
"stderr" => Some(Self::Stderr),
_ => None,
}
}
}
pub fn detect(override_name: Option<&str>) -> NotifyBackend {
match override_name {
None | Some("auto") => auto_detect(),
Some("stderr") => NotifyBackend::Stderr,
Some(name) => match NotifyBackend::from_name(name) {
Some(b) => b,
None => {
tracing::warn!(
backend = name,
"unknown notify_backend — falling through to auto-detect"
);
auto_detect()
}
},
}
}
fn auto_detect() -> NotifyBackend {
#[cfg(target_os = "macos")]
{
if which::which("terminal-notifier").is_ok() {
return NotifyBackend::TerminalNotifier;
}
if which::which("osascript").is_ok() {
return NotifyBackend::Osascript;
}
}
#[cfg(target_os = "windows")]
{
if which::which("powershell").is_ok() {
return NotifyBackend::PowerShell;
}
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
if which::which("notify-send").is_ok() {
return NotifyBackend::NotifySend;
}
}
NotifyBackend::Stderr
}
pub fn command_for(backend: NotifyBackend, title: &str, body: &str) -> (String, Vec<String>) {
match backend {
NotifyBackend::NotifySend => (
"notify-send".to_owned(),
vec![title.to_owned(), body.to_owned()],
),
NotifyBackend::Osascript => {
let script = format!(
"display notification \"{}\" with title \"{}\"",
escape_applescript(body),
escape_applescript(title),
);
("osascript".to_owned(), vec!["-e".to_owned(), script])
}
NotifyBackend::TerminalNotifier => (
"terminal-notifier".to_owned(),
vec![
"-title".to_owned(),
title.to_owned(),
"-message".to_owned(),
body.to_owned(),
],
),
NotifyBackend::PowerShell => {
let script = "Add-Type -AssemblyName System.Windows.Forms; \
[System.Windows.Forms.MessageBox]::Show(\
$env:KRYPT_NOTIFY_BODY, $env:KRYPT_NOTIFY_TITLE) | Out-Null"
.to_owned();
("powershell".to_owned(), vec!["-Command".to_owned(), script])
}
NotifyBackend::Stderr => (String::new(), vec![]),
}
}
pub fn escape_applescript(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
c => out.push(c),
}
}
out
}
pub fn notify(backend: NotifyBackend, title: &str, body: &str) -> Result<(), NotifyError> {
if backend == NotifyBackend::Stderr {
eprintln!("notice: {title} \u{2014} {body}");
return Ok(());
}
let (program, mut args) = command_for(backend, title, body);
let mut cmd = Command::new(&program);
cmd.args(&args);
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::piped());
cmd.stdin(Stdio::null());
if backend == NotifyBackend::PowerShell {
cmd.env("KRYPT_NOTIFY_TITLE", title);
cmd.env("KRYPT_NOTIFY_BODY", body);
}
let _ = args.as_mut_slice();
let output = cmd.output().map_err(|e| NotifyError::Spawn(Box::new(e)))?;
if !output.status.success() {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(NotifyError::NonZeroExit {
code,
stderr: stderr.into_boxed_str(),
});
}
Ok(())
}
pub struct AutoNotifier {
backend: NotifyBackend,
}
impl AutoNotifier {
pub fn new(override_name: Option<&str>) -> Self {
Self {
backend: detect(override_name),
}
}
pub fn with_backend(backend: NotifyBackend) -> Self {
Self { backend }
}
}
impl crate::runner::Notifier for AutoNotifier {
fn notify(&self, title: &str, body: &str) -> Result<(), io::Error> {
notify(self.backend, title, body).map_err(io::Error::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_none_returns_valid_backend() {
let b = detect(None);
let _ = b;
}
#[test]
fn detect_explicit_notify_send() {
assert_eq!(detect(Some("notify-send")), NotifyBackend::NotifySend);
}
#[test]
fn detect_auto_same_as_none() {
assert_eq!(detect(Some("auto")), detect(None));
}
#[test]
fn detect_explicit_stderr() {
assert_eq!(detect(Some("stderr")), NotifyBackend::Stderr);
}
#[test]
fn detect_unknown_falls_through() {
let b = detect(Some("typo-backend"));
let expected = detect(None);
assert_eq!(b, expected);
}
#[test]
fn notify_stderr_ok() {
assert!(notify(NotifyBackend::Stderr, "t", "b").is_ok());
}
#[test]
fn applescript_escaper() {
assert_eq!(escape_applescript(r#"say "hello""#), r#"say \"hello\""#);
assert_eq!(escape_applescript(r"back\slash"), r"back\\slash");
assert_eq!(escape_applescript("plain"), "plain");
assert_eq!(escape_applescript("line\nbreak"), "line\nbreak");
let input = r#"title with "quotes" and \backslash"#;
let escaped = escape_applescript(input);
assert!(!escaped.contains("\\\"") || escaped.contains("\\\\"));
}
#[test]
fn command_for_notify_send() {
let (prog, args) = command_for(NotifyBackend::NotifySend, "Title", "Body");
assert_eq!(prog, "notify-send");
assert_eq!(args, vec!["Title", "Body"]);
}
#[test]
fn command_for_terminal_notifier() {
let (prog, args) = command_for(NotifyBackend::TerminalNotifier, "My Title", "My Body");
assert_eq!(prog, "terminal-notifier");
assert!(args.contains(&"-title".to_owned()));
assert!(args.contains(&"My Title".to_owned()));
assert!(args.contains(&"-message".to_owned()));
assert!(args.contains(&"My Body".to_owned()));
}
#[test]
fn command_for_osascript_escapes_quotes() {
let (prog, args) = command_for(NotifyBackend::Osascript, r#"Ti"tle"#, r#"Bo"dy"#);
assert_eq!(prog, "osascript");
let script = &args[1];
assert!(
script.contains("\\\""),
"osascript script should escape double-quotes: {script}"
);
}
#[test]
fn command_for_powershell() {
let (prog, args) = command_for(NotifyBackend::PowerShell, "T", "B");
assert_eq!(prog, "powershell");
assert!(args.iter().any(|a| a.contains("MessageBox")));
}
#[test]
fn meta_notify_backend_parses() {
let toml = "[meta]\nnotify_backend = \"osascript\"\n";
let cfg: crate::config::Config = toml::from_str(toml).expect("parse");
assert_eq!(cfg.meta.notify_backend.as_deref(), Some("osascript"));
}
#[test]
fn meta_notify_backend_defaults_none() {
let toml = "[meta]\nname = \"test\"\n";
let cfg: crate::config::Config = toml::from_str(toml).expect("parse");
assert!(cfg.meta.notify_backend.is_none());
}
}