use std::process::{Command, Stdio}; use serde::{Deserialize, Serialize}; use sysinfo::{Pid, ProcessesToUpdate, System}; use tauri::{AppHandle, Manager, State}; use crate::state::AppState; /// Default binary name for the sensing server. const DEFAULT_SERVER_BIN: &str = "sensing-server"; /// Find the sensing server binary path. /// /// Search order: /// 1. Custom path from config.server_path /// 2. Bundled in app resources (macOS: Contents/Resources/bin/) /// 3. Next to the app executable /// 4. System PATH fn find_server_binary(app: &AppHandle, custom_path: Option<&str>) -> Result { // 1. Custom path from settings if let Some(path) = custom_path { if std::path::Path::new(path).exists() { return Ok(path.to_string()); } } // 2. Bundled in resources (Tauri bundles to Contents/Resources/) if let Ok(resource_dir) = app.path().resource_dir() { let bundled = resource_dir.join("bin").join(DEFAULT_SERVER_BIN); if bundled.exists() { return Ok(bundled.to_string_lossy().to_string()); } // Also check directly in resources let direct = resource_dir.join(DEFAULT_SERVER_BIN); if direct.exists() { return Ok(direct.to_string_lossy().to_string()); } } // 3. Next to the executable if let Ok(exe_path) = std::env::current_exe() { if let Some(exe_dir) = exe_path.parent() { let sibling = exe_dir.join(DEFAULT_SERVER_BIN); if sibling.exists() { return Ok(sibling.to_string_lossy().to_string()); } } } // 4. Check if it's in PATH if let Ok(output) = Command::new("which").arg(DEFAULT_SERVER_BIN).output() { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { return Ok(path); } } } Err(format!( "Sensing server binary '{}' not found. Please build it with: cargo build --release -p wifi-densepose-sensing-server", DEFAULT_SERVER_BIN )) } /// Start the sensing server as a managed child process. /// /// The server binary is looked up in the following order: /// 1. Settings `server_path` if set /// 2. Bundled resource path /// 3. Next to executable /// 4. System PATH #[tauri::command] pub async fn start_server( app: AppHandle, config: ServerConfig, state: State<'_, AppState>, ) -> Result { // Check if already running { let srv = state.server.lock().map_err(|e| e.to_string())?; if srv.running { return Err("Server is already running".into()); } } // Find server binary let server_path = find_server_binary(&app, config.server_path.as_deref())?; tracing::info!("Starting sensing server from: {}", server_path); // Build command with configuration let mut cmd = Command::new(&server_path); if let Some(port) = config.http_port { cmd.args(["--http-port", &port.to_string()]); } if let Some(port) = config.ws_port { cmd.args(["--ws-port", &port.to_string()]); } if let Some(port) = config.udp_port { cmd.args(["--udp-port", &port.to_string()]); } if let Some(ref bind_addr) = config.bind_address { cmd.args(["--bind", bind_addr]); } if let Some(ref log_level) = config.log_level { cmd.args(["--log-level", log_level]); } // Set data source (default to "simulate" if not specified for demo mode) let source = config.source.as_deref().unwrap_or("simulate"); cmd.args(["--source", source]); // Redirect stdout/stderr to pipes for monitoring cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); // Spawn the child process let child = cmd.spawn().map_err(|e| { format!( "Failed to start server: {}. Is '{}' installed?", e, server_path ) })?; let pid = child.id(); // Store the child process in state { let mut srv = state.server.lock().map_err(|e| e.to_string())?; srv.running = true; srv.pid = Some(pid); srv.http_port = config.http_port; srv.ws_port = config.ws_port; srv.udp_port = config.udp_port; srv.child = Some(child); } tracing::info!("Started sensing server with PID {}", pid); Ok(ServerStartResult { pid, http_port: config.http_port, ws_port: config.ws_port, udp_port: config.udp_port, }) } /// Stop the managed sensing server process. /// /// First attempts graceful termination (SIGTERM), then SIGKILL after timeout. #[tauri::command] pub async fn stop_server(state: State<'_, AppState>) -> Result<(), String> { // Extract child process and take ownership for killing let (child_id, mut child_process) = { let mut srv = state.server.lock().map_err(|e| e.to_string())?; if !srv.running { return Err("Server is not running".into()); } let pid = srv.pid; let child = srv.child.take(); // Take ownership of child (pid, child) }; let child_id = match child_id { Some(id) => id, None => return Err("No server process found".into()), }; tracing::info!("Stopping sensing server with PID {}", child_id); // First try graceful termination via SIGTERM #[cfg(unix)] { unsafe { // Kill the process group (negative PID) to kill all children too let _ = libc::kill(-(child_id as i32), libc::SIGTERM); // Also kill the main process directly let _ = libc::kill(child_id as i32, libc::SIGTERM); } } // Wait briefly for graceful shutdown tokio::time::sleep(std::time::Duration::from_millis(500)).await; // Check if still running let still_running = { let mut sys = System::new(); let pid = Pid::from_u32(child_id); sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); sys.process(pid).is_some() }; // Force kill if still running if still_running { tracing::warn!("Server still running after SIGTERM, sending SIGKILL"); #[cfg(unix)] { unsafe { // SIGKILL the process group and main process let _ = libc::kill(-(child_id as i32), libc::SIGKILL); let _ = libc::kill(child_id as i32, libc::SIGKILL); } } // Also use the child handle if available if let Some(ref mut child) = child_process { let _ = child.kill(); } } // Wait for process to actually terminate if let Some(ref mut child) = child_process { let _ = child.wait(); } // Final verification and cleanup tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Clear state { let mut srv = state.server.lock().map_err(|e| e.to_string())?; srv.running = false; srv.pid = None; srv.http_port = None; srv.ws_port = None; srv.udp_port = None; srv.child = None; } // Verify process is dead let still_alive = { let mut sys = System::new(); let pid = Pid::from_u32(child_id); sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); sys.process(pid).is_some() }; if still_alive { tracing::error!("Failed to kill server process {}", child_id); return Err(format!("Failed to stop server process {}", child_id)); } tracing::info!("Stopped sensing server"); Ok(()) } /// Get sensing server status including resource usage. #[tauri::command] pub async fn server_status(state: State<'_, AppState>) -> Result { let srv = state.server.lock().map_err(|e| e.to_string())?; if !srv.running || srv.pid.is_none() { return Ok(ServerStatusResponse { running: false, pid: None, http_port: None, ws_port: None, udp_port: None, memory_mb: None, cpu_percent: None, uptime_secs: None, }); } // srv.pid.is_none() is checked above; the expect is unreachable in practice. let pid = srv.pid.expect("pid checked as Some before this point"); let mut sys = System::new(); let sysinfo_pid = Pid::from_u32(pid); sys.refresh_processes(ProcessesToUpdate::Some(&[sysinfo_pid]), true); let (memory_mb, cpu_percent) = sys .process(sysinfo_pid) .map(|proc| { let mem = proc.memory() as f64 / 1024.0 / 1024.0; let cpu = proc.cpu_usage(); (Some(mem), Some(cpu)) }) .unwrap_or((None, None)); // Calculate uptime if we have start time let uptime_secs = srv .start_time .map(|start| std::time::Instant::now().duration_since(start).as_secs()); Ok(ServerStatusResponse { running: srv.running, pid: Some(pid), http_port: srv.http_port, ws_port: srv.ws_port, udp_port: srv.udp_port, memory_mb, cpu_percent, uptime_secs, }) } /// Restart the sensing server with the same or new configuration. #[tauri::command] pub async fn restart_server( app: AppHandle, config: Option, state: State<'_, AppState>, ) -> Result { // Get current config if no new config provided let restart_config = if let Some(cfg) = config { cfg } else { let srv = state.server.lock().map_err(|e| e.to_string())?; ServerConfig { http_port: srv.http_port, ws_port: srv.ws_port, udp_port: srv.udp_port, log_level: None, bind_address: None, server_path: None, source: None, // Use default (simulate) } }; // Stop existing server let _ = stop_server(state.clone()).await; // Brief delay to ensure port is released tokio::time::sleep(std::time::Duration::from_millis(500)).await; // Start with new config start_server(app, restart_config, state).await } /// Get server logs (last N lines from stdout/stderr). #[tauri::command] pub async fn server_logs( _lines: Option, state: State<'_, AppState>, ) -> Result { let _srv = state.server.lock().map_err(|e| e.to_string())?; // For now, return empty logs - full implementation would capture stdout/stderr // to ring buffer during process lifetime Ok(ServerLogsResponse { stdout: Vec::new(), stderr: Vec::new(), truncated: false, }) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub http_port: Option, pub ws_port: Option, pub udp_port: Option, pub log_level: Option, pub bind_address: Option, pub server_path: Option, /// Data source: "auto", "wifi", "esp32", "simulate" pub source: Option, } #[derive(Debug, Clone, Serialize)] pub struct ServerStartResult { pub pid: u32, pub http_port: Option, pub ws_port: Option, pub udp_port: Option, } #[derive(Debug, Clone, Serialize)] pub struct ServerStatusResponse { pub running: bool, pub pid: Option, pub http_port: Option, pub ws_port: Option, pub udp_port: Option, pub memory_mb: Option, pub cpu_percent: Option, pub uptime_secs: Option, } #[derive(Debug, Clone, Serialize)] pub struct ServerLogsResponse { pub stdout: Vec, pub stderr: Vec, pub truncated: bool, } #[cfg(test)] mod tests { use super::*; #[test] fn test_server_config_default() { let config = ServerConfig { http_port: Some(8080), ws_port: Some(8765), udp_port: Some(5005), log_level: None, bind_address: None, server_path: None, source: Some("simulate".to_string()), }; assert_eq!(config.http_port, Some(8080)); assert_eq!(config.ws_port, Some(8765)); } }