The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
442 lines
16 KiB
Rust
442 lines
16 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.
|
|
//! 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<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 = 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<PathBuf> {
|
|
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);
|
|
}
|
|
}
|