Files
peerspeak/src/notify.rs
T
molluskandClaude Opus 4.8 7e75ae31af feat(notify): per-sound notification toggles (W6)
Add a per-event enable checkbox next to each chime in Settings so a user
can silence individual sounds (e.g. keep 'message'/peer-join but drop
reconnect chimes) while the master 'Enable sound notifications' toggle
stays as the global kill-switch.

The gate lives in one place at the play() seam: a pure should_play(master,
sound) AND that's unit-tested, fed by a per-sound AtomicBool array in
notify keyed by a stable Sound::index/ALL. Flags persist as 8 sound_*_enabled
bools in AppConfig (default true, so upgrades are silent-change-free) with
sound_enabled/set_sound_enabled accessors centralizing the field mapping.
The per-sound checkbox greys out (drops on_toggle) while the master is off.

+3 notify unit tests (should_play truth table, index bijection, flag
set/query independence) + extended config backward-compat test. 230 lib
tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:06:49 -04:00

300 lines
12 KiB
Rust

//! 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
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). 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::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
static ENABLED: AtomicBool = AtomicBool::new(true);
/// 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<bool> {
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<PathBuf> {
static CACHE: OnceLock<Mutex<HashMap<&'static str, PathBuf>>> = 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 = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name()));
if std::fs::write(&path, sound.bytes()).is_err() {
return None;
}
guard.insert(sound.name(), path.clone());
Some(path)
}
/// 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.
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;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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_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));
}
}