返回 CodeWhale
audio.rs
根目录 / crates / tui / src / notify / audio.rs
1 //! Local audio dispatch. Policy decides the cue; this sink never substitutes a
2 //! bell for a missing/unsupported WAV. Tests inject a sink and never call an OS
3 //! player. A single in-flight WAV keeps repeated categories from stacking audio.
4 use super::sound_policy::SoundCue;
5 use std::io::{self, Write};
6 use std::path::{Path, PathBuf};
7
8 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9 pub enum AudioOutcome {
10 Emitted,
11 Dispatched,
12 Unsupported,
13 Failed,
14 Busy,
15 }
16
17 pub fn emit_terminal(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
18 let bytes: &[u8] = match cue {
19 SoundCue::Bell | SoundCue::Beep => b"\x07",
20 SoundCue::DoubleBell => b"\x07\x07",
21 SoundCue::Whale | SoundCue::File(_) => return AudioOutcome::Unsupported,
22 };
23 if out.write_all(bytes).and_then(|()| out.flush()).is_ok() {
24 AudioOutcome::Emitted
25 } else {
26 AudioOutcome::Failed
27 }
28 }
29
30 pub const WHALE_WAV: &[u8] = include_bytes!("../../assets/audio/codewhale-whale-call.wav");
31
32 /// Resolve an external system helper (the audio players `aplay` and the
33 /// pet's `ffplay`, the Linux browser launcher `xdg-open`) from fixed install
34 /// prefixes. The ambient `PATH` is never consulted: an empty (`::`), relative
35 /// (`.`) or repository-local entry would let a checked-out workspace plant a
36 /// helper that Codewhale then runs with the user's authority.
37 ///
38 /// Known limitation: a helper installed only outside these prefixes (a
39 /// custom `~/bin`, a version manager shim) is refused and the caller reports
40 /// the feature as unavailable. That is deliberate — there is no `PATH`
41 /// fallback.
42 pub(crate) fn trusted_system_executable(name: &str) -> io::Result<PathBuf> {
43 trusted_player_in(name, &trusted_player_dirs())
44 }
45
46 fn trusted_player_in(name: &str, dirs: &[PathBuf]) -> io::Result<PathBuf> {
47 let file = if cfg!(windows) {
48 format!("{name}.exe")
49 } else {
50 name.to_owned()
51 };
52 dirs.iter()
53 .filter(|dir| dir.is_absolute())
54 .map(|dir| dir.join(&file))
55 .find(|candidate| is_executable_file(candidate))
56 .ok_or_else(|| {
57 io::Error::new(
58 io::ErrorKind::NotFound,
59 format!("`{name}` is not installed in a trusted system location"),
60 )
61 })
62 }
63
64 fn trusted_player_dirs() -> Vec<PathBuf> {
65 #[cfg(unix)]
66 {
67 [
68 "/usr/bin",
69 "/bin",
70 "/usr/local/bin",
71 "/opt/homebrew/bin",
72 "/home/linuxbrew/.linuxbrew/bin",
73 "/run/current-system/sw/bin",
74 ]
75 .into_iter()
76 .map(PathBuf::from)
77 .collect()
78 }
79 #[cfg(windows)]
80 {
81 // Package-manager shim directories from known folders, never PATH.
82 let mut found = Vec::new();
83 if let Some(local) = dirs::data_local_dir() {
84 found.push(local.join("Microsoft").join("WinGet").join("Links"));
85 }
86 if let Some(home) = dirs::home_dir() {
87 found.push(home.join("scoop").join("shims"));
88 }
89 if let Some(data) = std::env::var_os("ProgramData") {
90 found.push(PathBuf::from(data).join("chocolatey").join("bin"));
91 }
92 found
93 }
94 #[cfg(not(any(unix, windows)))]
95 {
96 Vec::new()
97 }
98 }
99
100 fn is_executable_file(path: &Path) -> bool {
101 #[cfg(unix)]
102 {
103 use std::os::unix::fs::PermissionsExt as _;
104 path.metadata()
105 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
106 }
107 #[cfg(not(unix))]
108 {
109 path.is_file()
110 }
111 }
112
113 #[cfg(not(test))]
114 pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
115 #[cfg(target_os = "windows")]
116 if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
117 use windows::Win32::System::Diagnostics::Debug::MessageBeep;
118 use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
119 let count = if *cue == SoundCue::DoubleBell { 2 } else { 1 };
120 for _ in 0..count {
121 if unsafe { MessageBeep(MESSAGEBOX_STYLE(0)) }.is_err() {
122 return AudioOutcome::Failed;
123 }
124 }
125 return AudioOutcome::Emitted;
126 }
127 if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
128 return emit_terminal(cue, out);
129 }
130 dispatch_wav(cue)
131 }
132
133 #[cfg(test)]
134 pub fn dispatch(_cue: &SoundCue, _out: &mut dyn Write) -> AudioOutcome {
135 // Production entry points are also fail-closed in library tests.
136 AudioOutcome::Unsupported
137 }
138
139 #[cfg(not(test))]
140 static PLAYING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
141
142 #[cfg(not(test))]
143 fn dispatch_wav(cue: &SoundCue) -> AudioOutcome {
144 use std::sync::atomic::Ordering;
145 if !cfg!(any(
146 target_os = "windows",
147 target_os = "macos",
148 target_os = "linux"
149 )) {
150 return AudioOutcome::Unsupported;
151 }
152 if PLAYING
153 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
154 .is_err()
155 {
156 return AudioOutcome::Busy;
157 }
158 let cue = cue.clone();
159 match std::thread::Builder::new()
160 .name("notification-audio".into())
161 .spawn(move || {
162 struct Reset;
163 impl Drop for Reset {
164 fn drop(&mut self) {
165 PLAYING.store(false, Ordering::SeqCst);
166 }
167 }
168 let _reset = Reset;
169 if let Err(error) = play_wav(&cue) {
170 // Do not log file names or external-player output (both may contain private data).
171 tracing::warn!(kind = ?error.kind(), "notification audio playback failed");
172 }
173 }) {
174 Ok(_) => AudioOutcome::Dispatched,
175 Err(_) => {
176 PLAYING.store(false, Ordering::SeqCst);
177 AudioOutcome::Failed
178 }
179 }
180 }
181
182 #[cfg(not(test))]
183 fn play_wav(cue: &SoundCue) -> io::Result<()> {
184 let mut bundled = None;
185 let path = match cue {
186 SoundCue::Whale => {
187 let mut file = tempfile::Builder::new()
188 .prefix("codewhale-call-")
189 .suffix(".wav")
190 .tempfile()?;
191 file.write_all(WHALE_WAV)?;
192 file.flush()?;
193 let path = file.path().to_path_buf();
194 bundled = Some(file);
195 path
196 }
197 SoundCue::File(path) => std::fs::canonicalize(path)?,
198 _ => return Err(io::Error::other("expected WAV cue")),
199 };
200 // Retain the private temporary file until the synchronous player exits.
201 let result = play_file(&path);
202 drop(bundled);
203 result
204 }
205
206 #[cfg(all(not(test), target_os = "windows"))]
207 fn play_file(path: &std::path::Path) -> io::Result<()> {
208 use std::os::windows::ffi::OsStrExt;
209 use windows::Win32::Media::Audio::{PlaySoundW, SND_FILENAME, SND_NODEFAULT};
210 use windows::core::PCWSTR;
211 let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
212 // Synchronous in the worker: the bundled file must outlive playback.
213 if unsafe { PlaySoundW(PCWSTR(wide.as_ptr()), None, SND_FILENAME | SND_NODEFAULT) }.as_bool() {
214 Ok(())
215 } else {
216 Err(io::Error::other("audio player failed"))
217 }
218 }
219
220 #[cfg(all(not(test), any(target_os = "macos", target_os = "linux")))]
221 fn play_file(path: &std::path::Path) -> io::Result<()> {
222 #[cfg(target_os = "macos")]
223 let player = PathBuf::from("/usr/bin/afplay");
224 #[cfg(target_os = "linux")]
225 let player = trusted_system_executable("aplay")?;
226 let status = std::process::Command::new(player)
227 .arg(path)
228 .stdin(std::process::Stdio::null())
229 .stdout(std::process::Stdio::null())
230 .stderr(std::process::Stdio::null())
231 .status()?;
232 if status.success() {
233 Ok(())
234 } else {
235 Err(io::Error::other("audio player failed"))
236 }
237 }
238
239 #[cfg(all(
240 not(test),
241 not(any(target_os = "windows", target_os = "macos", target_os = "linux"))
242 ))]
243 fn play_file(_path: &std::path::Path) -> io::Result<()> {
244 Err(io::Error::new(
245 io::ErrorKind::Unsupported,
246 "WAV playback unsupported",
247 ))
248 }
249
250 #[cfg(test)]
251 mod tests {
252 use super::*;
253
254 #[test]
255 fn bundled_whale_is_the_complete_pcm_wav_without_clipped_samples() {
256 assert_eq!(&WHALE_WAV[..4], b"RIFF");
257 assert_eq!(&WHALE_WAV[8..12], b"WAVE");
258 assert_eq!(WHALE_WAV.len(), 136754);
259 assert_eq!(u16::from_le_bytes(WHALE_WAV[22..24].try_into().unwrap()), 1);
260 assert_eq!(
261 u32::from_le_bytes(WHALE_WAV[24..28].try_into().unwrap()),
262 44100
263 );
264 assert_eq!(
265 u16::from_le_bytes(WHALE_WAV[34..36].try_into().unwrap()),
266 16
267 );
268 let samples: Vec<i16> = WHALE_WAV[44..]
269 .as_chunks::<2>()
270 .0
271 .iter()
272 .copied()
273 .map(i16::from_le_bytes)
274 .collect();
275 assert_eq!(samples.first(), Some(&0));
276 assert_eq!(samples.last(), Some(&0));
277 assert!(
278 samples
279 .iter()
280 .all(|sample| *sample != i16::MIN && *sample != i16::MAX)
281 );
282 }
283
284 #[test]
285 fn terminal_sink_has_exact_bell_bytes_and_no_wav_fallback() {
286 for (cue, bytes) in [
287 (SoundCue::Bell, &b"\x07"[..]),
288 (SoundCue::Beep, &b"\x07"[..]),
289 (SoundCue::DoubleBell, &b"\x07\x07"[..]),
290 ] {
291 let mut out = Vec::new();
292 assert_eq!(emit_terminal(&cue, &mut out), AudioOutcome::Emitted);
293 assert_eq!(out, bytes);
294 }
295 for cue in [SoundCue::Whale, SoundCue::File("missing.wav".into())] {
296 let mut out = Vec::new();
297 assert_eq!(emit_terminal(&cue, &mut out), AudioOutcome::Unsupported);
298 assert!(out.is_empty());
299 }
300 }
301
302 #[test]
303 fn trusted_player_needs_an_executable_file_under_an_absolute_prefix() {
304 assert!(trusted_player_dirs().iter().all(|dir| dir.is_absolute()));
305 let dir = tempfile::tempdir().unwrap();
306 let name = "codewhale-test-player";
307 let file = dir.path().join(if cfg!(windows) {
308 format!("{name}.exe")
309 } else {
310 name.to_string()
311 });
312 let prefixes = [dir.path().to_path_buf()];
313 assert!(trusted_player_in(name, &prefixes).is_err(), "absent");
314 std::fs::write(&file, b"").unwrap();
315 #[cfg(unix)]
316 {
317 use std::os::unix::fs::PermissionsExt as _;
318 assert!(
319 trusted_player_in(name, &prefixes).is_err(),
320 "a non-executable file is not a player"
321 );
322 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
323 }
324 assert_eq!(trusted_player_in(name, &prefixes).unwrap(), file);
325 }
326
327 #[test]
328 fn production_audio_entry_is_silent_in_library_tests() {
329 let mut out = Vec::new();
330 assert_eq!(
331 dispatch(&SoundCue::Whale, &mut out),
332 AudioOutcome::Unsupported
333 );
334 assert_eq!(
335 dispatch(&SoundCue::Bell, &mut out),
336 AudioOutcome::Unsupported
337 );
338 assert!(out.is_empty());
339 }
340 }
341
341 lines RUST