// 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; /// Find a voicebox-server process listening on a given port (Windows only). /// /// Uses PowerShell `Get-NetTCPConnection` to look up the PID owning the port, /// then verifies via `tasklist` that it's a voicebox process. The caller is /// responsible for checking port occupancy first (e.g. `TcpStream::connect_timeout`). /// Replaces the previous `netstat -ano` approach which failed on systems with /// corrupted system DLLs (see #277). #[cfg(windows)] fn find_voicebox_pid_on_port(port: u16) -> Option { use std::process::Command; // Use PowerShell's Get-NetTCPConnection to find the PID listening on the port. // This is a built-in cmdlet that doesn't depend on netstat.exe. let ps_script = format!( "Get-NetTCPConnection -LocalPort {} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess", port ); if let Ok(output) = Command::new("powershell") .args(["-NoProfile", "-Command", &ps_script]) .output() { let output_str = String::from_utf8_lossy(&output.stdout); for line in output_str.lines() { if let Ok(pid) = line.trim().parse::() { // Verify this PID is a voicebox process 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") { return Some(pid); } } } } } None } /// Check if a Voicebox server is responding on the given port. /// /// Sends an HTTP GET to `/health` and returns `true` only if the response /// is valid JSON matching the Voicebox `HealthResponse` schema — specifically /// `status` must be `"healthy"`, and both `model_loaded` and `gpu_available` /// must be present as booleans. This prevents misidentifying an unrelated /// service that happens to expose a `/health` endpoint. #[allow(dead_code)] // Used in platform-specific cfg blocks fn check_health(port: u16) -> bool { let url = format!("http://127.0.0.1:{}/health", port); match reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(3)) .build() { Ok(client) => match client.get(&url).send() { Ok(resp) => { if !resp.status().is_success() { return false; } // Parse as JSON and validate Voicebox-specific fields match resp.json::() { Ok(body) => { body.get("status").and_then(|v| v.as_str()) == Some("healthy") && body.get("model_loaded").map(|v| v.is_boolean()).unwrap_or(false) && body.get("gpu_available").map(|v| v.is_boolean()).unwrap_or(false) } Err(_) => false, } } Err(_) => false, }, Err(_) => false, } } 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, // or an externally started server e.g. via `python`, `uvicorn`, Docker, etc.) #[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)); } } else { // Process name doesn't contain "voicebox" — could be an external // Python/uvicorn/Docker server. Verify via HTTP health check. println!("Port {} in use by '{}' (PID: {}), checking if it's a Voicebox server...", SERVER_PORT, command, pid_str); if check_health(SERVER_PORT) { println!("Health check passed — reusing external server on port {}", SERVER_PORT); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } println!("Health check failed — port is occupied by a non-Voicebox process"); return Err(format!( "Port {} is already in use by another application ({}). \ Close it or change the Voicebox server port.", SERVER_PORT, command )); } } } } } #[cfg(windows)] { 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() { // Port is in use — check if it's a voicebox process by name first if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) { println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid); *state.server_pid.lock().unwrap() = Some(pid); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } // Process name doesn't match — could be an external Python/Docker server. // Verify via HTTP health check before giving up. println!("Port {} in use by unknown process, checking if it's a Voicebox server...", SERVER_PORT); if check_health(SERVER_PORT) { println!("Health check passed — reusing external server on port {}", SERVER_PORT); return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); } return Err(format!( "Port {} is already in use by another application. \ Close the other application or change the Voicebox port.", 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; 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) { 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 orphaned voicebox-server on legacy port {} (PID: {}, CMD: {}), killing it...", LEGACY_PORT, pid, command); 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::net::TcpStream; if TcpStream::connect_timeout( &format!("127.0.0.1:{}", LEGACY_PORT).parse().unwrap(), std::time::Duration::from_secs(1), ).is_ok() { if let Some(pid) = find_voicebox_pid_on_port(LEGACY_PORT) { println!("Found orphaned voicebox-server on legacy port {} (PID: {}), killing it...", LEGACY_PORT, pid); let _ = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .output(); } } } // 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 in data directory (onedir layout: backends/cuda/) let cuda_binary = { let cuda_dir = data_dir.join("backends").join("cuda"); let cuda_name = if cfg!(windows) { "voicebox-server-cuda.exe" } else { "voicebox-server-cuda" }; let exe_path = cuda_dir.join(cuda_name); if exe_path.exists() { println!("Found CUDA backend at {:?}", cuda_dir); // Version check: run --version from the onedir directory so // PyInstaller can find its support files for the fast --version path let app_version = app.config().version.clone().unwrap_or_default(); let version_ok = match std::process::Command::new(&exe_path) .arg("--version") .current_dir(&cuda_dir) .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(exe_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 from the onedir directory. // .current_dir() is critical: PyInstaller onedir expects all DLLs and // support files (nvidia/, _internal/, etc.) relative to the exe. let spawn_result = if let Some(ref cuda_path) = cuda_binary { let cuda_dir = cuda_path.parent().unwrap(); println!("Launching CUDA backend: {:?} (cwd: {:?})", cuda_path, cuda_dir); let mut cmd = app.shell().command(cuda_path.to_str().unwrap()); cmd = cmd.current_dir(cuda_dir); 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); let _ = app.emit("server-log", serde_json::json!({ "stream": "stdout", "line": line_str.trim_end(), })); 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); let _ = app.emit("server-log", serde_json::json!({ "stream": "stderr", "line": line_str.trim_end(), })); // 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 and emit to frontend let app_handle = app.clone(); tokio::spawn(async move { while let Some(event) = rx.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stdout(line) => { let line_str = String::from_utf8_lossy(&line); println!("Server: {}", line_str); let _ = app_handle.emit("server-log", serde_json::json!({ "stream": "stdout", "line": line_str.trim_end(), })); } tauri_plugin_shell::process::CommandEvent::Stderr(line) => { let line_str = String::from_utf8_lossy(&line); eprintln!("Server error: {}", line_str); let _ = app_handle.emit("server-log", serde_json::json!({ "stream": "stderr", "line": line_str.trim_end(), })); } _ => {} } } }); 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) { println!("set_keep_server_running called with: {}", keep_running); *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 => { let state = app.state::(); let keep_running = *state.keep_running_on_close.lock().unwrap(); let has_pid = state.server_pid.lock().unwrap().is_some(); println!("RunEvent::Exit — keep_running={}, has_pid={}", keep_running, has_pid); if keep_running { // Tell the server to disable its watchdog so it survives // after this process exits. println!("Keep server running: disabling watchdog..."); // Write a sentinel file as a reliable fallback. On Windows // the HTTP request below can race with process exit, leaving // the watchdog unaware it should stay alive. The sentinel // file is checked during the watchdog grace period. let data_dir = app .path() .app_data_dir() .unwrap_or_default(); let sentinel = data_dir.join(".keep-running"); if let Err(e) = std::fs::write(&sentinel, b"1") { eprintln!("Failed to write keep-running sentinel: {}", e); } else { println!("Wrote keep-running sentinel to {:?}", sentinel); } let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() .unwrap(); match client .post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT)) .send() { Ok(resp) => println!("Watchdog disable response: {}", resp.status()), Err(e) => eprintln!("Failed to disable watchdog: {}", e), } } else { // Server will self-terminate via parent-pid watchdog when // this process exits. On Unix, also send SIGTERM for // immediate cleanup. println!("RunEvent::Exit - server will self-terminate via watchdog"); #[cfg(unix)] { 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(); }