Files
voicebox/tauri/src-tauri/src/main.rs
T
4a6b5da793 fix(linux): skip click-through toggle on dictate pill to prevent startup crash (#906)
The dictate pill window is built hidden at setup and the frontend emits
dictate:hide as soon as it mounts. The handler calls
set_ignore_cursor_events(true) on a window GTK has never realized, and
tao's CursorIgnoreEvents path unwraps the missing GdkWindow
(tao-0.34.5 event_loop.rs:449), panicking inside a glib dispatch that
cannot unwind — the process aborts within seconds of launch on Linux.

The click-through toggle exists as a macOS workaround for transparent
always-on-top NSWindows lingering as invisible click targets; it was
never needed on Linux. Gate all three call sites so Linux never toggles
it: the true/false pair stays balanced (never set, never unset), and
macOS/Windows builds are unchanged.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 12:40:10 -07:00

1660 lines
69 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod accessibility;
mod audio_capture;
mod audio_output;
mod clipboard;
mod focus_capture;
#[cfg(desktop)]
mod hotkey_monitor;
mod input_monitoring;
#[cfg(desktop)]
mod key_codes;
mod keyboard_layout;
mod speak_monitor;
mod synthetic_keys;
use std::sync::Mutex;
use tauri::{command, State, Manager, WindowEvent, Emitter, Listener, RunEvent, WebviewUrl, WebviewWindowBuilder, PhysicalPosition};
use tauri_plugin_shell::ShellExt;
use tokio::sync::mpsc;
pub const DICTATE_WINDOW_LABEL: &str = "dictate";
const DICTATE_WINDOW_WIDTH: f64 = 420.0;
const DICTATE_WINDOW_HEIGHT: f64 = 64.0;
/// Create the floating dictate webview hidden. The HotkeyMonitor shows it on
/// chord-start; the frontend hides it when the capture pipeline finishes.
/// Building it at setup avoids a race where the first chord or agent-speech
/// event fires before the webview subscribes to the `dictate:*` events.
#[cfg(desktop)]
fn build_dictate_window(app: &tauri::AppHandle) -> tauri::Result<tauri::WebviewWindow> {
let window = WebviewWindowBuilder::new(
app,
DICTATE_WINDOW_LABEL,
WebviewUrl::App("?view=dictate".into()),
)
.title("Voicebox Dictate")
.inner_size(DICTATE_WINDOW_WIDTH, DICTATE_WINDOW_HEIGHT)
.decorations(false)
.transparent(true)
.always_on_top(true)
// Follow the user across macOS Spaces / virtual desktops instead of
// being pinned to the Space where the window was first created.
.visible_on_all_workspaces(true)
.skip_taskbar(true)
.resizable(false)
.shadow(false)
.visible(false)
.build()?;
if let Some(monitor) = window.current_monitor()? {
let monitor_size = monitor.size();
let win_size = window.outer_size()?;
let x = (monitor_size.width as i32 - win_size.width as i32) / 2;
let y = (monitor_size.height as f64 * 0.04) as i32;
window.set_position(PhysicalPosition::new(x, y))?;
}
Ok(window)
}
/// Position, undo click-through, and show the dictate pill window.
///
/// The hide path parks the window at (-10_000, -10_000) and toggles
/// `ignore_cursor_events(true)` so invisible click targets don't leak; we
/// undo both here. Mirrors the logic the hotkey_monitor's
/// `Effect::StartRecording` path runs, minus the focus snapshot — this is
/// for agent-initiated speech, not dictation, so there's no focused text
/// field to paste into.
/// Build the pill webview if it doesn't exist yet. Idempotent — used by
/// agent-speech to prime the webview on speak-start so its listeners can
/// register before the actual show arrives from `audio.onplaying`.
#[cfg(desktop)]
pub fn ensure_dictate_window(app: &tauri::AppHandle) {
if app.get_webview_window(DICTATE_WINDOW_LABEL).is_none() {
if let Err(e) = build_dictate_window(app) {
eprintln!("ensure_dictate_window: failed to build pill: {e}");
}
}
}
#[cfg(desktop)]
pub fn show_dictate_window(app: &tauri::AppHandle) {
// Build on demand so agent-initiated speech works before the user has
// enabled the global hotkey (the hotkey path is the other place this
// window gets built, see `enable_hotkey`).
let window = match app.get_webview_window(DICTATE_WINDOW_LABEL) {
Some(w) => w,
None => match build_dictate_window(app) {
Ok(w) => w,
Err(e) => {
eprintln!("show_dictate_window: failed to build pill window: {e}");
return;
}
},
};
// current_monitor() returns None when the window has been parked
// off any display by the hide path; fall back to the primary.
let monitor = window
.current_monitor()
.ok()
.flatten()
.or_else(|| window.primary_monitor().ok().flatten());
if let Some(monitor) = monitor {
let monitor_pos = monitor.position();
let monitor_size = monitor.size();
if let Ok(win_size) = window.outer_size() {
let x = monitor_pos.x
+ (monitor_size.width as i32 - win_size.width as i32) / 2;
let y = monitor_pos.y + (monitor_size.height as f64 * 0.04) as i32;
let _ = window.set_position(PhysicalPosition::new(x, y));
}
}
// Skip on Linux: tao's CursorIgnoreEvents handler unwraps the GdkWindow,
// which is None until the window is first shown, aborting the process.
// The click-through toggle is a macOS workaround and is never set on Linux.
#[cfg(not(target_os = "linux"))]
let _ = window.set_ignore_cursor_events(false);
let _ = window.show();
}
const LEGACY_PORT: u16 = 8000;
pub(crate) 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>>,
/// Override the backend selection: Some("cpu") forces the CPU sidecar even
/// when GPU binaries exist (solving the Windows catch-22 where an active
/// .exe cannot be deleted), while Some("cuda")/Some("rocm") pin a specific
/// GPU variant when more than one is installed. None uses the on-disk
/// default (ROCm preferred, then CUDA). Persisted to disk so the choice
/// survives an app restart.
backend_override: Mutex<Option<String>>,
}
fn backend_override_file(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join("backend_override")
}
fn read_persisted_backend_override(data_dir: &std::path::Path) -> Option<String> {
std::fs::read_to_string(backend_override_file(data_dir))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn write_persisted_backend_override(data_dir: &std::path::Path, value: Option<&str>) {
let path = backend_override_file(data_dir);
match value {
Some(v) => {
let _ = std::fs::create_dir_all(data_dir);
if let Err(e) = std::fs::write(&path, v) {
println!("Failed to persist backend override: {}", e);
}
}
None => {
let _ = std::fs::remove_file(&path);
}
}
}
/// Run `<exe> --version` with a 10-second timeout to avoid hanging Tauri startup.
/// Returns the last whitespace-delimited token from stdout (e.g. "0.4.4"), or None on any failure.
async fn probe_binary_version(exe: &std::path::Path, cwd: &std::path::Path) -> Option<String> {
let mut cmd = tokio::process::Command::new(exe);
cmd.arg("--version")
.current_dir(cwd)
.kill_on_drop(true);
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(output)) => {
let s = String::from_utf8_lossy(&output.stdout);
s.trim().split_whitespace().last().map(String::from)
}
Ok(Err(e)) => {
println!("Version probe failed: {}", e);
None
}
Err(_) => {
println!("Version probe timed out after 10s");
None
}
}
}
#[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 ROCm backend in data directory (onedir layout: backends/rocm/)
let rocm_binary = {
let rocm_dir = data_dir.join("backends").join("rocm");
let rocm_name = if cfg!(windows) {
"voicebox-server-rocm.exe"
} else {
"voicebox-server-rocm"
};
let exe_path = rocm_dir.join(rocm_name);
if exe_path.exists() {
println!("Found ROCm backend at {:?}", rocm_dir);
let app_version = app.config().version.clone().unwrap_or_default();
let binary_version = probe_binary_version(&exe_path, &rocm_dir).await;
let version_ok = if !app_version.is_empty()
&& binary_version.as_deref() == Some(app_version.as_str())
{
println!("ROCm binary version {} matches app version", app_version);
true
} else {
println!(
"ROCm binary version mismatch: binary={}, app={}. Falling back to CPU.",
binary_version.as_deref().unwrap_or("<unknown>"),
app_version
);
false
};
if version_ok {
Some(exe_path)
} else {
None
}
} else {
println!("No ROCm backend found");
None
}
};
// 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 binary_version = probe_binary_version(&exe_path, &cuda_dir).await;
let version_ok = if !app_version.is_empty()
&& binary_version.as_deref() == Some(app_version.as_str())
{
println!("CUDA binary version {} matches app version", app_version);
true
} else {
println!(
"CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.",
binary_version.as_deref().unwrap_or("<unknown>"),
app_version
);
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);
}
// Respect backend override (e.g., user wants CPU even though a GPU binary
// exists, or pinned a specific GPU variant). The in-memory value resets to
// None on app launch, so fall back to the persisted choice on disk.
let backend_override = {
let in_memory = state.backend_override.lock().unwrap().clone();
in_memory.or_else(|| read_persisted_backend_override(&data_dir))
};
// Honor a pinned GPU variant by ignoring the other one — but only when the
// pinned variant is actually installed, so a stale pin to a deleted backend
// self-heals to the default order instead of forcing CPU. With no pin, both
// stay eligible and the launch order below prefers ROCm, then CUDA.
let pin = backend_override.as_deref();
let pin_cuda = pin == Some("cuda") && cuda_binary.is_some();
let pin_rocm = pin == Some("rocm") && rocm_binary.is_some();
let rocm_binary = if pin_cuda { None } else { rocm_binary };
let cuda_binary = if pin_rocm { None } else { cuda_binary };
// If ROCm binary exists, launch it from the onedir directory.
// If CUDA binary exists, launch it from the onedir directory.
// .current_dir() is critical: PyInstaller onedir expects all DLLs and
// support files relative to the exe.
let spawn_result = if backend_override.as_deref() != Some("cpu") {
let mut gpu_spawn = None;
if let Some(ref rocm_path) = rocm_binary {
let rocm_dir = rocm_path.parent().unwrap();
println!("Launching ROCm backend: {:?} (cwd: {:?})", rocm_path, rocm_dir);
let mut cmd = app.shell().command(rocm_path.to_str().unwrap());
cmd = cmd.current_dir(rocm_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); }
match cmd.spawn() {
Ok(r) => { gpu_spawn = Some(Ok(r)); }
Err(e) => { println!("ROCm spawn failed ({}), trying CUDA/CPU fallback", e); }
}
}
if gpu_spawn.is_none() {
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); }
match cmd.spawn() {
Ok(r) => { gpu_spawn = Some(Ok(r)); }
Err(e) => { println!("CUDA spawn failed ({}), falling back to CPU", e); }
}
}
}
if let Some(result) = gpu_spawn {
result
} else {
// Fall back to 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 bundled CPU server process...");
sidecar.spawn()
}
} else {
// Override forces CPU — use bundled sidecar, GPU binary stays on disk
println!("Backend override=cpu: using 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);
}
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 GPU binary and use stored models_dir)
println!("restart_server: starting server...");
start_server(app, state.clone(), 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]
fn set_backend_override(
app: tauri::AppHandle,
state: State<'_, ServerState>,
backend: Option<String>,
) {
println!("set_backend_override called with: {:?}", backend);
if let Ok(data_dir) = app.path().app_data_dir() {
write_persisted_backend_override(&data_dir, backend.as_deref());
}
*state.backend_override.lock().unwrap() = backend;
}
#[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()
}
/// Identifier of the Voicebox app itself — used to short-circuit auto-paste
/// when the user fires a chord while focus was inside one of our own
/// windows. Paste into Voicebox-internal targets is step 6 territory and
/// goes through a different (JS-side) injection path.
///
/// Value matches what `focus_capture::capture_focus` writes into
/// `FocusSnapshot::bundle_id` on the current platform — reverse-DNS bundle
/// id on macOS, lowercased exe basename on Windows/Linux.
#[cfg(target_os = "macos")]
const VOICEBOX_BUNDLE_ID: &str = "sh.voicebox.app";
#[cfg(target_os = "windows")]
const VOICEBOX_BUNDLE_ID: &str = "voicebox.exe";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const VOICEBOX_BUNDLE_ID: &str = "voicebox";
/// Milliseconds to wait between activating the target app and firing the
/// synthetic ⌘V, giving AppKit time to finish re-ordering windows and
/// restoring its last-focused field.
const POST_ACTIVATE_SETTLE_MS: u64 = 120;
/// Milliseconds the staged text lives on the clipboard after the paste
/// keystroke, before we restore the user's original clipboard contents.
/// Too short and slow apps haven't consumed the paste yet; too long and
/// the user sees our text if they look at their clipboard manager.
const PASTE_CONSUME_MS: u64 = 400;
/// Reports whether the process currently has macOS Accessibility trust.
/// Used by the settings UI and the paste debug harness to decide whether
/// synthetic key events will actually land.
#[command]
fn check_accessibility_permission() -> bool {
accessibility::is_trusted()
}
/// Reports whether the process can observe global keyboard events. Read by
/// the Captures settings UI to surface a "missing — open Settings" hint
/// beside the hotkey toggle. No prompt side-effect.
#[command]
fn check_input_monitoring_permission() -> bool {
input_monitoring::is_trusted()
}
/// Holds the lazily-spawned global hotkey monitor. The monitor is `None`
/// until the user opts in via the Captures settings toggle — that opt-in is
/// what triggers the macOS Input Monitoring TCC prompt, so a fresh-install
/// user who never enables the hotkey never sees the prompt.
///
/// Disabling the hotkey clears the monitor's internal `ChordMatcher` so
/// keytap's event tap is released while Tauri still owns this `HotkeyState`
/// for the rest of the process. A subsequent enable re-arms without
/// re-prompting for the Input Monitoring permission.
#[cfg(desktop)]
#[derive(Default)]
pub struct HotkeyState {
monitor: Mutex<Option<hotkey_monitor::HotkeyMonitor>>,
}
#[cfg(desktop)]
fn build_chord_bindings(
push_to_talk: &[String],
toggle_to_talk: &[String],
) -> Result<hotkey_monitor::Bindings, String> {
use hotkey_monitor::{Bindings, ChordAction};
use keytap::Key;
use std::collections::HashSet;
fn build_chord(name: &str, names: &[String]) -> Result<HashSet<Key>, String> {
if names.is_empty() {
return Err(format!("{name} chord must have at least one key"));
}
let mut chord = HashSet::new();
for raw in names {
let key = key_codes::key_from_str(raw)
.ok_or_else(|| format!("Unsupported key in {name} chord: {raw}"))?;
chord.insert(key);
}
Ok(chord)
}
let push_chord = build_chord("push-to-talk", push_to_talk)?;
let toggle_chord = build_chord("toggle-to-talk", toggle_to_talk)?;
let mut bindings = Bindings::new();
bindings.insert(ChordAction::PushToTalk, push_chord);
bindings.insert(ChordAction::ToggleToTalk, toggle_chord);
Ok(bindings)
}
/// Spawn the global hotkey monitor on first call; subsequent calls just push
/// the new bindings into the existing monitor. Idempotent on purpose — the
/// frontend invokes this both at startup (when `capture_settings.hotkey_enabled`
/// is true) and from the settings toggle.
///
/// On macOS this is the call that triggers the "Voicebox would like to receive
/// keystrokes from any application" TCC prompt, since keytap's `Tap` creates
/// the CGEventTap inside `HotkeyMonitor::spawn`.
#[cfg(desktop)]
#[command]
fn enable_hotkey(
app: tauri::AppHandle,
state: State<'_, HotkeyState>,
push_to_talk: Vec<String>,
toggle_to_talk: Vec<String>,
) -> Result<(), String> {
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
// Fire the Input Monitoring TCC prompt explicitly from the user's
// toggle click, before keytap's Tap would do it implicitly via
// CGEventTap creation. Two reasons: (1) the prompt timing becomes
// deterministic — it appears in response to a click instead of as a
// mysterious side-effect of "the app started"; (2) on subsequent
// launches we can short-circuit the spawn entirely if the user
// revoked the grant, instead of relying on the tap silently failing.
// The call returns the current grant state; we ignore it because
// keytap surfaces its own error via stderr, and the settings UI
// polls `check_input_monitoring_permission` separately.
let _ = input_monitoring::request();
// The dictate pill webview must exist before the first chord fires so it
// can subscribe to `dictate:start`. Build it here (idempotent — Tauri
// returns the existing window when one with this label already exists).
if app.get_webview_window(DICTATE_WINDOW_LABEL).is_none() {
if let Err(e) = build_dictate_window(&app) {
eprintln!("Failed to build dictate window: {}", e);
}
}
let mut slot = state.monitor.lock().map_err(|e| e.to_string())?;
match slot.as_mut() {
Some(monitor) => monitor.update_bindings(bindings),
None => {
*slot = Some(hotkey_monitor::HotkeyMonitor::spawn(app, bindings));
}
}
Ok(())
}
/// Quiet the global hotkey. Tears down the `ChordMatcher` (which stops
/// keytap's chord worker and closes the OS event tap) but keeps the
/// `HotkeyMonitor` handle around so a subsequent `enable_hotkey` re-arms
/// without re-prompting for Input Monitoring permission.
#[cfg(desktop)]
#[command]
fn disable_hotkey(state: State<'_, HotkeyState>) -> Result<(), String> {
let mut slot = state.monitor.lock().map_err(|e| e.to_string())?;
if let Some(monitor) = slot.as_mut() {
monitor.update_bindings(hotkey_monitor::Bindings::new());
}
Ok(())
}
/// Push a new chord configuration into the running `HotkeyMonitor`. Called
/// by the chord-picker UI when the user edits the chord. No-ops when the
/// monitor isn't spawned — the picker is gated behind the enable toggle, so
/// this can only happen if the frontend races; the next `enable_hotkey` will
/// pick up the saved chords.
///
/// Returns an error when a key name doesn't map to a `keytap::Key`, so the
/// picker UI can surface "this key isn't supported" instead of silently
/// dropping it from the chord.
#[cfg(desktop)]
#[command]
fn update_chord_bindings(
state: State<'_, HotkeyState>,
push_to_talk: Vec<String>,
toggle_to_talk: Vec<String>,
) -> Result<(), String> {
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
let mut slot = state.monitor.lock().map_err(|e| e.to_string())?;
if let Some(monitor) = slot.as_mut() {
monitor.update_bindings(bindings);
}
Ok(())
}
/// Open the Privacy & Security → Accessibility pane in System Settings so
/// the user can grant the permission. The URL scheme is stable across
/// macOS 10.1415; no-op on other platforms.
#[command]
fn open_accessibility_settings(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let url = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
app.shell()
.open(url, None)
.map_err(|e| format!("Failed to open Accessibility settings: {e}"))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let _ = app;
Err("Accessibility settings pane is only implemented on macOS".into())
}
}
/// Open the Privacy & Security → Input Monitoring pane in System Settings.
/// Used by the Captures settings UI when the toggle is on but the grant
/// is missing, so the user can flip the system toggle without hunting.
#[command]
fn open_input_monitoring_settings(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let url = "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent";
app.shell()
.open(url, None)
.map_err(|e| format!("Failed to open Input Monitoring settings: {e}"))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let _ = app;
Err("Input Monitoring settings pane is only implemented on macOS".into())
}
}
/// Deliver `text` into the UI that had focus when the chord fired.
///
/// Pipeline: activate the captured PID → settle → save the user's
/// clipboard → write `text` → fire ⌘V → wait for the target to consume it
/// → conditionally restore the original clipboard.
///
/// The restore is conditional on `NSPasteboard.changeCount` (or the
/// Windows sequence number) matching the value captured right after
/// `write_text`: if something else wrote to the clipboard during the
/// paste-consume window — the user's own ⌘C in the target app, a
/// clipboard history tool (Paste, Pastebot, Maccy), Universal Clipboard
/// sync, 1Password inserting a secret — their newer content takes
/// priority over our snapshot and is preserved. A
/// [`clipboard::current_change_count`] read failure is treated the same
/// way: unknown state is safer than an unconditional overwrite.
///
/// `send_paste` failure is isolated from the restore decision: we always
/// attempt the conditional restore before propagating the paste error,
/// so a failed `CGEventPost` / `SendInput` never leaves the user's
/// clipboard stuck on the transcript.
///
/// Skips (returns `false`) without touching anything when:
/// - `focus.bundle_id` is Voicebox itself — step 6 will inject directly
/// into our own webview; pasting would just double-insert or miss the
/// real target.
/// - Accessibility is not trusted — `CGEventPost` would silently drop the
/// keystroke, leaving the user's clipboard clobbered with nothing to
/// show for it.
///
/// Returns `true` when the paste sequence completed end-to-end.
#[command]
async fn paste_final_text(
text: String,
focus: focus_capture::FocusSnapshot,
) -> Result<bool, String> {
if focus.bundle_id.as_deref() == Some(VOICEBOX_BUNDLE_ID) {
return Ok(false);
}
if !accessibility::is_trusted() {
return Err(
"Accessibility permission required for auto-paste. Open System Settings → Privacy & Security → Accessibility and enable Voicebox."
.into(),
);
}
focus_capture::activate_pid(focus.pid)?;
tokio::time::sleep(std::time::Duration::from_millis(POST_ACTIVATE_SETTLE_MS)).await;
let snapshot = clipboard::save_clipboard()?;
let after_write = clipboard::write_text(&text)?;
let paste_result = synthetic_keys::send_paste();
tokio::time::sleep(std::time::Duration::from_millis(PASTE_CONSUME_MS)).await;
let safe_to_restore = matches!(
clipboard::current_change_count(),
Ok(current) if current == after_write
);
if safe_to_restore {
clipboard::restore_clipboard(&snapshot)?;
} else {
eprintln!(
"[voicebox] clipboard mutated during paste window — skipping restore to preserve newer content"
);
}
paste_result?;
Ok(true)
}
/// Inspect the currently focused UI element. Returns the owning app's PID,
/// bundle id, and AX role. Useful for sanity-checking the focus pipeline
/// before committing to a paste.
#[command]
fn debug_capture_focus() -> Result<focus_capture::FocusSnapshot, String> {
focus_capture::capture_focus()
}
/// Full auto-paste rehearsal: snapshot the focus target now, sleep
/// `drift_ms` so the user can deliberately switch to a different app
/// (proving we don't paste into whichever window is frontmost when the
/// transcribe finishes), then activate the captured PID, stage `text`,
/// fire ⌘V, and restore the clipboard.
#[command]
async fn debug_focus_roundtrip(
text: String,
drift_ms: u64,
post_paste_delay_ms: u64,
) -> Result<serde_json::Value, String> {
if !accessibility::is_trusted() {
return Err(
"Accessibility permission not granted. Open System Settings → Privacy & Security → Accessibility and enable Voicebox."
.into(),
);
}
let snapshot = focus_capture::capture_focus()?;
tokio::time::sleep(std::time::Duration::from_millis(drift_ms)).await;
focus_capture::activate_pid(snapshot.pid)?;
// Give AppKit a beat to process the activation before the synthetic
// Cmd+V arrives — without this the paste sometimes races ahead of the
// window-ordering animation and lands in the previous frontmost app.
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
let clip = clipboard::save_clipboard()?;
let after_write = clipboard::write_text(&text)?;
synthetic_keys::send_paste()?;
tokio::time::sleep(std::time::Duration::from_millis(post_paste_delay_ms)).await;
let before_restore = clipboard::current_change_count()?;
clipboard::restore_clipboard(&clip)?;
Ok(serde_json::json!({
"focus": snapshot,
"change_count_after_write": after_write,
"change_count_before_restore": before_restore,
"clobbered_during_paste": before_restore != after_write,
}))
}
/// End-to-end smoke test for the auto-paste pipeline: save the user's
/// clipboard, stage `text`, optionally wait `pre_paste_delay_ms` so the
/// caller has time to focus the target app, synthesise ⌘V, wait
/// `post_paste_delay_ms` for the target app to consume the event, and put
/// the original clipboard back.
///
/// Short-circuits when Accessibility permission is missing — without it
/// `CGEventPost` silently drops events, so running the full sequence
/// would just clobber the clipboard with nothing to show for it.
#[command]
async fn debug_paste_text(
text: String,
pre_paste_delay_ms: u64,
post_paste_delay_ms: u64,
) -> Result<serde_json::Value, String> {
if !accessibility::is_trusted() {
return Err(
"Accessibility permission not granted. Open System Settings → Privacy & Security → Accessibility and enable Voicebox, then try again."
.into(),
);
}
let snapshot = clipboard::save_clipboard()?;
let before = snapshot.change_count();
let after_write = clipboard::write_text(&text)?;
tokio::time::sleep(std::time::Duration::from_millis(pre_paste_delay_ms)).await;
synthetic_keys::send_paste()?;
tokio::time::sleep(std::time::Duration::from_millis(post_paste_delay_ms)).await;
let before_restore = clipboard::current_change_count()?;
clipboard::restore_clipboard(&snapshot)?;
let after_restore = clipboard::current_change_count()?;
Ok(serde_json::json!({
"change_count_before": before,
"change_count_after_write": after_write,
"change_count_before_restore": before_restore,
"change_count_after_restore": after_restore,
"clobbered_during_paste": before_restore != after_write,
}))
}
/// Manual smoke test for the clipboard snapshot/restore primitives used by
/// the auto-paste pipeline. Stages `text` on the pasteboard, waits
/// `hold_ms` so the caller can ⌘V into another app, then puts the original
/// clipboard contents back. The return value reports the change-count deltas
/// so the harness can verify no third party mutated the clipboard mid-paste.
#[command]
async fn debug_clipboard_roundtrip(
text: String,
hold_ms: u64,
) -> Result<serde_json::Value, String> {
let snapshot = clipboard::save_clipboard()?;
let before = snapshot.change_count();
let item_count = snapshot.item_count();
let after_write = clipboard::write_text(&text)?;
tokio::time::sleep(std::time::Duration::from_millis(hold_ms)).await;
let before_restore = clipboard::current_change_count()?;
clipboard::restore_clipboard(&snapshot)?;
let after_restore = clipboard::current_change_count()?;
Ok(serde_json::json!({
"saved_items": item_count,
"change_count_before": before,
"change_count_after_write": after_write,
"change_count_before_restore": before_restore,
"change_count_after_restore": after_restore,
"clobbered_during_hold": before_restore != after_write,
}))
}
#[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),
backend_override: 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())?;
// Resolve the active keyboard layout's V keycode now, on
// the main thread, and register an observer for layout
// changes. The synthetic-paste hot path then only reads an
// atomic. See keyboard_layout.rs for why this matters
// (Cmd+V is matched by translated character, not keycode,
// so QWERTY keycode 9 produces Cmd+. on Dvorak).
keyboard_layout::init();
// HotkeyMonitor is spawned lazily via the `enable_hotkey`
// command — see HotkeyState. The hidden dictate webview is
// safe to build up front because it does not create the global
// keyboard tap or trigger the macOS Input Monitoring prompt.
app.manage(HotkeyState::default());
// The frontend emits `dictate:hide` whenever the pill cycle
// finishes (rest-fade → hidden). `hide()` alone has been
// unreliable for transparent always-on-top windows on macOS
// — the NSWindow lingers as an invisible click target that
// steals focus to the Voicebox app when the user clicks
// where it used to be. Park the window off-screen and mark
// it click-through as well, so even if `hide()` no-ops the
// user sees and interacts with nothing.
let handle_for_hide = app.handle().clone();
app.handle().listen("dictate:hide", move |_event| {
if let Some(window) = handle_for_hide.get_webview_window(DICTATE_WINDOW_LABEL) {
// Skip on Linux: aborts if the window was never realized
// (see show_dictate_window).
#[cfg(not(target_os = "linux"))]
let _ = window.set_ignore_cursor_events(true);
let _ = window.set_position(PhysicalPosition::new(-10_000, -10_000));
let _ = window.hide();
}
});
// Agent-initiated speech (voicebox.speak over MCP or POST /speak)
// pops the pill up so the user can see what's coming out of their
// machine. The `dictate:show` listener is kept for any frontend
// caller that wants to force-surface the pill directly, but the
// primary source is `speak_monitor` below — Rust subscribes to
// the backend /events/speak SSE stream so the pill surfaces even
// when no JS window is active.
let handle_for_show = app.handle().clone();
app.handle().listen("dictate:show", move |_event| {
show_dictate_window(&handle_for_show);
});
ensure_dictate_window(app.handle());
speak_monitor::spawn_speak_monitor(app.handle().clone());
}
// 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,
set_backend_override,
start_system_audio_capture,
stop_system_audio_capture,
is_system_audio_supported,
list_audio_output_devices,
play_audio_to_devices,
stop_audio_playback,
debug_clipboard_roundtrip,
debug_paste_text,
debug_capture_focus,
debug_focus_roundtrip,
check_accessibility_permission,
check_input_monitoring_permission,
open_accessibility_settings,
open_input_monitoring_settings,
paste_final_text,
enable_hotkey,
disable_hotkey,
update_chord_bindings
])
.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();
}