927 lines
39 KiB
Rust
927 lines
39 KiB
Rust
// 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<u32> {
|
|
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::<u32>() {
|
|
// 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::<serde_json::Value>() {
|
|
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<Option<tauri_plugin_shell::process::CommandChild>>,
|
|
server_pid: Mutex<Option<u32>>,
|
|
keep_running_on_close: Mutex<bool>,
|
|
models_dir: Mutex<Option<String>>,
|
|
}
|
|
|
|
#[command]
|
|
async fn start_server(
|
|
app: tauri::AppHandle,
|
|
state: State<'_, ServerState>,
|
|
remote: Option<bool>,
|
|
models_dir: Option<String>,
|
|
) -> Result<String, String> {
|
|
// 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::<u32>() {
|
|
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::<i32>() {
|
|
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<String>,
|
|
) -> Result<String, String> {
|
|
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<String, String> {
|
|
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<Vec<audio_output::AudioOutputDevice>, String> {
|
|
state.list_output_devices()
|
|
}
|
|
|
|
#[command]
|
|
async fn play_audio_to_devices(
|
|
state: State<'_, audio_output::AudioOutputState>,
|
|
audio_data: Vec<u8>,
|
|
device_ids: Vec<String>,
|
|
) -> 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::<webkit2gtk::UserMediaPermissionRequest>() {
|
|
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::<ServerState>();
|
|
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();
|
|
}
|