crates/notify/src/audio.rs
Rama main · réplica de lectura del repositorio original.
//! Local audio dispatch. Policy decides the cue; this sink never substitutes a
//! bell for a missing/unsupported WAV. Tests inject a sink and never call an OS
//! player. A single in-flight WAV keeps repeated categories from stacking audio.
use super::sound_policy::SoundCue;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioOutcome {
Emitted,
Dispatched,
Unsupported,
Failed,
Busy,
}
pub fn emit_terminal(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
let bytes: &[u8] = match cue {
SoundCue::Bell | SoundCue::Beep => b"\x07",
SoundCue::DoubleBell => b"\x07\x07",
SoundCue::Whale | SoundCue::File(_) => return AudioOutcome::Unsupported,
};
if out.write_all(bytes).and_then(|()| out.flush()).is_ok() {
AudioOutcome::Emitted
} else {
AudioOutcome::Failed
}
}
pub const WHALE_WAV: &[u8] = include_bytes!("../assets/audio/codewhale-whale-call.wav");
/// Resolve an external system helper (the audio players `aplay` and the
/// pet's `ffplay`, the Linux browser launcher `xdg-open`) from fixed install
/// prefixes. The ambient `PATH` is never consulted: an empty (`::`), relative
/// (`.`) or repository-local entry would let a checked-out workspace plant a
/// helper that Codewhale then runs with the user's authority.
///
/// Known limitation: a helper installed only outside these prefixes (a
/// custom `~/bin`, a version manager shim) is refused and the caller reports
/// the feature as unavailable. That is deliberate — there is no `PATH`
/// fallback.
pub fn trusted_system_executable(name: &str) -> io::Result<PathBuf> {
trusted_player_in(name, &trusted_player_dirs())
}
fn trusted_player_in(name: &str, dirs: &[PathBuf]) -> io::Result<PathBuf> {
let file = if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_owned()
};
dirs.iter()
.filter(|dir| dir.is_absolute())
.map(|dir| dir.join(&file))
.find(|candidate| is_executable_file(candidate))
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("`{name}` is not installed in a trusted system location"),
)
})
}
fn trusted_player_dirs() -> Vec<PathBuf> {
#[cfg(unix)]
{
[
"/usr/bin",
"/bin",
"/usr/local/bin",
"/opt/homebrew/bin",
"/home/linuxbrew/.linuxbrew/bin",
"/run/current-system/sw/bin",
]
.into_iter()
.map(PathBuf::from)
.collect()
}
#[cfg(windows)]
{
// Package-manager shim directories from known folders, never PATH.
let mut found = Vec::new();
if let Some(local) = dirs::data_local_dir() {
found.push(local.join("Microsoft").join("WinGet").join("Links"));
}
if let Some(home) = dirs::home_dir() {
found.push(home.join("scoop").join("shims"));
}
if let Some(data) = std::env::var_os("ProgramData") {
found.push(PathBuf::from(data).join("chocolatey").join("bin"));
}
found
}
#[cfg(not(any(unix, windows)))]
{
Vec::new()
}
}
fn is_executable_file(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
path.metadata()
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
#[cfg(target_os = "windows")]
if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
use windows::Win32::System::Diagnostics::Debug::MessageBeep;
use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
let count = if *cue == SoundCue::DoubleBell { 2 } else { 1 };
for _ in 0..count {
if unsafe { MessageBeep(MESSAGEBOX_STYLE(0)) }.is_err() {
return AudioOutcome::Failed;
}
}
return AudioOutcome::Emitted;
}
if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
return emit_terminal(cue, out);
}
dispatch_wav(cue)
}
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
pub fn dispatch(_cue: &SoundCue, _out: &mut dyn Write) -> AudioOutcome {
// Production entry points are also fail-closed in library and scoped debug tests.
AudioOutcome::Unsupported
}
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
static PLAYING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
fn dispatch_wav(cue: &SoundCue) -> AudioOutcome {
use std::sync::atomic::Ordering;
if !cfg!(any(
target_os = "windows",
target_os = "macos",
target_os = "linux"
)) {
return AudioOutcome::Unsupported;
}
if PLAYING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return AudioOutcome::Busy;
}
let cue = cue.clone();
match std::thread::Builder::new()
.name("notification-audio".into())
.spawn(move || {
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
PLAYING.store(false, Ordering::SeqCst);
}
}
let _reset = Reset;
if let Err(error) = play_wav(&cue) {
// Do not log file names or external-player output (both may contain private data).
tracing::warn!(kind = ?error.kind(), "notification audio playback failed");
}
}) {
Ok(_) => AudioOutcome::Dispatched,
Err(_) => {
PLAYING.store(false, Ordering::SeqCst);
AudioOutcome::Failed
}
}
}
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
fn play_wav(cue: &SoundCue) -> io::Result<()> {
let mut bundled = None;
let path = match cue {
SoundCue::Whale => {
let mut file = tempfile::Builder::new()
.prefix("codewhale-call-")
.suffix(".wav")
.tempfile()?;
file.write_all(WHALE_WAV)?;
file.flush()?;
let path = file.path().to_path_buf();
bundled = Some(file);
path
}
SoundCue::File(path) => std::fs::canonicalize(path)?,
_ => return Err(io::Error::other("expected WAV cue")),
};
// Retain the private temporary file until the synchronous player exits.
let result = play_file(&path);
drop(bundled);
result
}
#[cfg(all(
not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))),
target_os = "windows"
))]
fn play_file(path: &std::path::Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Media::Audio::{PlaySoundW, SND_FILENAME, SND_NODEFAULT};
use windows::core::PCWSTR;
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// Synchronous in the worker: the bundled file must outlive playback.
if unsafe { PlaySoundW(PCWSTR(wide.as_ptr()), None, SND_FILENAME | SND_NODEFAULT) }.as_bool() {
Ok(())
} else {
Err(io::Error::other("audio player failed"))
}
}
#[cfg(all(
not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))),
any(target_os = "macos", target_os = "linux")
))]
fn play_file(path: &std::path::Path) -> io::Result<()> {
#[cfg(target_os = "macos")]
let player = PathBuf::from("/usr/bin/afplay");
#[cfg(target_os = "linux")]
let player = trusted_system_executable("aplay")?;
let status = std::process::Command::new(player)
.arg(path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other("audio player failed"))
}
}
#[cfg(all(
not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))),
not(any(target_os = "windows", target_os = "macos", target_os = "linux"))
))]
fn play_file(_path: &std::path::Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"WAV playback unsupported",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_whale_is_the_complete_pcm_wav_without_clipped_samples() {
assert_eq!(&WHALE_WAV[..4], b"RIFF");
assert_eq!(&WHALE_WAV[8..12], b"WAVE");
assert_eq!(WHALE_WAV.len(), 136754);
assert_eq!(u16::from_le_bytes(WHALE_WAV[22..24].try_into().unwrap()), 1);
assert_eq!(
u32::from_le_bytes(WHALE_WAV[24..28].try_into().unwrap()),
44100
);
assert_eq!(
u16::from_le_bytes(WHALE_WAV[34..36].try_into().unwrap()),
16
);
let samples: Vec<i16> = WHALE_WAV[44..]
.as_chunks::<2>()
.0
.iter()
.copied()
.map(i16::from_le_bytes)
.collect();
assert_eq!(samples.first(), Some(&0));
assert_eq!(samples.last(), Some(&0));
assert!(
samples
.iter()
.all(|sample| *sample != i16::MIN && *sample != i16::MAX)
);
}
#[test]
fn terminal_sink_has_exact_bell_bytes_and_no_wav_fallback() {
for (cue, bytes) in [
(SoundCue::Bell, &b"\x07"[..]),
(SoundCue::Beep, &b"\x07"[..]),
(SoundCue::DoubleBell, &b"\x07\x07"[..]),
] {
let mut out = Vec::new();
assert_eq!(emit_terminal(&cue, &mut out), AudioOutcome::Emitted);
assert_eq!(out, bytes);
}
for cue in [SoundCue::Whale, SoundCue::File("missing.wav".into())] {
let mut out = Vec::new();
assert_eq!(emit_terminal(&cue, &mut out), AudioOutcome::Unsupported);
assert!(out.is_empty());
}
}
#[test]
fn trusted_player_needs_an_executable_file_under_an_absolute_prefix() {
assert!(trusted_player_dirs().iter().all(|dir| dir.is_absolute()));
let dir = tempfile::tempdir().unwrap();
let name = "codewhale-test-player";
let file = dir.path().join(if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
});
let prefixes = [dir.path().to_path_buf()];
assert!(trusted_player_in(name, &prefixes).is_err(), "absent");
std::fs::write(&file, b"").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert!(
trusted_player_in(name, &prefixes).is_err(),
"a non-executable file is not a player"
);
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(trusted_player_in(name, &prefixes).unwrap(), file);
}
#[test]
fn production_audio_entry_is_silent_in_library_tests() {
let mut out = Vec::new();
assert_eq!(
dispatch(&SoundCue::Whale, &mut out),
AudioOutcome::Unsupported
);
assert_eq!(
dispatch(&SoundCue::Bell, &mut out),
AudioOutcome::Unsupported
);
assert!(out.is_empty());
}
}