mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
803 lines
33 KiB
Rust
803 lines
33 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;
|
|
|
|
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)
|
|
#[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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
use std::process::Command;
|
|
if let Ok(output) = Command::new("netstat")
|
|
.args(["-ano"])
|
|
.output()
|
|
{
|
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
|
for line in output_str.lines() {
|
|
if line.contains(&format!(":{}", SERVER_PORT)) && line.contains("LISTENING") {
|
|
if let Some(pid_str) = line.split_whitespace().last() {
|
|
if let Ok(pid) = pid_str.parse::<u32>() {
|
|
if let Ok(tasklist_output) = Command::new("tasklist")
|
|
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
|
.output()
|
|
{
|
|
let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
|
|
if tasklist_str.to_lowercase().contains("voicebox") {
|
|
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
|
|
// Store the PID so we can kill it on exit if needed
|
|
*state.server_pid.lock().unwrap() = Some(pid);
|
|
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Kill any orphaned voicebox-server from previous session on legacy port 8000
|
|
// This handles upgrades from older versions that used a fixed port
|
|
#[cfg(unix)]
|
|
{
|
|
use std::process::Command;
|
|
// Find processes listening on legacy port 8000 with their command names
|
|
if let Ok(output) = Command::new("lsof")
|
|
.args(["-i", &format!(":{}", LEGACY_PORT), "-sTCP:LISTEN"])
|
|
.output()
|
|
{
|
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
|
for line in output_str.lines().skip(1) { // Skip header line
|
|
// lsof output format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 2 {
|
|
let command = parts[0];
|
|
let pid_str = parts[1];
|
|
|
|
// Only kill if it's a voicebox-server process
|
|
if command.contains("voicebox") {
|
|
if let Ok(pid) = pid_str.parse::<i32>() {
|
|
println!("Found orphaned voicebox-server on legacy port {} (PID: {}, CMD: {}), killing it...", LEGACY_PORT, pid, command);
|
|
// Kill the process group
|
|
let _ = Command::new("kill")
|
|
.args(["-9", "--", &format!("-{}", pid)])
|
|
.output();
|
|
let _ = Command::new("kill")
|
|
.args(["-9", &pid.to_string()])
|
|
.output();
|
|
}
|
|
} else {
|
|
println!("Legacy port {} is in use by non-voicebox process: {} (PID: {}), not killing", LEGACY_PORT, command, pid_str);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
use std::process::Command;
|
|
// On Windows, find PIDs on legacy port 8000, then check their names
|
|
if let Ok(output) = Command::new("netstat")
|
|
.args(["-ano"])
|
|
.output()
|
|
{
|
|
let output_str = String::from_utf8_lossy(&output.stdout);
|
|
for line in output_str.lines() {
|
|
if line.contains(&format!(":{}", LEGACY_PORT)) && line.contains("LISTENING") {
|
|
if let Some(pid_str) = line.split_whitespace().last() {
|
|
if let Ok(pid) = pid_str.parse::<u32>() {
|
|
// Get process name for this PID
|
|
if let Ok(tasklist_output) = Command::new("tasklist")
|
|
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
|
.output()
|
|
{
|
|
let tasklist_str = String::from_utf8_lossy(&tasklist_output.stdout);
|
|
if tasklist_str.to_lowercase().contains("voicebox") {
|
|
println!("Found orphaned voicebox-server on legacy port {} (PID: {}), killing it...", LEGACY_PORT, pid);
|
|
let _ = Command::new("taskkill")
|
|
.args(["/PID", &pid.to_string(), "/T", "/F"])
|
|
.output();
|
|
} else {
|
|
println!("Legacy port {} is in use by non-voicebox process (PID: {}), not killing", LEGACY_PORT, pid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Brief wait for port to be released
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
|
|
// Get app data directory
|
|
let data_dir = app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
|
|
|
// Ensure data directory exists
|
|
std::fs::create_dir_all(&data_dir)
|
|
.map_err(|e| format!("Failed to create data dir: {}", e))?;
|
|
|
|
println!("=================================================================");
|
|
println!("Starting voicebox-server sidecar");
|
|
println!("Data directory: {:?}", data_dir);
|
|
println!("Remote mode: {}", remote.unwrap_or(false));
|
|
|
|
// Check for CUDA backend binary in data directory
|
|
let cuda_binary = {
|
|
let backends_dir = data_dir.join("backends");
|
|
let cuda_name = if cfg!(windows) {
|
|
"voicebox-server-cuda.exe"
|
|
} else {
|
|
"voicebox-server-cuda"
|
|
};
|
|
let path = backends_dir.join(cuda_name);
|
|
if path.exists() {
|
|
println!("Found CUDA backend binary at {:?}", path);
|
|
|
|
// Version check: run --version and compare to app version
|
|
let app_version = app.config().version.clone().unwrap_or_default();
|
|
let version_ok = match std::process::Command::new(&path)
|
|
.arg("--version")
|
|
.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(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 directly instead of the bundled sidecar
|
|
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
|
println!("Launching CUDA backend: {:?}", cuda_path);
|
|
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
|
|
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);
|
|
|
|
if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") {
|
|
println!("Server is ready!");
|
|
break;
|
|
}
|
|
}
|
|
tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
|
let line_str = String::from_utf8_lossy(&line).to_string();
|
|
eprintln!("Server: {}", line_str);
|
|
|
|
// Collect error lines for debugging
|
|
if line_str.contains("ERROR") || line_str.contains("Error") || line_str.contains("Failed") {
|
|
error_output.push(line_str.clone());
|
|
}
|
|
|
|
// Uvicorn logs to stderr, so check there too
|
|
if line_str.contains("Uvicorn running") || line_str.contains("Application startup complete") {
|
|
println!("Server is ready!");
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
// 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
|
|
tokio::spawn(async move {
|
|
while let Some(event) = rx.recv().await {
|
|
match event {
|
|
tauri_plugin_shell::process::CommandEvent::Stdout(line) => {
|
|
println!("Server: {}", String::from_utf8_lossy(&line));
|
|
}
|
|
tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
|
eprintln!("Server error: {}", String::from_utf8_lossy(&line));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
|
}
|
|
|
|
#[command]
|
|
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
|
let pid = state.server_pid.lock().unwrap().take();
|
|
let _child = state.child.lock().unwrap().take();
|
|
|
|
if let Some(pid) = pid {
|
|
println!("stop_server: 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) {
|
|
*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 => {
|
|
println!("RunEvent::Exit received - server will self-terminate via parent watchdog");
|
|
// The server monitors this process's PID via --parent-pid.
|
|
// When this process exits, the server detects it and shuts itself down.
|
|
// No need for taskkill/wmic/process tree enumeration.
|
|
|
|
// On Unix, send SIGTERM to the process group for immediate cleanup.
|
|
#[cfg(unix)]
|
|
{
|
|
let state = app.state::<ServerState>();
|
|
let keep_running = *state.keep_running_on_close.lock().unwrap();
|
|
if !keep_running {
|
|
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();
|
|
}
|