//! Notifications and sounds, mirroring herdr's macOS delivery. use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{info, warn}; pub enum NotificationKind { NeedsAttention, Finished, } impl NotificationKind { pub fn title(&self) -> &'static str { match self { Self::NeedsAttention => "needs attention", Self::Finished => "finished", } } /// herdr's bundled notification sounds; `request` for blockers, `done` /// for completions. fn sound_bytes(&self) -> &'static [u8] { match self { Self::NeedsAttention => include_bytes!("../assets/request.mp3"), Self::Finished => include_bytes!("../assets/done.mp3"), } } } static SOUND_SEQ: AtomicU64 = AtomicU64::new(0); pub fn play_sound(kind: &NotificationKind) { // Test seam: play through a command instead (tests/e2e.sh). if let Ok(command) = std::env::var("DINGGER_SOUND") { let name = match kind { NotificationKind::NeedsAttention => "request", NotificationKind::Finished => "done", }; let _ = Command::new("sh").args(["-c", &command, name]).stdin(Stdio::null()).spawn(); return; } // Sounds are embedded; afplay needs a file, so stage the bytes in the // system temp dir. let path = std::env::temp_dir().join(format!( "dingger-{}-{}.mp3", std::process::id(), SOUND_SEQ.fetch_add(1, Ordering::Relaxed) )); if let Err(err) = std::fs::write(&path, kind.sound_bytes()) { warn!(%err, "failed to stage notification sound"); return; } match Command::new("afplay") .arg(&path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() { // Fire and forget: never let audio latency block the watch loop. Ok(mut child) => { std::thread::spawn(move || { let _ = child.wait(); let _ = std::fs::remove_file(&path); }); } Err(err) => { warn!(%err, "failed to play notification sound"); let _ = std::fs::remove_file(&path); } } } /// Deliver a notification and wait for it: for short-lived commands, whose /// exit would cut a background delivery short. pub fn send_message_blocking(title: &str, body: &str) { info!(title, body, "notifying"); if let Ok(command) = std::env::var("DINGGER_NOTIFIER") { let _ = std::process::Command::new("sh").args(["-c", &command, title, body]).status(); return; } if let Err(err) = mac_notification_sys::Notification::new().title(title).message(body).send() { warn!(%err, "failed to deliver macOS notification"); } } /// Deliver a macOS notification without blocking the caller: the delivery /// call waits on Notification Center for seconds, and the watcher must keep /// updating the UI meanwhile. pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) { info!(agent = agent_label, kind = kind.title(), context, "notifying"); let title = format!("{agent_label} {}", kind.title()); let context = context.to_string(); std::thread::spawn(move || { // Test seam: deliver through a command instead (tests/e2e.sh). if let Ok(command) = std::env::var("DINGGER_NOTIFIER") { let _ = std::process::Command::new("sh") .args(["-c", &command, &title, &context]) .status(); return; } let notification = mac_notification_sys::Notification::new() .title(title.as_str()) .message(context.as_str()) .send(); if let Err(err) = notification { warn!(%err, "failed to deliver macOS notification"); } }); }