// 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, } #[command] async fn start_server( app: tauri::AppHandle, state: State<'_, ServerState>, remote: Option, ) -> Result { // 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)); let mut sidecar = app .shell() .sidecar("voicebox-server") .map_err(|e| { eprintln!("Failed to get sidecar: {}", e); eprintln!("This usually means the binary is not bundled correctly or doesn't have execute permissions"); format!("Failed to get sidecar: {}", e) })?; println!("Sidecar command created successfully"); // Pass data directory and port to Python server sidecar = sidecar.args([ "--data-dir", data_dir .to_str() .ok_or_else(|| "Invalid data dir path".to_string())?, "--port", &SERVER_PORT.to_string(), ]); if remote.unwrap_or(false) { sidecar = sidecar.args(["--host", "0.0.0.0"]); } println!("Spawning server process..."); let (mut rx, child) = sidecar .spawn() .map_err(|e| { eprintln!("Failed to spawn server process: {}", e); eprintln!("This could be due to:"); eprintln!(" - Missing or corrupted binary"); eprintln!(" - Missing execute permissions"); eprintln!(" - Code signing issues on macOS"); eprintln!(" - Missing dependencies"); 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); } } 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) => { 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: Killing server process group 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(); } #[cfg(windows)] { use std::process::Command; let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .output(); } println!("stop_server: Process group kill completed"); } Ok(()) } #[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 } #[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), }) .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); } } } } #[cfg(debug_assertions)] { // Get all windows and open devtools on the first one if let Some((_, window)) = app.webview_windows().iter().next() { window.open_devtools(); println!("Dev tools opened"); } else { println!("No window found to open dev tools"); } } Ok(()) }) .invoke_handler(tauri::generate_handler![ start_server, stop_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 ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { // Prevent automatic close 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); // If event emission fails, allow close anyway window.close().ok(); return; } // Set up listener for frontend response let window_for_close = window.clone(); let (tx, mut rx) = mpsc::unbounded_channel::<()>(); // Listen for response from frontend using window's listen method let listener_id = window.listen("window-close-allowed", move |_| { // Frontend has checked setting and stopped server if needed // Signal that we can close let _ = tx.send(()); }); // Wait for frontend response or timeout tokio::spawn(async move { tokio::select! { _ = rx.recv() => { // Frontend responded, close window window_for_close.close().ok(); } _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => { // Timeout - close anyway eprintln!("Window close timeout, closing anyway"); window_for_close.close().ok(); } } // Clean up listener window_for_close.unlisten(listener_id); }); } }) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| { match &event { RunEvent::Exit => { println!("================================================================="); println!("RunEvent::Exit received - checking server cleanup"); let state = app.state::(); let keep_running = *state.keep_running_on_close.lock().unwrap(); println!("keep_running_on_close = {}", keep_running); if !keep_running { // Get the stored PID for process group killing let pid = state.server_pid.lock().unwrap().take(); // Also take the child to clean up let _child = state.child.lock().unwrap().take(); if let Some(pid) = pid { println!("Killing server process group with PID: {}", pid); // Kill the entire process group on Unix systems // Using negative PID sends signal to all processes in the group #[cfg(unix)] { use std::process::Command; // First try SIGTERM to the process group let pgid_kill = Command::new("kill") .args(["-TERM", "--", &format!("-{}", pid)]) .output(); match pgid_kill { Ok(output) => { if output.status.success() { println!("SIGTERM sent to process group -{}", pid); } else { // Process group kill failed, try direct kill println!("Process group kill failed, trying direct kill"); let _ = Command::new("kill") .args(["-TERM", &pid.to_string()]) .output(); } } Err(e) => { eprintln!("Failed to execute kill command: {}", e); } } // Give it a moment, then force kill if needed std::thread::sleep(std::time::Duration::from_millis(100)); // Force kill with SIGKILL let _ = Command::new("kill") .args(["-9", "--", &format!("-{}", pid)]) .output(); let _ = Command::new("kill") .args(["-9", &pid.to_string()]) .output(); println!("Server process group kill completed"); } #[cfg(windows)] { // On Windows, use taskkill with /T to kill child processes use std::process::Command; let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .output(); println!("Server process tree kill completed"); } } else { println!("No server PID found (already stopped or never started)"); } } else { println!("Keeping server running per user setting"); } println!("================================================================="); } RunEvent::ExitRequested { api, .. } => { println!("RunEvent::ExitRequested received"); // Don't prevent exit, just log it let _ = api; } _ => {} } }); } fn main() { run(); }