Cross-platform push notifications (#33)

* Cross-platform push notifications

* Bundle Sound assets

* Fix browser opening in WSL 2
This commit is contained in:
Solomon
2025-07-01 15:34:21 +01:00
committed by GitHub
parent c9fada8979
commit db110eca29
7 changed files with 272 additions and 113 deletions

View File

@@ -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"] }

View 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)

View File

@@ -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,40 +140,30 @@ 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)
.arg(&file_path)
.spawn();
}
} else if cfg!(target_os = "linux") && !is_wsl2() {
// Try different Linux notification sounds
if absolute_path.exists() {
} else if cfg!(target_os = "linux") && !crate::utils::is_wsl2() {
// Try different Linux audio players
if tokio::process::Command::new("paplay")
.arg(&absolute_path)
.arg(&file_path)
.spawn()
.is_ok()
{
// Success with paplay
} else if tokio::process::Command::new("aplay")
.arg(&absolute_path)
.arg(&file_path)
.spawn()
.is_ok()
{
@@ -209,25 +175,17 @@ async fn play_sound_notification(sound_file: &crate::models::config::SoundFile)
.arg("\\a")
.spawn();
}
} else {
// Try system bell as fallback if sound file doesn't exist
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() {
} 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 is_wsl2() {
if let Some(windows_path) = wsl_to_windows_path(&absolute_path).await {
let file_path = if crate::utils::is_wsl2() {
if let Some(windows_path) = wsl_to_windows_path(&file_path).await {
windows_path
} else {
// Fallback to original path if conversion fails
absolute_path.to_string_lossy().to_string()
file_path.to_string_lossy().to_string()
}
} else {
absolute_path.to_string_lossy().to_string()
file_path.to_string_lossy().to_string()
};
let _ = tokio::process::Command::new("powershell.exe")
@@ -237,17 +195,10 @@ async fn play_sound_notification(sound_file: &crate::models::config::SoundFile)
file_path
))
.spawn();
} 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();
}
}
}
/// 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();
}
}

View File

@@ -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;

View File

@@ -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?;

View File

@@ -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)
}
}

View File

@@ -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())
}
}