Cross-platform push notifications (#33)
* Cross-platform push notifications * Bundle Sound assets * Fix browser opening in WSL 2
This commit is contained in:
@@ -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"] }
|
||||
|
||||
23
backend/scripts/toast-notification.ps1
Normal file
23
backend/scripts/toast-notification.ps1
Normal file
@@ -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)
|
||||
@@ -72,33 +72,9 @@ async fn commit_execution_changes(
|
||||
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() {
|
||||
@@ -164,90 +140,65 @@ async fn wsl_to_windows_path(wsl_path: &std::path::Path) -> Option<String> {
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<serde_json::Value>,
|
||||
) -> ResponseJson<ApiResponse<serde_json::Value>> {
|
||||
@@ -87,10 +83,6 @@ async fn serve_file(path: &str) -> impl IntoResponse {
|
||||
async fn serve_sound_file(
|
||||
axum::extract::Path(filename): axum::extract::Path<String>,
|
||||
) -> 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?;
|
||||
|
||||
@@ -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<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<bool> = 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<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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<dyn std::error::Error + Send + Sync>> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user