Cross-platform sound support (#23)
* Cross-platform sound support WAV files work on linux, macos, and windows with the builtin commands. Particularly `aplay` in Linux, which is the only preinstalled command in Ubuntu, only works with .wav files. * Make sound notification work in WSL2
This commit is contained in:
Binary file not shown.
BIN
backend/sounds/abstract-sound1.wav
Normal file
BIN
backend/sounds/abstract-sound1.wav
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/sounds/abstract-sound2.wav
Normal file
BIN
backend/sounds/abstract-sound2.wav
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/sounds/abstract-sound3.wav
Normal file
BIN
backend/sounds/abstract-sound3.wav
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/sounds/abstract-sound4.wav
Normal file
BIN
backend/sounds/abstract-sound4.wav
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/sounds/cow-mooing.wav
Normal file
BIN
backend/sounds/cow-mooing.wav
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/sounds/phone-vibration.wav
Normal file
BIN
backend/sounds/phone-vibration.wav
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/sounds/rooster.wav
Normal file
BIN
backend/sounds/rooster.wav
Normal file
Binary file not shown.
@@ -1,3 +1,5 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use git2::Repository;
|
use git2::Repository;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -70,54 +72,174 @@ async fn commit_execution_changes(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cache for WSL2 detection result
|
||||||
|
static WSL2_CACHE: OnceLock<bool> = OnceLock::new();
|
||||||
|
/// Cache for WSL root path from PowerShell
|
||||||
|
static WSL_ROOT_PATH_CACHE: OnceLock<Option<String>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Check if running in WSL2 (cached)
|
||||||
|
fn is_wsl2() -> bool {
|
||||||
|
*WSL2_CACHE.get_or_init(|| {
|
||||||
|
// Check for WSL environment variables
|
||||||
|
if std::env::var("WSL_DISTRO_NAME").is_ok() || std::env::var("WSLENV").is_ok() {
|
||||||
|
tracing::debug!("WSL2 detected via environment variables");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check /proc/version for WSL2 signature
|
||||||
|
if let Ok(version) = std::fs::read_to_string("/proc/version") {
|
||||||
|
if version.contains("WSL2") || version.contains("microsoft") {
|
||||||
|
tracing::debug!("WSL2 detected via /proc/version");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("WSL2 not detected");
|
||||||
|
false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get WSL root path via PowerShell (cached)
|
||||||
|
async fn get_wsl_root_path() -> Option<String> {
|
||||||
|
if let Some(cached) = WSL_ROOT_PATH_CACHE.get() {
|
||||||
|
return cached.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::process::Command::new("powershell.exe")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("(Get-Location).Path -replace '^.*::', ''")
|
||||||
|
.current_dir("/")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(output) => {
|
||||||
|
match String::from_utf8(output.stdout) {
|
||||||
|
Ok(pwd_str) => {
|
||||||
|
let pwd = pwd_str.trim();
|
||||||
|
tracing::info!("WSL root path detected: {}", pwd);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
let _ = WSL_ROOT_PATH_CACHE.set(Some(pwd.to_string()));
|
||||||
|
return Some(pwd.to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to parse PowerShell pwd output as UTF-8: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to execute PowerShell pwd command: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the failure result
|
||||||
|
let _ = WSL_ROOT_PATH_CACHE.set(None);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert WSL path to Windows UNC path for PowerShell
|
||||||
|
async fn wsl_to_windows_path(wsl_path: &std::path::Path) -> Option<String> {
|
||||||
|
let path_str = wsl_path.to_string_lossy();
|
||||||
|
|
||||||
|
// Relative paths work fine as-is in PowerShell
|
||||||
|
if !path_str.starts_with('/') {
|
||||||
|
tracing::debug!("Using relative path as-is: {}", path_str);
|
||||||
|
return Some(path_str.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cached WSL root path from PowerShell
|
||||||
|
if let Some(wsl_root) = get_wsl_root_path().await {
|
||||||
|
// Simply concatenate WSL root with the absolute path - PowerShell doesn't mind /
|
||||||
|
let windows_path = format!("{}{}", wsl_root, path_str);
|
||||||
|
tracing::debug!("WSL path converted: {} -> {}", path_str, windows_path);
|
||||||
|
Some(windows_path)
|
||||||
|
} else {
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to determine WSL root path for conversion: {}",
|
||||||
|
path_str
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Play a system sound notification
|
/// Play a system sound notification
|
||||||
async fn play_sound_notification(sound_file: &crate::models::config::SoundFile) {
|
async fn play_sound_notification(sound_file: &crate::models::config::SoundFile) {
|
||||||
|
let sound_path = sound_file.to_path();
|
||||||
|
let current_dir = std::env::current_dir().unwrap_or_else(|e| {
|
||||||
|
tracing::error!("Failed to get current directory: {}", e);
|
||||||
|
std::path::PathBuf::from(".")
|
||||||
|
});
|
||||||
|
let absolute_path = current_dir.join(&sound_path);
|
||||||
|
|
||||||
|
if !absolute_path.exists() {
|
||||||
|
tracing::error!(
|
||||||
|
"Sound file not found: {} (resolved from {})",
|
||||||
|
absolute_path.display(),
|
||||||
|
sound_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Use platform-specific sound notification
|
// Use platform-specific sound notification
|
||||||
// Note: spawn() calls are intentionally not awaited - sound notifications should be fire-and-forget
|
// Note: spawn() calls are intentionally not awaited - sound notifications should be fire-and-forget
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
let sound_path = sound_file.to_path();
|
if absolute_path.exists() {
|
||||||
let _ = tokio::process::Command::new("afplay")
|
let _ = tokio::process::Command::new("afplay")
|
||||||
.arg(sound_path)
|
.arg(&absolute_path)
|
||||||
.spawn();
|
.spawn();
|
||||||
} else if cfg!(target_os = "linux") {
|
}
|
||||||
|
} else if cfg!(target_os = "linux") && !is_wsl2() {
|
||||||
// Try different Linux notification sounds
|
// Try different Linux notification sounds
|
||||||
let sound_path = sound_file.to_path();
|
if absolute_path.exists() {
|
||||||
if tokio::process::Command::new("paplay")
|
if tokio::process::Command::new("paplay")
|
||||||
.arg(&sound_path)
|
.arg(&absolute_path)
|
||||||
.spawn()
|
.spawn()
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
// Success with paplay
|
// Success with paplay
|
||||||
} else if tokio::process::Command::new("aplay")
|
} else if tokio::process::Command::new("aplay")
|
||||||
.arg(&sound_path)
|
.arg(&absolute_path)
|
||||||
.spawn()
|
.spawn()
|
||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
// Success with aplay
|
// Success with aplay
|
||||||
|
} else {
|
||||||
|
// Try system bell as fallback
|
||||||
|
let _ = tokio::process::Command::new("echo")
|
||||||
|
.arg("-e")
|
||||||
|
.arg("\\a")
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Try system bell as fallback
|
// Try system bell as fallback if sound file doesn't exist
|
||||||
let _ = tokio::process::Command::new("echo")
|
let _ = tokio::process::Command::new("echo")
|
||||||
.arg("-e")
|
.arg("-e")
|
||||||
.arg("\\a")
|
.arg("\\a")
|
||||||
.spawn();
|
.spawn();
|
||||||
}
|
}
|
||||||
} else if cfg!(target_os = "windows") {
|
} else if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && is_wsl2()) {
|
||||||
let sound_path = sound_file.to_path();
|
|
||||||
let current_dir = std::env::current_dir().unwrap_or_else(|e| {
|
|
||||||
tracing::error!("Failed to get current directory: {}", e);
|
|
||||||
std::path::PathBuf::from(".")
|
|
||||||
});
|
|
||||||
let absolute_path = current_dir.join(&sound_path);
|
|
||||||
|
|
||||||
if absolute_path.exists() {
|
if absolute_path.exists() {
|
||||||
let _ = tokio::process::Command::new("powershell")
|
// Convert WSL path to Windows path if in WSL2
|
||||||
.arg("-Command")
|
let file_path = if is_wsl2() {
|
||||||
.arg("(New-Object Media.SoundPlayer $args[0]).PlaySync()")
|
if let Some(windows_path) = wsl_to_windows_path(&absolute_path).await {
|
||||||
.arg(absolute_path.to_string_lossy().as_ref())
|
windows_path
|
||||||
|
} else {
|
||||||
|
// Fallback to original path if conversion fails
|
||||||
|
absolute_path.to_string_lossy().to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
absolute_path.to_string_lossy().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("powershell.exe")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(format!(
|
||||||
|
r#"(New-Object Media.SoundPlayer "{}").PlaySync()"#,
|
||||||
|
file_path
|
||||||
|
))
|
||||||
.spawn();
|
.spawn();
|
||||||
} else {
|
} else {
|
||||||
// Fallback to system beep if sound file doesn't exist
|
// Fallback to system beep if sound file doesn't exist
|
||||||
let _ = tokio::process::Command::new("powershell")
|
let _ = tokio::process::Command::new("powershell.exe")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg("[System.Media.SystemSounds]::Beep.Play()")
|
.arg("[System.Media.SystemSounds]::Beep.Play()")
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|||||||
@@ -93,13 +93,13 @@ async fn serve_sound_file(
|
|||||||
|
|
||||||
// Validate filename contains only expected sound files
|
// Validate filename contains only expected sound files
|
||||||
let valid_sounds = [
|
let valid_sounds = [
|
||||||
"abstract-sound1.mp3",
|
"abstract-sound1.wav",
|
||||||
"abstract-sound2.mp3",
|
"abstract-sound2.wav",
|
||||||
"abstract-sound3.mp3",
|
"abstract-sound3.wav",
|
||||||
"abstract-sound4.mp3",
|
"abstract-sound4.wav",
|
||||||
"cow-mooing.mp3",
|
"cow-mooing.wav",
|
||||||
"phone-vibration.mp3",
|
"phone-vibration.wav",
|
||||||
"rooster.mp3",
|
"rooster.wav",
|
||||||
];
|
];
|
||||||
|
|
||||||
if !valid_sounds.contains(&filename.as_str()) {
|
if !valid_sounds.contains(&filename.as_str()) {
|
||||||
|
|||||||
@@ -185,13 +185,13 @@ impl EditorConfig {
|
|||||||
impl SoundFile {
|
impl SoundFile {
|
||||||
pub fn to_filename(&self) -> &'static str {
|
pub fn to_filename(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
SoundFile::AbstractSound1 => "abstract-sound1.mp3",
|
SoundFile::AbstractSound1 => "abstract-sound1.wav",
|
||||||
SoundFile::AbstractSound2 => "abstract-sound2.mp3",
|
SoundFile::AbstractSound2 => "abstract-sound2.wav",
|
||||||
SoundFile::AbstractSound3 => "abstract-sound3.mp3",
|
SoundFile::AbstractSound3 => "abstract-sound3.wav",
|
||||||
SoundFile::AbstractSound4 => "abstract-sound4.mp3",
|
SoundFile::AbstractSound4 => "abstract-sound4.wav",
|
||||||
SoundFile::CowMooing => "cow-mooing.mp3",
|
SoundFile::CowMooing => "cow-mooing.wav",
|
||||||
SoundFile::PhoneVibration => "phone-vibration.mp3",
|
SoundFile::PhoneVibration => "phone-vibration.wav",
|
||||||
SoundFile::Rooster => "rooster.mp3",
|
SoundFile::Rooster => "rooster.wav",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function Settings() {
|
|||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
const playSound = async (soundFile: SoundFile) => {
|
const playSound = async (soundFile: SoundFile) => {
|
||||||
const audio = new Audio(`/api/sounds/${soundFile}.mp3`);
|
const audio = new Audio(`/api/sounds/${soundFile}.wav`);
|
||||||
try {
|
try {
|
||||||
await audio.play();
|
await audio.play();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user