diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 621f48e6..c88e94d1 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -40,6 +40,7 @@ openssl-sys = { workspace = true } rmcp = { version = "0.1.5", features = ["server", "transport-io"] } schemars = "0.8" regex = "1.11.1" +notify-rust = "4.11" [build-dependencies] ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl"] } diff --git a/backend/scripts/toast-notification.ps1 b/backend/scripts/toast-notification.ps1 new file mode 100644 index 00000000..9719c89e --- /dev/null +++ b/backend/scripts/toast-notification.ps1 @@ -0,0 +1,23 @@ +param( + [Parameter(Mandatory=$true)] + [string]$Title, + + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter(Mandatory=$false)] + [string]$AppName = "Vibe Kanban" +) + +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) +$RawXml = [xml] $Template.GetXml() +($RawXml.toast.visual.binding.text|where {$_.id -eq "1"}).AppendChild($RawXml.CreateTextNode($Title)) | Out-Null +($RawXml.toast.visual.binding.text|where {$_.id -eq "2"}).AppendChild($RawXml.CreateTextNode($Message)) | Out-Null +$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument +$SerializedXml.LoadXml($RawXml.OuterXml) +$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) +$Toast.Tag = $AppName +$Toast.Group = $AppName +$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AppName) +$Notifier.Show($Toast) diff --git a/backend/src/execution_monitor.rs b/backend/src/execution_monitor.rs index ca14d906..eab5cdfc 100644 --- a/backend/src/execution_monitor.rs +++ b/backend/src/execution_monitor.rs @@ -72,33 +72,9 @@ async fn commit_execution_changes( Ok(()) } -/// Cache for WSL2 detection result -static WSL2_CACHE: OnceLock = OnceLock::new(); /// Cache for WSL root path from PowerShell static WSL_ROOT_PATH_CACHE: OnceLock> = 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 { if let Some(cached) = WSL_ROOT_PATH_CACHE.get() { @@ -164,90 +140,65 @@ async fn wsl_to_windows_path(wsl_path: &std::path::Path) -> Option { /// Play a system sound notification 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() - ); - } + let file_path = match sound_file.get_path().await { + Ok(path) => path, + Err(e) => { + tracing::error!("Failed to create cached sound file: {}", e); + return; + } + }; // Use platform-specific sound notification // Note: spawn() calls are intentionally not awaited - sound notifications should be fire-and-forget if cfg!(target_os = "macos") { - if absolute_path.exists() { - let _ = tokio::process::Command::new("afplay") - .arg(&absolute_path) - .spawn(); - } - } else if cfg!(target_os = "linux") && !is_wsl2() { - // Try different Linux notification sounds - if absolute_path.exists() { - if tokio::process::Command::new("paplay") - .arg(&absolute_path) - .spawn() - .is_ok() - { - // Success with paplay - } else if tokio::process::Command::new("aplay") - .arg(&absolute_path) - .spawn() - .is_ok() - { - // Success with aplay - } else { - // Try system bell as fallback - let _ = tokio::process::Command::new("echo") - .arg("-e") - .arg("\\a") - .spawn(); - } + let _ = tokio::process::Command::new("afplay") + .arg(&file_path) + .spawn(); + } else if cfg!(target_os = "linux") && !crate::utils::is_wsl2() { + // Try different Linux audio players + if tokio::process::Command::new("paplay") + .arg(&file_path) + .spawn() + .is_ok() + { + // Success with paplay + } else if tokio::process::Command::new("aplay") + .arg(&file_path) + .spawn() + .is_ok() + { + // Success with aplay } else { - // Try system bell as fallback if sound file doesn't exist + // Try system bell as fallback let _ = tokio::process::Command::new("echo") .arg("-e") .arg("\\a") .spawn(); } - } else if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && is_wsl2()) { - if absolute_path.exists() { - // Convert WSL path to Windows path if in WSL2 - let file_path = if is_wsl2() { - if let Some(windows_path) = wsl_to_windows_path(&absolute_path).await { - windows_path - } else { - // Fallback to original path if conversion fails - absolute_path.to_string_lossy().to_string() - } + } else if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && crate::utils::is_wsl2()) + { + // Convert WSL path to Windows path if in WSL2 + let file_path = if crate::utils::is_wsl2() { + if let Some(windows_path) = wsl_to_windows_path(&file_path).await { + windows_path } 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(); + file_path.to_string_lossy().to_string() + } } else { - // Fallback to system beep if sound file doesn't exist - let _ = tokio::process::Command::new("powershell.exe") - .arg("-c") - .arg("[System.Media.SystemSounds]::Beep.Play()") - .spawn(); - } + file_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(); } } -/// Send a macOS push notification +/// Send a cross-platform push notification async fn send_push_notification(title: &str, message: &str) { if cfg!(target_os = "macos") { let script = format!( @@ -260,6 +211,57 @@ async fn send_push_notification(title: &str, message: &str) { .arg("-e") .arg(script) .spawn(); + } else if cfg!(target_os = "linux") && !crate::utils::is_wsl2() { + // Linux: Use notify-rust crate - fire and forget + use notify_rust::Notification; + + let title = title.to_string(); + let message = message.to_string(); + + let _handle = tokio::task::spawn_blocking(move || { + if let Err(e) = Notification::new() + .summary(&title) + .body(&message) + .timeout(10000) + .show() + { + tracing::error!("Failed to send Linux notification: {}", e); + } + }); + drop(_handle); // Don't await, fire-and-forget + } else if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && crate::utils::is_wsl2()) + { + // Windows and WSL2: Use PowerShell toast notification script + let script_path = match crate::utils::get_powershell_script().await { + Ok(path) => path, + Err(e) => { + tracing::error!("Failed to get PowerShell script: {}", e); + return; + } + }; + + // Convert WSL path to Windows path if in WSL2 + let script_path_str = if crate::utils::is_wsl2() { + if let Some(windows_path) = wsl_to_windows_path(&script_path).await { + windows_path + } else { + script_path.to_string_lossy().to_string() + } + } else { + script_path.to_string_lossy().to_string() + }; + + let _ = tokio::process::Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(script_path_str) + .arg("-Title") + .arg(title) + .arg("-Message") + .arg(message) + .spawn(); } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 23860c8a..a9856309 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,3 +1,5 @@ +use rust_embed::RustEmbed; + pub mod app_state; pub mod execution_monitor; pub mod executor; @@ -6,3 +8,15 @@ pub mod mcp; pub mod models; pub mod routes; pub mod utils; + +#[derive(RustEmbed)] +#[folder = "../frontend/dist"] +pub struct Assets; + +#[derive(RustEmbed)] +#[folder = "sounds"] +pub struct SoundAssets; + +#[derive(RustEmbed)] +#[folder = "scripts"] +pub struct ScriptAssets; diff --git a/backend/src/main.rs b/backend/src/main.rs index a3777f25..4dc6dd45 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,10 +8,10 @@ use axum::{ routing::{get, post}, Json, Router, }; -use rust_embed::RustEmbed; use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; use tokio::sync::RwLock; use tower_http::cors::CorsLayer; +use vibe_kanban::{Assets, ScriptAssets, SoundAssets}; mod app_state; mod execution_monitor; @@ -27,10 +27,6 @@ use execution_monitor::execution_monitor; use models::{ApiResponse, Config}; use routes::{config, filesystem, health, projects, task_attempts, tasks}; -#[derive(RustEmbed)] -#[folder = "../frontend/dist"] -struct Assets; - async fn echo_handler( Json(payload): Json, ) -> ResponseJson> { @@ -87,10 +83,6 @@ async fn serve_file(path: &str) -> impl IntoResponse { async fn serve_sound_file( axum::extract::Path(filename): axum::extract::Path, ) -> impl IntoResponse { - use std::path::Path; - - use tokio::fs; - // Validate filename contains only expected sound files let valid_sounds = [ "abstract-sound1.wav", @@ -109,15 +101,13 @@ async fn serve_sound_file( .unwrap(); } - let sound_path = Path::new("backend/sounds").join(&filename); - - match fs::read(&sound_path).await { - Ok(content) => Response::builder() + match SoundAssets::get(&filename) { + Some(content) => Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, HeaderValue::from_static("audio/mpeg")) - .body(Body::from(content)) + .header(header::CONTENT_TYPE, HeaderValue::from_static("audio/wav")) + .body(Body::from(content.data.into_owned())) .unwrap(), - Err(_) => Response::builder() + None => Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::from("Sound file not found")) .unwrap(), @@ -199,7 +189,9 @@ async fn main() -> anyhow::Result<()> { if !cfg!(debug_assertions) { tracing::info!("Opening browser..."); - open::that(format!("http://127.0.0.1:{actual_port}"))?; + if let Err(e) = utils::open_browser(&format!("http://127.0.0.1:{actual_port}")).await { + tracing::warn!("Failed to open browser automatically: {}. Please open http://127.0.0.1:{} manually.", e, actual_port); + } } axum::serve(listener, app).await?; diff --git a/backend/src/models/config.rs b/backend/src/models/config.rs index fe731e19..e3076983 100644 --- a/backend/src/models/config.rs +++ b/backend/src/models/config.rs @@ -195,8 +195,42 @@ impl SoundFile { } } - pub fn to_path(&self) -> PathBuf { - PathBuf::from("backend/sounds").join(self.to_filename()) + /// Get or create a cached sound file with the embedded sound data + pub async fn get_path(&self) -> Result> { + use std::io::Write; + + let filename = self.to_filename(); + let cache_dir = crate::utils::cache_dir(); + let cached_path = cache_dir.join(format!("sound-{}", filename)); + + // Check if cached file already exists and is valid + if cached_path.exists() { + // Verify file has content (basic validation) + if let Ok(metadata) = std::fs::metadata(&cached_path) { + if metadata.len() > 0 { + return Ok(cached_path); + } + } + } + + // File doesn't exist or is invalid, create it + let sound_data = crate::SoundAssets::get(filename) + .ok_or_else(|| format!("Embedded sound file not found: {}", filename))? + .data; + + // Ensure cache directory exists + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + let mut file = std::fs::File::create(&cached_path) + .map_err(|e| format!("Failed to create cached sound file: {}", e))?; + + file.write_all(&sound_data) + .map_err(|e| format!("Failed to write sound data to cached file: {}", e))?; + + drop(file); // Ensure file is closed + + Ok(cached_path) } } diff --git a/backend/src/utils.rs b/backend/src/utils.rs index f102df99..dc3116be 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -1,10 +1,35 @@ -use std::env; +use std::{env, sync::OnceLock}; use directories::ProjectDirs; pub mod shell; pub mod text; +/// Cache for WSL2 detection result +static WSL2_CACHE: OnceLock = OnceLock::new(); + +/// Check if running in WSL2 (cached) +pub 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 + }) +} + pub fn asset_dir() -> std::path::PathBuf { let proj = if cfg!(debug_assertions) { ProjectDirs::from("ai", "bloop-dev", env!("CARGO_PKG_NAME")) @@ -23,3 +48,71 @@ pub fn asset_dir() -> std::path::PathBuf { pub fn config_path() -> std::path::PathBuf { asset_dir().join("config.json") } + +pub fn cache_dir() -> std::path::PathBuf { + let proj = if cfg!(debug_assertions) { + ProjectDirs::from("ai", "bloop-dev", env!("CARGO_PKG_NAME")) + .expect("OS didn't give us a home directory") + } else { + ProjectDirs::from("ai", "bloop", env!("CARGO_PKG_NAME")) + .expect("OS didn't give us a home directory") + }; + + // ✔ macOS → ~/Library/Caches/MyApp + // ✔ Linux → ~/.cache/myapp (respects XDG_CACHE_HOME) + // ✔ Windows → %LOCALAPPDATA%\Example\MyApp + proj.cache_dir().to_path_buf() +} + +/// Get or create cached PowerShell script file +pub async fn get_powershell_script( +) -> Result> { + use std::io::Write; + + let cache_dir = cache_dir(); + let script_path = cache_dir.join("toast-notification.ps1"); + + // Check if cached file already exists and is valid + if script_path.exists() { + // Verify file has content (basic validation) + if let Ok(metadata) = std::fs::metadata(&script_path) { + if metadata.len() > 0 { + return Ok(script_path); + } + } + } + + // File doesn't exist or is invalid, create it + let script_content = crate::ScriptAssets::get("toast-notification.ps1") + .ok_or("Embedded PowerShell script not found: toast-notification.ps1")? + .data; + + // Ensure cache directory exists + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + let mut file = std::fs::File::create(&script_path) + .map_err(|e| format!("Failed to create PowerShell script file: {}", e))?; + + file.write_all(&script_content) + .map_err(|e| format!("Failed to write PowerShell script data: {}", e))?; + + drop(file); // Ensure file is closed + + Ok(script_path) +} + +/// Open URL in browser with WSL2 support +pub async fn open_browser(url: &str) -> Result<(), Box> { + if is_wsl2() { + // In WSL2, use PowerShell to open the browser + tokio::process::Command::new("powershell.exe") + .arg("-Command") + .arg(format!("Start-Process '{}'", url)) + .spawn()?; + Ok(()) + } else { + // Use the standard open crate for other platforms + open::that(url).map_err(|e| e.into()) + } +}