aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 03:25:50 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 03:25:50 +0300
commitfdf6a0180d3cc1ee6631107526cf26212f283d08 (patch)
tree262218d3d552fb44910e43bff49a00467f84a3aa /src
parentbfe2f84f4fc8f37517eaadfa3274eb0ff79b47ab (diff)
fix: improve macos system notifications
Diffstat (limited to 'src')
-rw-r--r--src/platform/macos.rs263
1 files changed, 256 insertions, 7 deletions
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index 3debc244..417251ce 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -3,10 +3,12 @@ use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
+use std::sync::OnceLock;
use super::{ClipboardCommand, ForegroundJob, ForegroundProcess, Signal};
const PROC_PGRP_ONLY: u32 = 2;
+const HERDR_LOGO_PNG: &[u8] = include_bytes!("../../assets/logo.png");
/// Collect the foreground terminal job for a given child PID.
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
@@ -201,7 +203,11 @@ pub fn write_clipboard(bytes: &[u8]) -> bool {
)
}
-/// Show a native macOS notification through AppleScript.
+/// Show a native macOS notification.
+///
+/// Prefer `terminal-notifier` when it is installed because it supports Herdr's
+/// icon and can activate the hosting terminal on click. Fall back to built-in
+/// AppleScript notifications when it is not available.
pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
show_desktop_notification_with_command(title, body, |program| Command::new(program))
}
@@ -211,6 +217,63 @@ fn show_desktop_notification_with_command(
body: Option<&str>,
mut command: impl FnMut(&str) -> Command,
) -> std::io::Result<bool> {
+ if show_terminal_notifier_notification(title, body, &mut command).unwrap_or(false) {
+ return Ok(true);
+ }
+
+ show_osascript_notification(title, body, &mut command)
+}
+
+fn show_terminal_notifier_notification(
+ title: &str,
+ body: Option<&str>,
+ command: &mut impl FnMut(&str) -> Command,
+) -> std::io::Result<bool> {
+ let icon_path = cached_notification_icon_path().ok();
+ let activate_bundle_id = verified_terminal_bundle_identifier(command);
+ show_terminal_notifier_notification_with_options(
+ title,
+ body,
+ icon_path.as_deref(),
+ activate_bundle_id.as_deref(),
+ command,
+ )
+}
+
+fn show_terminal_notifier_notification_with_options(
+ title: &str,
+ body: Option<&str>,
+ icon_path: Option<&Path>,
+ activate_bundle_id: Option<&str>,
+ command: &mut impl FnMut(&str) -> Command,
+) -> std::io::Result<bool> {
+ let mut cmd = command("terminal-notifier");
+ build_terminal_notifier_command(&mut cmd, title, body, icon_path, activate_bundle_id);
+ run_notification_command(cmd)
+}
+
+fn build_terminal_notifier_command(
+ cmd: &mut Command,
+ title: &str,
+ body: Option<&str>,
+ icon_path: Option<&Path>,
+ activate_bundle_id: Option<&str>,
+) {
+ cmd.arg("-title").arg(title);
+ cmd.arg("-message").arg(body.unwrap_or_default());
+ if let Some(icon_path) = icon_path {
+ cmd.arg("-appIcon").arg(icon_path);
+ }
+ if let Some(bundle_id) = activate_bundle_id {
+ cmd.arg("-activate").arg(bundle_id);
+ }
+}
+
+fn show_osascript_notification(
+ title: &str,
+ body: Option<&str>,
+ command: &mut impl FnMut(&str) -> Command,
+) -> std::io::Result<bool> {
let mut cmd = command("/usr/bin/osascript");
cmd.arg("-e")
.arg("on run argv")
@@ -223,6 +286,87 @@ fn show_desktop_notification_with_command(
run_notification_command(cmd)
}
+fn cached_notification_icon_path() -> std::io::Result<PathBuf> {
+ static ICON_PATH: OnceLock<std::io::Result<PathBuf>> = OnceLock::new();
+ match ICON_PATH.get_or_init(write_notification_icon) {
+ Ok(path) => Ok(path.clone()),
+ Err(err) => Err(std::io::Error::new(err.kind(), err.to_string())),
+ }
+}
+
+fn write_notification_icon() -> std::io::Result<PathBuf> {
+ let cache_dir = if let Ok(home) = std::env::var("HOME") {
+ PathBuf::from(home).join("Library/Caches/herdr")
+ } else {
+ std::env::temp_dir().join("herdr")
+ };
+ std::fs::create_dir_all(&cache_dir)?;
+ let path = cache_dir.join("logo.png");
+ if std::fs::read(&path).ok().as_deref() != Some(HERDR_LOGO_PNG) {
+ std::fs::write(&path, HERDR_LOGO_PNG)?;
+ }
+ Ok(path)
+}
+
+fn verified_terminal_bundle_identifier(
+ command: &mut impl FnMut(&str) -> Command,
+) -> Option<String> {
+ static BUNDLE_ID: OnceLock<Option<String>> = OnceLock::new();
+ BUNDLE_ID
+ .get_or_init(|| {
+ let bundle_id = detected_terminal_bundle_identifier()?;
+ bundle_identifier_available(bundle_id, command).then(|| bundle_id.to_owned())
+ })
+ .clone()
+}
+
+fn bundle_identifier_available(bundle_id: &str, command: &mut impl FnMut(&str) -> Command) -> bool {
+ let query = format!("kMDItemCFBundleIdentifier == '{bundle_id}'");
+ let output = command("mdfind")
+ .arg(query)
+ .stdin(Stdio::null())
+ .stderr(Stdio::null())
+ .output();
+
+ match output {
+ Ok(output) if output.status.success() => !output.stdout.is_empty(),
+ _ => false,
+ }
+}
+
+fn detected_terminal_bundle_identifier() -> Option<&'static str> {
+ terminal_bundle_identifier_from_env(
+ std::env::var("TERM_PROGRAM").ok().as_deref(),
+ std::env::var("TERM").ok().as_deref(),
+ std::env::var_os("KITTY_WINDOW_ID").is_some(),
+ std::env::var_os("ALACRITTY_WINDOW_ID").is_some(),
+ )
+}
+
+fn terminal_bundle_identifier_from_env(
+ term_program: Option<&str>,
+ term: Option<&str>,
+ has_kitty_window_id: bool,
+ has_alacritty_window_id: bool,
+) -> Option<&'static str> {
+ match term_program {
+ Some("ghostty") => return Some("com.mitchellh.ghostty"),
+ Some("iTerm.app") => return Some("com.googlecode.iterm2"),
+ Some("WezTerm") => return Some("com.github.wez.wezterm"),
+ Some("Apple_Terminal") => return Some("com.apple.Terminal"),
+ _ => {}
+ }
+
+ if has_kitty_window_id || term == Some("xterm-kitty") {
+ return Some("net.kovidgoyal.kitty");
+ }
+ if has_alacritty_window_id {
+ return Some("org.alacritty");
+ }
+
+ None
+}
+
fn run_notification_command(mut command: Command) -> std::io::Result<bool> {
let status = match command
.stdin(Stdio::null())
@@ -508,19 +652,124 @@ mod tests {
}
#[test]
- fn desktop_notification_uses_osascript_argv() {
+ fn terminal_bundle_identifier_maps_known_terminal_env() {
+ assert_eq!(
+ terminal_bundle_identifier_from_env(Some("ghostty"), None, false, false),
+ Some("com.mitchellh.ghostty")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(Some("iTerm.app"), None, false, false),
+ Some("com.googlecode.iterm2")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(Some("WezTerm"), None, false, false),
+ Some("com.github.wez.wezterm")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(Some("Apple_Terminal"), None, false, false),
+ Some("com.apple.Terminal")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(None, Some("xterm-kitty"), false, false),
+ Some("net.kovidgoyal.kitty")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(None, None, true, false),
+ Some("net.kovidgoyal.kitty")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(None, None, false, true),
+ Some("org.alacritty")
+ );
+ assert_eq!(
+ terminal_bundle_identifier_from_env(None, None, false, false),
+ None
+ );
+ }
+
+ #[test]
+ fn terminal_notifier_command_includes_icon_and_activation() {
+ let mut cmd = Command::new("terminal-notifier");
+ build_terminal_notifier_command(
+ &mut cmd,
+ "pi finished",
+ Some("workspace 1"),
+ Some(Path::new("/tmp/herdr-logo.png")),
+ Some("com.mitchellh.ghostty"),
+ );
+ let args = cmd
+ .get_args()
+ .map(|arg| arg.to_string_lossy().into_owned())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ args,
+ vec![
+ "-title",
+ "pi finished",
+ "-message",
+ "workspace 1",
+ "-appIcon",
+ "/tmp/herdr-logo.png",
+ "-activate",
+ "com.mitchellh.ghostty"
+ ]
+ );
+ }
+
+ #[test]
+ fn terminal_notifier_success_skips_osascript() {
+ let path = std::env::temp_dir().join(format!(
+ "herdr-terminal-notifier-args-{}",
+ std::process::id()
+ ));
+ let script = "printf '%s:%s\\n' \"$0\" \"$*\" >> \"$HERDR_NOTIFY_ARGS\"";
+ let mut command = |program: &str| {
+ let mut cmd = Command::new("sh");
+ cmd.arg("-c")
+ .arg(script)
+ .arg(program)
+ .env("HERDR_NOTIFY_ARGS", &path);
+ cmd
+ };
+
+ let shown = show_terminal_notifier_notification_with_options(
+ "title",
+ Some("body"),
+ Some(Path::new("/tmp/herdr-logo.png")),
+ Some("com.mitchellh.ghostty"),
+ &mut command,
+ )
+ .expect("terminal-notifier command should run");
+
+ assert!(shown);
+ let args = std::fs::read_to_string(&path).expect("args file");
+ let _ = std::fs::remove_file(&path);
+ assert!(args.starts_with("terminal-notifier:"), "{args}");
+ assert!(args.contains("-appIcon /tmp/herdr-logo.png"), "{args}");
+ assert!(args.contains("-activate com.mitchellh.ghostty"), "{args}");
+ assert!(!args.contains("osascript"), "{args}");
+ }
+
+ #[test]
+ fn desktop_notification_falls_back_to_osascript_when_terminal_notifier_fails() {
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 script = r#"
+if [ "$0" = "terminal-notifier" ]; then
+ exit 1
+fi
+printf '%s\n' "$@" > "$HERDR_NOTIFY_ARGS"
+"#;
+ let mut command = |program: &str| {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(script)
- .arg("osascript")
+ .arg(program)
.env("HERDR_NOTIFY_ARGS", &path);
cmd
- })
- .expect("notification command should run");
+ };
+ let shown = show_desktop_notification_with_command("title", Some("body"), &mut command)
+ .expect("osascript fallback should run");
assert!(shown);
let args = std::fs::read_to_string(&path).expect("args file");