//! Notification chimes for room events (join / peer-join / peer-leave, with more //! events in later phases). //! //! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single //! binary is self-contained — no asset directory to ship alongside it. On first //! use each sound is written once to a temp file, then played fire-and-forget. //! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`); //! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a //! detached thread that waits on the child, so it never blocks the UI and never //! leaves a zombie. Any failure (no player, no audio) is silent by design — a //! missing chime should never disrupt a call. use std::collections::HashMap; use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; static ENABLED: AtomicBool = AtomicBool::new(true); static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0); /// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED` /// toggle gates everything; these silence individual events while the master /// stays on. Both must be true for a chime to play (see `should_play`). All /// start true so behaviour is unchanged until a user opts a sound out. static SOUND_ENABLED: [AtomicBool; Sound::COUNT] = { // The repeat-init `const` is the standard way to build an atomic array; the // interior-mutability lint is a false positive here (we want one fresh // `AtomicBool` per slot, which is exactly what this does). #[allow(clippy::declare_interior_mutable_const)] const TRUE: AtomicBool = AtomicBool::new(true); [TRUE; Sound::COUNT] }; /// Enable or disable all notification chimes globally (the master toggle). pub fn set_enabled(enabled: bool) { ENABLED.store(enabled, Ordering::Relaxed); } /// Check if notifications are enabled (the master toggle). pub fn is_enabled() -> bool { ENABLED.load(Ordering::Relaxed) } /// Enable or disable the chime for a single event, independent of the master. pub fn set_sound_enabled(sound: Sound, enabled: bool) { SOUND_ENABLED[sound.index()].store(enabled, Ordering::Relaxed); } /// Whether the chime for `sound` is enabled (its own flag only, not the master). pub fn is_sound_enabled(sound: Sound) -> bool { SOUND_ENABLED[sound.index()].load(Ordering::Relaxed) } /// The single gate deciding whether a chime plays: the master toggle AND the /// per-sound flag must both be on. Pure so the rule is unit-testable. pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool { master_enabled && sound_enabled } /// A notification event with a distinct chime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Sound { /// You joined a room. SelfJoin, /// Another participant joined. PeerJoin, /// A participant left. PeerLeave, /// Reconnecting to a peer. ReconnectAttempt, /// Reconnected to a peer. Reconnected, /// You left a room. SelfLeave, /// Mic mute/unmute toggle. MicToggle, /// Reconnect failed / peer evicted. ReconnectFailed, } impl Sound { /// Every notification event, in a stable order (also the `index` order). pub const ALL: [Sound; Sound::COUNT] = [ Sound::SelfJoin, Sound::PeerJoin, Sound::PeerLeave, Sound::ReconnectAttempt, Sound::Reconnected, Sound::SelfLeave, Sound::MicToggle, Sound::ReconnectFailed, ]; /// Number of distinct notification events. pub const COUNT: usize = 8; /// Stable 0-based index into the per-sound flag array. Must match `ALL`. fn index(self) -> usize { match self { Sound::SelfJoin => 0, Sound::PeerJoin => 1, Sound::PeerLeave => 2, Sound::ReconnectAttempt => 3, Sound::Reconnected => 4, Sound::SelfLeave => 5, Sound::MicToggle => 6, Sound::ReconnectFailed => 7, } } /// Embedded WAV bytes for this sound. fn bytes(self) -> &'static [u8] { match self { Sound::SelfJoin => include_bytes!("../assets/sounds/self-join.wav"), Sound::PeerJoin => include_bytes!("../assets/sounds/peer-join.wav"), Sound::PeerLeave => include_bytes!("../assets/sounds/peer-leave.wav"), Sound::ReconnectAttempt => include_bytes!("../assets/sounds/reconnect-attempt.wav"), Sound::Reconnected => include_bytes!("../assets/sounds/reconnected.wav"), Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"), Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"), Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"), } } /// Stable file stem for the cached temp file. fn name(self) -> &'static str { match self { Sound::SelfJoin => "self-join", Sound::PeerJoin => "peer-join", Sound::PeerLeave => "peer-leave", Sound::ReconnectAttempt => "reconnect-attempt", Sound::Reconnected => "reconnected", Sound::SelfLeave => "self-leave", Sound::MicToggle => "mic-toggle", Sound::ReconnectFailed => "reconnect-failed", } } } /// Helper to expand `~` at the start of a path to the user's home directory. pub fn expand_tilde(path_str: &str) -> PathBuf { let trimmed = path_str.trim(); if trimmed == "~" { dirs::home_dir().unwrap_or_else(|| PathBuf::from(trimmed)) } else if let Some(stripped) = trimmed.strip_prefix("~/") { dirs::home_dir() .map(|home| home.join(stripped)) .unwrap_or_else(|| PathBuf::from(trimmed)) } else { PathBuf::from(trimmed) } } /// Check if a custom path is valid (i.e. if it exists and is a file). /// Returns `None` if empty/whitespace, `Some(true)` if valid, `Some(false)` if invalid. pub fn validate_custom_path(path_str: &str) -> Option { if path_str.trim().is_empty() { None } else { let path = expand_tilde(path_str); Some(path.exists() && path.is_file()) } } /// Play a notification chime, fire-and-forget. Never blocks; never errors out. pub fn play(sound: Sound, custom_path: Option<&str>) { if !should_play(is_enabled(), is_sound_enabled(sound)) { return; } let custom_path = custom_path.map(String::from); std::thread::spawn(move || { if let Some(ref path_str) = custom_path && !path_str.trim().is_empty() { let path = expand_tilde(path_str); if path.exists() && path.is_file() { spawn_player(&path); return; } } if let Some(path) = cached_path(sound) { spawn_player(&path); } }); } /// Materialize the embedded WAV to a temp file the first time it's needed and /// cache the path. Returns `None` only if the file can't be written. fn cached_path(sound: Sound) -> Option { static CACHE: OnceLock>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut guard = cache.lock().unwrap(); if let Some(path) = guard.get(sound.name()) { return Some(path.clone()); } let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) { Ok(path) => path, Err(_) => return None, }; guard.insert(sound.name(), path.clone()); Some(path) } fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result { let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let path = dir.join(format!( "peerspeak-{stem}-{}-{counter}-{nanos}.wav", std::process::id() )); let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } let mut file = options.open(&path)?; file.write_all(bytes)?; Ok(path) } #[cfg(any(windows, test))] fn escape_powershell_single_quoted(s: &str) -> String { s.replace('\'', "''") } /// Try each available player in turn, waiting on the first that starts (which /// reaps the child). Runs on a detached thread, so the wait is harmless. #[cfg(not(windows))] fn spawn_player(path: &Path) { for player in ["pw-play", "paplay", "aplay"] { let started = Command::new(player) .arg(path) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); // `status()` errors only if the player binary isn't present; on a real // playback error it still returns (non-zero), so a started player ends // the loop either way — we don't want to double-play through fallbacks. if started.is_ok() { return; } } } /// Play through Windows' built-in WAV player. Runs on a detached thread, so /// `PlaySync()` blocking for the sound duration is fine. #[cfg(windows)] fn spawn_player(path: &Path) { let path = escape_powershell_single_quoted(&path.display().to_string()); let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()"); let _ = Command::new("powershell") .arg("-NoProfile") .arg("-NonInteractive") .arg("-Command") .arg(command) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); } #[cfg(test)] mod tests { use super::*; static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); fn temp_wav_dir(tag: &str) -> PathBuf { let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "peerspeak-notifytest-{}-{tag}-{counter}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn test_should_play_truth_table() { // Plays only when BOTH the master and the per-sound flag are on. assert!(should_play(true, true)); assert!(!should_play(true, false)); assert!(!should_play(false, true)); assert!(!should_play(false, false)); } #[test] fn test_powershell_single_quote_escape() { assert_eq!( escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"), r"C:\Users\O''Brien\chime.wav" ); assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c"); } #[test] fn test_sound_indices_unique_and_match_all() { // `index()` must be a 0..COUNT bijection in `ALL` order, or the flag // array would alias two sounds onto one slot. for (expected, sound) in Sound::ALL.iter().enumerate() { assert_eq!(sound.index(), expected, "index drift for {sound:?}"); } assert_eq!(Sound::ALL.len(), Sound::COUNT); } #[test] fn test_set_and_query_per_sound_flag() { // Toggling one sound's flag is independent of the others. (Uses // PeerLeave to avoid clashing with flags other tests might touch.) set_sound_enabled(Sound::PeerLeave, false); assert!(!is_sound_enabled(Sound::PeerLeave)); assert!(is_sound_enabled(Sound::PeerJoin)); set_sound_enabled(Sound::PeerLeave, true); assert!(is_sound_enabled(Sound::PeerLeave)); } #[test] fn test_expand_tilde() { if let Some(home) = dirs::home_dir() { // bare `~` -> home dir assert_eq!(expand_tilde("~"), home); // `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav` assert_eq!( expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav") ); } // absolute path (`/etc/foo.wav`) -> unchanged assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav")); // relative path (`foo/bar.wav`) -> unchanged assert_eq!(expand_tilde("foo/bar.wav"), PathBuf::from("foo/bar.wav")); // a tilde not at the start (`/opt/~/x.wav`) -> unchanged assert_eq!(expand_tilde("/opt/~/x.wav"), PathBuf::from("/opt/~/x.wav")); // `~username` style (`~bob/x.wav`) -> unchanged assert_eq!(expand_tilde("~bob/x.wav"), PathBuf::from("~bob/x.wav")); // leading/trailing whitespace is trimmed if let Some(home) = dirs::home_dir() { assert_eq!(expand_tilde(" ~ "), home); assert_eq!( expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav") ); } assert_eq!( expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav") ); assert_eq!( expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav") ); } #[test] fn test_validate_custom_path() { // empty and whitespace-only -> None assert_eq!(validate_custom_path(""), None); assert_eq!(validate_custom_path(" "), None); // a path that does not exist -> Some(false) assert_eq!(validate_custom_path("/non/existent/file.wav"), Some(false)); // a real existing file -> Some(true) let existing_file = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); assert_eq!(validate_custom_path(existing_file), Some(true)); // an existing directory -> Some(false) let existing_dir = env!("CARGO_MANIFEST_DIR"); assert_eq!(validate_custom_path(existing_dir), Some(false)); // a `~`-prefixed path that resolves to a non-existent file -> Some(false) assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false)); } #[test] fn write_private_wav_writes_exact_bytes() { let dir = temp_wav_dir("writes"); let bytes = b"RIFFpeerspeak-test"; let path = write_private_wav(&dir, "unit", bytes).unwrap(); assert!(path.exists()); assert_eq!(std::fs::read(&path).unwrap(), bytes); let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] fn write_private_wav_creates_0600_file() { use std::os::unix::fs::PermissionsExt; let dir = temp_wav_dir("mode"); let path = write_private_wav(&dir, "unit", b"mode").unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); let _ = std::fs::remove_dir_all(&dir); } #[test] fn write_private_wav_uses_unique_paths() { let dir = temp_wav_dir("unique"); let first = write_private_wav(&dir, "same-stem", b"first").unwrap(); let second = write_private_wav(&dir, "same-stem", b"second").unwrap(); assert_ne!(first, second); assert!(first.exists()); assert!(second.exists()); assert_eq!(std::fs::read(&first).unwrap(), b"first"); assert_eq!(std::fs::read(&second).unwrap(), b"second"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn create_new_refuses_existing_path() { let dir = temp_wav_dir("create-new"); let path = dir.join("preexisting.wav"); std::fs::write(&path, b"original").unwrap(); let err = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&path) .unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); assert_eq!(std::fs::read(&path).unwrap(), b"original"); let _ = std::fs::remove_dir_all(&dir); } }