From bfe2f84f4fc8f37517eaadfa3274eb0ff79b47ab Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 15 May 2026 01:37:57 +0300 Subject: feat: add system toast delivery --- src/platform/fallback.rs | 5 ++++ src/platform/linux.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ src/platform/macos.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) (limited to 'src/platform') diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 7addbc5d..bf3ac42b 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -34,3 +34,8 @@ pub fn process_exists(_pid: u32) -> bool { pub fn write_clipboard(_bytes: &[u8]) -> bool { false } + +/// Unsupported platform stub. +pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result { + Ok(false) +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 13aa6ec8..6823cca8 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -155,6 +155,43 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { false } +/// Show a native desktop notification through libnotify's command-line helper. +pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result { + show_desktop_notification_with_command(title, body, |program| Command::new(program)) +} + +fn show_desktop_notification_with_command( + title: &str, + body: Option<&str>, + mut command: impl FnMut(&str) -> Command, +) -> std::io::Result { + if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { + return Ok(false); + } + + let mut cmd = command("notify-send"); + cmd.arg("--").arg(title); + if let Some(body) = body.filter(|body| !body.is_empty()) { + cmd.arg(body); + } + run_notification_command(cmd) +} + +fn run_notification_command(mut command: Command) -> std::io::Result { + let status = match command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) => status, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + + Ok(status.success()) +} + fn clipboard_commands() -> Vec { let mut commands = Vec::new(); @@ -248,4 +285,31 @@ mod tests { assert_eq!(commands[0].program, "xclip"); assert_eq!(commands[1].program, "xsel"); } + + #[test] + fn desktop_notification_separates_option_like_titles() { + let _guard = env_lock().lock().unwrap(); + unsafe { + std::env::remove_var("WAYLAND_DISPLAY"); + std::env::set_var("DISPLAY", ":0"); + } + + let path = + std::env::temp_dir().join(format!("herdr-notify-send-args-{}", std::process::id())); + let script = "printf '%s\\n' \"$@\" > \"$HERDR_NOTIFY_ARGS\""; + let shown = show_desktop_notification_with_command("-danger", Some("body"), |_| { + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(script) + .arg("notify-send") + .env("HERDR_NOTIFY_ARGS", &path); + cmd + }) + .expect("notification command should run"); + + assert!(shown); + let args = std::fs::read_to_string(&path).expect("args file"); + let _ = std::fs::remove_file(&path); + assert_eq!(args, "--\n-danger\nbody\n"); + } } diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 987f5f54..3debc244 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -201,6 +201,43 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { ) } +/// Show a native macOS notification through AppleScript. +pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result { + show_desktop_notification_with_command(title, body, |program| Command::new(program)) +} + +fn show_desktop_notification_with_command( + title: &str, + body: Option<&str>, + mut command: impl FnMut(&str) -> Command, +) -> std::io::Result { + let mut cmd = command("/usr/bin/osascript"); + cmd.arg("-e") + .arg("on run argv") + .arg("-e") + .arg("display notification (item 2 of argv) with title (item 1 of argv)") + .arg("-e") + .arg("end run") + .arg(title) + .arg(body.unwrap_or_default()); + run_notification_command(cmd) +} + +fn run_notification_command(mut command: Command) -> std::io::Result { + let status = match command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) => status, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + + Ok(status.success()) +} + fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool { let mut child = match Command::new(command.program) .args(command.args) @@ -469,4 +506,28 @@ mod tests { assert_eq!(argv.join(" "), "node /Users/can/.local/bin/pi"); assert!(!argv.join(" ").contains("codex.system")); } + + #[test] + fn desktop_notification_uses_osascript_argv() { + let path = + std::env::temp_dir().join(format!("herdr-osascript-args-{}", std::process::id())); + let script = "printf '%s\\n' \"$@\" > \"$HERDR_NOTIFY_ARGS\""; + let shown = show_desktop_notification_with_command("title", Some("body"), |_| { + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(script) + .arg("osascript") + .env("HERDR_NOTIFY_ARGS", &path); + cmd + }) + .expect("notification command should run"); + + assert!(shown); + let args = std::fs::read_to_string(&path).expect("args file"); + let _ = std::fs::remove_file(&path); + assert_eq!( + args, + "-e\non run argv\n-e\ndisplay notification (item 2 of argv) with title (item 1 of argv)\n-e\nend run\ntitle\nbody\n" + ); + } } -- cgit v1.2.3