Initial commit
This commit is contained in:
926
tauri/src-tauri/src/main.rs
Normal file
926
tauri/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,926 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user