// Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod audio_capture; mod audio_output; use std::sync::Mutex; use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent}; use tauri_plugin_shell::ShellExt; use tokio::sync::mpsc; const LEGACY_PORT: u16 = 8000; const SERVER_PORT: u16 = 17493; struct ServerState { child: Mutex>, server_pid: Mutex>, keep_running_on_close: Mutex, models_dir: Mutex>, } #[command] async fn start_server( app: tauri::AppHandle, state: State<'_, ServerState>, remote: Option, models_dir: Option, ) -> Result { // Store models_dir for use on restart (empty string means reset to default) if let Some(ref dir) = models_dir { if dir.is_empty() { *state.models_dir.lock().unwrap() = None; } else { *state.models_dir.lock().unwrap() = Some(dir.clone()); } } // Check if server is already running (managed by this app instance) if state.child.lock().unwrap().is_some() { return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } // Check if a voicebox server is already running on our port (from previous session with keep_running=true) #[cfg(unix)] { use std::process::Command; if let Ok(output) = Command::new("lsof") .args(["-i", &format!(":{}", SERVER_PORT), "-sTCP:LISTEN"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines().skip(1) { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 2 { let command = parts[0]; let pid_str = parts[1]; if command.contains("voicebox") { if let Ok(pid) = pid_str.parse::() { println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid); // Store the PID so we can kill it on exit if needed *state.server_pid.lock().unwrap() = Some(pid); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } } } } } } #[cfg(windows)] { use std::process::Command; if let Ok(output) = Command::new("netstat") .args(["-ano"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.contains(&format!(":{}", SERVER_PORT)) && line.contains("LISTENING") { if let Some(pid_str) = line.split_whitespace().last() { if let Ok(pid) = pid_str.parse::() { if let Ok(tasklist_output) = Command::new("tasklist") .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) .output() { let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout); if tasklist_str.to_lowercase().contains("voicebox") { println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid); // Store the PID so we can kill it on exit if needed *state.server_pid.lock().unwrap() = Some(pid); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } } } } } } } } // Kill any orphaned voicebox-server from previous session on legacy port 8000 // This handles upgrades from older versions that used a fixed port #[cfg(unix)] { use std::process::Command; // Find processes listening on legacy port 8000 with their command names if let Ok(output) = Command::new("lsof") .args(["-i", &format!(":{}", LEGACY_PORT), "-sTCP:LISTEN"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines().skip(1) { // Skip header line // lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 2 { let command = parts[0]; let pid_str = parts[1]; // Only kill if it's a voicebox-server process if command.contains("voicebox") { if let Ok(pid) = pid_str.parse::() { println!("Found orphaned voicebox-server on legacy port {} (PID: {}, CMD: {}), killing it...", LEGACY_PORT, pid, command); // Kill the process group let _ = Command::new("kill") .args(["-9", "--", &format!("-{}", pid)]) .output(); let _ = Command::new("kill") .args(["-9", &pid.to_string()]) .output(); } } else { println!("Legacy port {} is in use by non-voicebox process: {} (PID: {}), not killing", LEGACY_PORT, command, pid_str); } } } } } #[cfg(windows)] { use std::process::Command; // On Windows, find PIDs on legacy port 8000, then check their names if let Ok(output) = Command::new("netstat") .args(["-ano"]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if line.contains(&format!(":{}", LEGACY_PORT)) && line.contains("LISTENING") { if let Some(pid_str) = line.split_whitespace().last() { if let Ok(pid) = pid_str.parse::() { // Get process name for this PID if let Ok(tasklist_output) = Command::new("tasklist") .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) .output() { let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout); if tasklist_str.to_lowercase().contains("voicebox") { println!("Found orphaned voicebox-server on legacy port {} (PID: {}), killing it...", LEGACY_PORT, pid); let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .output(); } else { println!("Legacy port {} is in use by non-voicebox process (PID: {}), not killing", LEGACY_PORT, pid); } } } } } } } } // Brief wait for port to be released std::thread::sleep(std::time::Duration::from_millis(200)); // Get app data directory let data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to get app data dir: {}", e))?; // Ensure data directory exists std::fs::create_dir_all(&data_dir) .map_err(|e| format!("Failed to create data dir: {}", e))?; println!("================================================================="); println!("Starting voicebox-server sidecar"); println!("Data directory: {:?}", data_dir); println!("Remote mode: {}", remote.unwrap_or(false)); // Check for CUDA backend binary in data directory let cuda_binary = { let backends_dir = data_dir.join("backends"); let cuda_name = if cfg!(windows) { "voicebox-server-cuda.exe" } else { "voicebox-server-cuda" }; let path = backends_dir.join(cuda_name); if path.exists() { println!("Found CUDA backend binary at {:?}", path); // Version check: run --version and compare to app version let app_version = app.config().version.clone().unwrap_or_default(); let version_ok = match std::process::Command::new(&path) .arg("--version") .output() { Ok(output) => { // Output format: "voicebox-server X.Y.Z\n" let version_str = String::from_utf8_lossy(&output.stdout); let binary_version = version_str.trim().split_whitespace().last().unwrap_or(""); if binary_version == app_version { println!("CUDA binary version {} matches app version", binary_version); true } else { println!( "CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.", binary_version, app_version ); false } } Err(e) => { println!("Failed to check CUDA binary version: {}. Falling back to CPU.", e); false } }; if version_ok { Some(path) } else { None } } else { println!("No CUDA backend found, using bundled CPU binary"); None } }; let sidecar_result = app.shell().sidecar("voicebox-server"); let mut sidecar = match sidecar_result { Ok(s) => s, Err(e) => { eprintln!("Failed to get sidecar: {}", e); // In dev mode, check if the server is already running (started manually) #[cfg(debug_assertions)] { eprintln!("Dev mode: Checking if server is already running on port {}...", SERVER_PORT); // Try to connect to the server port use std::net::TcpStream; if TcpStream::connect_timeout( &format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(), std::time::Duration::from_secs(1), ).is_ok() { println!("Found server already running on port {}", SERVER_PORT); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } eprintln!(""); eprintln!("================================================================="); eprintln!("DEV MODE: No server found on port {}", SERVER_PORT); eprintln!(""); eprintln!("Start the Python server in a separate terminal:"); eprintln!(" bun run dev:server"); eprintln!("================================================================="); eprintln!(""); } return Err(format!("Failed to start server. In dev mode, run 'bun run dev:server' in a separate terminal.")); } }; println!("Sidecar command created successfully"); // Build common args let data_dir_str = data_dir .to_str() .ok_or_else(|| "Invalid data dir path".to_string())? .to_string(); let port_str = SERVER_PORT.to_string(); let parent_pid_str = std::process::id().to_string(); let is_remote = remote.unwrap_or(false); // Resolve the custom models directory from the parameter or stored state let effective_models_dir = models_dir.or_else(|| state.models_dir.lock().unwrap().clone()); if let Some(ref dir) = effective_models_dir { println!("Custom models directory: {}", dir); } // If CUDA binary exists, launch it directly instead of the bundled sidecar let spawn_result = if let Some(ref cuda_path) = cuda_binary { println!("Launching CUDA backend: {:?}", cuda_path); let mut cmd = app.shell().command(cuda_path.to_str().unwrap()); cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]); if is_remote { cmd = cmd.args(["--host", "0.0.0.0"]); } if let Some(ref dir) = effective_models_dir { cmd = cmd.env("VOICEBOX_MODELS_DIR", dir); } cmd.spawn() } else { // Use the bundled CPU sidecar sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]); if is_remote { sidecar = sidecar.args(["--host", "0.0.0.0"]); } if let Some(ref dir) = effective_models_dir { sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir); } println!("Spawning server process..."); sidecar.spawn() }; let (mut rx, child) = match spawn_result { Ok(result) => result, Err(e) => { eprintln!("Failed to spawn server process: {}", e); // In dev mode, check if a manually-started server is available #[cfg(debug_assertions)] { use std::net::TcpStream; if TcpStream::connect_timeout( &format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(), std::time::Duration::from_secs(1), ).is_ok() { println!("Found manually-started server on port {}", SERVER_PORT); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } eprintln!(""); eprintln!("================================================================="); eprintln!("DEV MODE: Server binary failed to start"); eprintln!(""); eprintln!("Start the Python server in a separate terminal:"); eprintln!(" bun run dev:server"); eprintln!("================================================================="); eprintln!(""); return Err("Dev mode: Start server manually with 'bun run dev:server'".to_string()); } #[cfg(not(debug_assertions))] { eprintln!("This could be due to:"); eprintln!(" - Missing or corrupted binary"); eprintln!(" - Missing execute permissions"); eprintln!(" - Code signing issues on macOS"); eprintln!(" - Missing dependencies"); return Err(format!("Failed to spawn: {}", e)); } } }; println!("Server process spawned, waiting for ready signal..."); println!("================================================================="); // Store child process and PID let process_pid = child.pid(); *state.server_pid.lock().unwrap() = Some(process_pid); *state.child.lock().unwrap() = Some(child); // Wait for server to be ready by listening for startup log // PyInstaller bundles can be slow on first import, especially torch/transformers let timeout = tokio::time::Duration::from_secs(120); let start_time = tokio::time::Instant::now(); let mut error_output = Vec::new(); loop { if start_time.elapsed() > timeout { eprintln!("Server startup timeout after 120 seconds"); if !error_output.is_empty() { eprintln!("Collected error output:"); for line in &error_output { eprintln!(" {}", line); } } // In dev mode, check if a manual server came up during the wait #[cfg(debug_assertions)] { use std::net::TcpStream; if TcpStream::connect_timeout( &format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(), std::time::Duration::from_secs(1), ).is_ok() { // Kill the placeholder process let _ = state.child.lock().unwrap().take(); println!("Found manually-started server on port {}", SERVER_PORT); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } } return Err("Server startup timeout - check Console.app for detailed logs".to_string()); } match tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv()).await { Ok(Some(event)) => { match event { tauri_plugin_shell::process::CommandEvent::Stdout(line) => { let line_str = String::from_utf8_lossy(&line); println!("Server output: {}", line_str); if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") { println!("Server is ready!"); break; } } tauri_plugin_shell::process::CommandEvent::Stderr(line) => { let line_str = String::from_utf8_lossy(&line).to_string(); eprintln!("Server: {}", line_str); // Collect error lines for debugging if line_str.contains("ERROR") || line_str.contains("Error") || line_str.contains("Failed") { error_output.push(line_str.clone()); } // Uvicorn logs to stderr, so check there too if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") { println!("Server is ready!"); break; } } _ => {} } } Ok(None) => { // In dev mode, this is expected when using the placeholder binary #[cfg(debug_assertions)] { use std::net::TcpStream; eprintln!("Server process ended (dev mode placeholder detected)"); // Check if a manually-started server is available if TcpStream::connect_timeout( &format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(), std::time::Duration::from_secs(1), ).is_ok() { // Clean up state let _ = state.child.lock().unwrap().take(); let _ = state.server_pid.lock().unwrap().take(); println!("Found manually-started server on port {}", SERVER_PORT); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } eprintln!(""); eprintln!("================================================================="); eprintln!("DEV MODE: No bundled server binary available"); eprintln!(""); eprintln!("Start the Python server in a separate terminal:"); eprintln!(" bun run dev:server"); eprintln!("================================================================="); eprintln!(""); return Err("Dev mode: Start server manually with 'bun run dev:server'".to_string()); } #[cfg(not(debug_assertions))] { eprintln!("Server process ended unexpectedly during startup!"); eprintln!("The server binary may have crashed or exited with an error."); eprintln!("Check Console.app logs for more details (search for 'voicebox')"); return Err("Server process ended unexpectedly".to_string()); } } Err(_) => { // Timeout on this recv, continue loop continue; } } } // Spawn task to continue reading output tokio::spawn(async move { while let Some(event) = rx.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stdout(line) => { println!("Server: {}", String::from_utf8_lossy(&line)); } tauri_plugin_shell::process::CommandEvent::Stderr(line) => { eprintln!("Server error: {}", String::from_utf8_lossy(&line)); } _ => {} } } }); Ok(format!("http://127.0.0.1:{}", SERVER_PORT)) } #[command] async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> { let pid = state.server_pid.lock().unwrap().take(); let _child = state.child.lock().unwrap().take(); if let Some(pid) = pid { println!("stop_server: Stopping server with PID: {}", pid); #[cfg(unix)] { use std::process::Command; // Kill process group with SIGTERM first let _ = Command::new("kill") .args(["-TERM", "--", &format!("-{}", pid)]) .output(); // Brief wait then force kill std::thread::sleep(std::time::Duration::from_millis(100)); let _ = Command::new("kill") .args(["-9", "--", &format!("-{}", pid)]) .output(); let _ = Command::new("kill") .args(["-9", &pid.to_string()]) .output(); println!("stop_server: Process group kill completed"); } #[cfg(windows)] { // Send graceful shutdown via HTTP — the server's parent-pid watchdog // will also handle cleanup if this app process exits. println!("Sending graceful shutdown via HTTP..."); let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() .unwrap(); let _ = client .post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT)) .send(); println!("Shutdown request sent (server watchdog will handle cleanup)"); } } Ok(()) } #[command] async fn restart_server( app: tauri::AppHandle, state: State<'_, ServerState>, models_dir: Option, ) -> Result { println!("restart_server: stopping current server..."); // Update stored models_dir: empty string means reset to default, non-empty means set if let Some(ref dir) = models_dir { if dir.is_empty() { *state.models_dir.lock().unwrap() = None; } else { *state.models_dir.lock().unwrap() = Some(dir.clone()); } } // Stop the current server stop_server(state.clone()).await?; // Wait for port to be released println!("restart_server: waiting for port release..."); tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; // Start server again (will auto-detect CUDA binary and use stored models_dir) println!("restart_server: starting server..."); start_server(app, state, None, None).await } #[command] fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) { *state.keep_running_on_close.lock().unwrap() = keep_running; } #[command] async fn start_system_audio_capture( state: State<'_, audio_capture::AudioCaptureState>, max_duration_secs: u32, ) -> Result<(), String> { audio_capture::start_capture(&state, max_duration_secs).await } #[command] async fn stop_system_audio_capture( state: State<'_, audio_capture::AudioCaptureState>, ) -> Result { audio_capture::stop_capture(&state).await } #[command] fn is_system_audio_supported() -> bool { audio_capture::is_supported() } #[command] fn list_audio_output_devices( state: State<'_, audio_output::AudioOutputState>, ) -> Result, String> { state.list_output_devices() } #[command] async fn play_audio_to_devices( state: State<'_, audio_output::AudioOutputState>, audio_data: Vec, device_ids: Vec, ) -> Result<(), String> { state.play_audio_to_devices(audio_data, device_ids).await } #[command] fn stop_audio_playback( state: State<'_, audio_output::AudioOutputState>, ) -> Result<(), String> { state.stop_all_playback() } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .manage(ServerState { child: Mutex::new(None), server_pid: Mutex::new(None), keep_running_on_close: Mutex::new(false), models_dir: Mutex::new(None), }) .manage(audio_capture::AudioCaptureState::new()) .manage(audio_output::AudioOutputState::new()) .setup(|app| { #[cfg(desktop)] { app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_process::init())?; } // Hide title bar icon on Windows #[cfg(windows)] { use windows::Win32::Foundation::HWND; use windows::Win32::UI::WindowsAndMessaging::{SetClassLongPtrW, GCLP_HICON, GCLP_HICONSM}; if let Some((_, window)) = app.webview_windows().iter().next() { if let Ok(hwnd) = window.hwnd() { let hwnd = HWND(hwnd.0); unsafe { // Set both small and regular icons to NULL to hide the title bar icon SetClassLongPtrW(hwnd, GCLP_HICON, 0); SetClassLongPtrW(hwnd, GCLP_HICONSM, 0); } } } } // Enable microphone access on Linux (WebKitGTK denies getUserMedia by default) #[cfg(target_os = "linux")] { use tauri::Manager; if let Some(window) = app.get_webview_window("main") { let _ = window.with_webview(|webview| { use webkit2gtk::{WebViewExt, SettingsExt, PermissionRequestExt}; use webkit2gtk::glib::ObjectExt; let wk_webview = webview.inner(); // Enable media stream support in WebKitGTK settings if let Some(settings) = WebViewExt::settings(&wk_webview) { settings.set_enable_media_stream(true); } // Auto-grant UserMediaPermissionRequest (microphone access) // Only for trusted local origins (Tauri dev server or custom protocol) wk_webview.connect_permission_request(move |webview, request: &webkit2gtk::PermissionRequest| { if request.is::() { let uri = WebViewExt::uri(webview).unwrap_or_default(); let is_trusted = uri.starts_with("tauri://") || uri.starts_with("https://tauri.localhost") || uri.starts_with("http://localhost") || uri.starts_with("http://127.0.0.1"); if is_trusted { request.allow(); return true; } request.deny(); return true; } false }); }); } } Ok(()) }) .invoke_handler(tauri::generate_handler![ start_server, stop_server, restart_server, set_keep_server_running, start_system_audio_capture, stop_system_audio_capture, is_system_audio_supported, list_audio_output_devices, play_audio_to_devices, stop_audio_playback ]) .on_window_event({ let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); move |window, event| { if let WindowEvent::CloseRequested { api, .. } = event { // If we're already in the close flow, let it proceed if closing.load(std::sync::atomic::Ordering::SeqCst) { return; } closing.store(true, std::sync::atomic::Ordering::SeqCst); // Prevent automatic close so frontend can clean up api.prevent_close(); // Emit event to frontend to check setting and stop server if needed let app_handle = window.app_handle(); if let Err(e) = app_handle.emit("window-close-requested", ()) { eprintln!("Failed to emit window-close-requested event: {}", e); window.close().ok(); return; } // Set up listener for frontend response let window_for_close = window.clone(); let closing_for_timeout = closing.clone(); let (tx, mut rx) = mpsc::unbounded_channel::<()>(); let listener_id = window.listen("window-close-allowed", move |_| { let _ = tx.send(()); }); tauri::async_runtime::spawn(async move { tokio::select! { _ = rx.recv() => { window_for_close.close().ok(); } _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => { eprintln!("Window close timeout, closing anyway"); window_for_close.close().ok(); } } window_for_close.unlisten(listener_id); closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst); }); } }}) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| { let _ = &app; // used on unix match &event { RunEvent::Exit => { println!("RunEvent::Exit received - server will self-terminate via parent watchdog"); // The server monitors this process's PID via --parent-pid. // When this process exits, the server detects it and shuts itself down. // No need for taskkill/wmic/process tree enumeration. // On Unix, send SIGTERM to the process group for immediate cleanup. #[cfg(unix)] { let state = app.state::(); let keep_running = *state.keep_running_on_close.lock().unwrap(); if !keep_running { if let Some(pid) = state.server_pid.lock().unwrap().take() { use std::process::Command; let _ = Command::new("kill") .args(["-TERM", "--", &format!("-{}", pid)]) .output(); std::thread::sleep(std::time::Duration::from_millis(100)); let _ = Command::new("kill") .args(["-9", "--", &format!("-{}", pid)]) .output(); let _ = Command::new("kill") .args(["-9", &pid.to_string()]) .output(); } } } } RunEvent::ExitRequested { api, .. } => { println!("RunEvent::ExitRequested received"); // Don't prevent exit, just log it let _ = api; } _ => {} } }); } fn main() { run(); }