Files
VoiceStudio/frontend/src-tauri/src/lib.rs
T
853b9eefc7 fix(dub): burn translated subtitles, fix subtitle save JSON error (#309) (#328)
* fix(dub): burn translated subtitles, fix subtitle save JSON error (#309)

Two symptoms, one root: the job kept the original-language ASR transcript
while the editor only sent translated/edited text in the generate request.

- dub_generate now persists the segments the dub was actually generated
  from back onto the job (metadata carried over by stable id, fallback
  index; text_original retained for dual-subtitle layouts) — SRT/VTT
  export and ffmpeg burn-in now render the dub language, not the source.
- The SRT/VTT export endpoints honor the save_path query param the Tauri
  save dialog appends (like every other export) and return the standard
  JSON envelope — previously they ignored it and returned the raw body,
  so the frontend's JSON.parse choked on the SRT cue index ('Unexpected
  non-whitespace character after JSON').
- Frontend guards the save response content-type so any future raw-body
  response surfaces as a clear error.

Fixes #309

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix(dub): use the file's established realpath+startswith containment idiom (CodeQL)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dub): write subtitle saves from the Tauri process, not the backend (#309)

The backend save_path variant on /dub/srt and /dub/vtt routed a
user-controlled destination through the loopback HTTP surface — six new
CodeQL path-injection flows plus two log-injection flows. Subtitles are
small text bodies, so the frontend now fetches them raw and writes the
file via a new save_text_file Tauri command: the OS save dialog in the
trusted process is the write authorization, and the backend never sees
a destination path. Binary exports keep the established save_path flow.
Also strips newlines from user-derived values in the two flagged log
lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dub): leave _native_save byte-identical to main

The newline-strip on the log line moved a path sink onto a changed line,
which made CodeQL re-attribute the long-standing binary-export flow to
this PR as a new alert. The subtitle endpoints no longer feed this
function at all, so restore the exact original line — the baseline alert
stays baseline, and hardening pre-existing flows belongs in its own PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-11 12:15:53 +05:30

765 lines
36 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.
//! OmniVoice Studio — Tauri desktop shell.
//!
//! Module layout:
//! config – persistent app config, region helpers
//! bootstrap – first-run venv creation, progress stages, retry commands
//! tools – sidecar detection, FFmpeg/ffprobe/uv resolution & install
//! backend – spawn backend process, port probing, log paths
//! commands – Tauri IPC commands (sysinfo, logs, HF cache, paste, tray, dictation)
pub mod config;
pub mod setup;
pub mod bootstrap;
pub mod tools;
pub mod backend;
pub mod commands;
pub mod updater_channel;
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{Emitter, Manager};
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use crate::bootstrap::{BootstrapStage, BootstrapState, set_stage};
use crate::config::{default_dictation_shortcut, load_config};
// ── Port ──────────────────────────────────────────────────────────────────
pub fn backend_port() -> u16 {
std::env::var("OMNIVOICE_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3900)
}
// ── Shared state types ────────────────────────────────────────────────────
pub struct BackendState {
pub process: Mutex<Option<Child>>,
}
pub struct AppFlags {
pub quitting: AtomicBool,
}
pub struct TrayHandle {
pub tray: Mutex<Option<tauri::tray::TrayIcon>>,
}
pub struct DictationShortcutState {
pub current: Mutex<Option<tauri_plugin_global_shortcut::Shortcut>>,
}
pub const TRAY_ICON_DEFAULT: &[u8] = include_bytes!("../icons/32x32.png");
pub const TRAY_ICON_RECORDING: &[u8] = include_bytes!("../icons/tray-recording.png");
// ── WebView media-capture permissions ─────────────────────────────────────
//
// `getUserMedia()` needs the WebView-engine permission answered, separately
// from the OS-level microphone permission:
//
// - Windows (WebView2): with no `PermissionRequested` handler registered,
// WebView2 falls back to its own permission UI. The dictation pill is a
// 300×64 transparent, undecorated, always-on-top window that can't host
// that UI (and is deliberately unfocused so the auto-paste lands in the
// target app) — the request dies and getUserMedia() rejects with
// NotAllowedError even though Windows already lets the app record (#323:
// backend transcribes fine, the pill still says access denied). We answer
// media-capture requests in code, for the app's own origin only. The OS
// privacy toggle (Settings → Privacy & security → Microphone) still
// applies on top.
// - Linux (WebKitGTK): media-stream must be enabled per-WebView and the
// permission request answered programmatically.
// - macOS (WKWebView): nothing to do here — wry grants media-capture to the
// app origin and the user-visible consent is the system TCC prompt driven
// by NSMicrophoneUsageDescription in src-tauri/Info.plist.
/// True for origins the app itself serves: the Tauri custom-protocol origin
/// in production and the Vite dev server / loopback in `tauri dev`.
#[cfg_attr(not(windows), allow(dead_code))]
fn is_app_origin(uri: &str) -> bool {
let rest = match uri
.strip_prefix("https://")
.or_else(|| uri.strip_prefix("http://"))
{
Some(rest) => rest,
None => return false,
};
let host = rest
.split(['/', '?', '#'])
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
host == "tauri.localhost" || host == "localhost" || host == "127.0.0.1"
}
#[allow(unused_variables)]
fn grant_webview_media_permissions(win: &tauri::WebviewWindow) {
#[cfg(target_os = "linux")]
{
let label = win.label().to_string();
let _ = win.with_webview(move |webview| {
use webkit2gtk::{PermissionRequestExt, SettingsExt, WebViewExt};
let wk = webview.inner();
if let Some(settings) = WebViewExt::settings(&wk) {
settings.set_enable_media_stream(true);
settings.set_enable_mediasource(true);
settings.set_media_playback_requires_user_gesture(false);
log::info!("WebKitGTK: media-stream enabled on '{label}'");
}
wk.connect_permission_request(|_, request| {
request.allow();
true
});
});
}
#[cfg(windows)]
{
let label = win.label().to_string();
let _ = win.with_webview(move |webview| {
use webview2_com::Microsoft::Web::WebView2::Win32::{
ICoreWebView2, ICoreWebView2PermissionRequestedEventArgs,
COREWEBVIEW2_PERMISSION_KIND_CAMERA, COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
COREWEBVIEW2_PERMISSION_KIND_UNKNOWN_PERMISSION,
COREWEBVIEW2_PERMISSION_STATE_ALLOW,
};
use webview2_com::{take_pwstr, PermissionRequestedEventHandler};
let core = match unsafe { webview.controller().CoreWebView2() } {
Ok(core) => core,
Err(e) => {
log::warn!("WebView2: CoreWebView2 unavailable on '{label}': {e}");
return;
}
};
let handler = PermissionRequestedEventHandler::create(Box::new(
move |_core: Option<ICoreWebView2>,
args: Option<ICoreWebView2PermissionRequestedEventArgs>|
-> windows_core::Result<()> {
let args = match args {
Some(args) => args,
None => return Ok(()),
};
unsafe {
let mut kind = COREWEBVIEW2_PERMISSION_KIND_UNKNOWN_PERMISSION;
args.PermissionKind(&mut kind)?;
if kind != COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
&& kind != COREWEBVIEW2_PERMISSION_KIND_CAMERA
{
// Leave non-media permissions to default handling.
return Ok(());
}
let mut uri = windows_core::PWSTR::null();
args.Uri(&mut uri)?;
if is_app_origin(&take_pwstr(uri)) {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW)?;
}
}
Ok(())
},
));
let mut token = 0i64;
match unsafe { core.add_PermissionRequested(&handler, &mut token) } {
Ok(()) => log::info!("WebView2: media-capture auto-grant active on '{label}'"),
Err(e) => log::warn!(
"WebView2: PermissionRequested handler registration failed on '{label}': {e}"
),
}
});
}
// macOS: intentionally empty — see module comment above.
}
#[cfg(test)]
mod media_permission_tests {
use super::is_app_origin;
#[test]
fn allows_app_and_dev_origins() {
assert!(is_app_origin("http://tauri.localhost/index.html"));
assert!(is_app_origin("https://tauri.localhost"));
assert!(is_app_origin("http://localhost:3901/"));
assert!(is_app_origin("http://127.0.0.1:3901/index.html"));
}
#[test]
fn rejects_foreign_origins() {
assert!(!is_app_origin("http://tauri.localhost.evil.com/"));
assert!(!is_app_origin("https://example.com/"));
assert!(!is_app_origin("file:///C:/index.html"));
assert!(!is_app_origin("http://localhost.evil.com:3901/"));
assert!(!is_app_origin(""));
}
}
// ── Tauri entry ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// ── Detect pill mode from CLI args OR persisted config ────────────────
// CLI flag takes precedence. If not passed, fall back to the
// `launch_as_widget` config field (set via tray "Switch to Pill Mode" or
// Settings → Launch options checkbox). This means a user can configure
// "launch as widget by default" once and never need to remember the flag.
let cli_pill = std::env::args().any(|a| a == "--pill");
let pill_mode = cli_pill || crate::config::load_config_pre_app().launch_as_widget;
if pill_mode {
log::info!(
"Starting in pill (dictation-only) mode (source: {})",
if cli_pill { "--pill flag" } else { "config.launch_as_widget" }
);
// On macOS, hide the Dock icon in pill mode so only the tray shows.
// This is handled after the app builds via set_activation_policy.
}
let pill_mode_setup = pill_mode;
let pill_mode_tray = pill_mode;
let app = tauri::Builder::default()
// Single-instance MUST be registered first.
.plugin(tauri_plugin_single_instance::init(move |app, _argv, _cwd| {
log::info!("Second instance attempted — focusing existing window");
let target = if pill_mode { "widget" } else { "main" };
if let Some(win) = app.get_webview_window(target) {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}))
.invoke_handler(tauri::generate_handler![
bootstrap::bootstrap_status,
bootstrap::get_bootstrap_logs,
bootstrap::retry_bootstrap,
bootstrap::clean_and_retry_bootstrap,
setup::get_setup_state,
setup::check_install_target,
setup::complete_setup,
config::get_region,
config::set_region,
config::get_update_channel,
config::set_update_channel,
updater_channel::check_update,
updater_channel::install_update,
updater_channel::list_releases,
commands::get_sysinfo,
commands::read_log_tail,
commands::hf_cache_scan,
commands::simulate_paste,
commands::set_tray_recording,
commands::quit_app,
commands::save_text_file,
commands::get_dictation_shortcut,
commands::set_dictation_shortcut,
commands::get_launch_as_widget,
commands::set_launch_as_widget,
commands::enable_pill_autostart,
commands::disable_pill_autostart,
commands::is_pill_autostart_enabled,
])
.setup(move |app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
app.handle().plugin(tauri_plugin_opener::init())?;
// Exclude the dictation widget from state persistence — otherwise
// `tauri-plugin-window-state` restores `visible: true` on next
// launch if the user happened to be dictating when they quit,
// overriding the WebviewWindowBuilder `.visible(false)` below.
// Symptom: pill appears on app load with no shortcut press.
// The main window is fine to persist (size/position are useful).
app.handle().plugin(
tauri_plugin_window_state::Builder::default()
.with_denylist(&["widget"])
.build(),
)?;
app.handle().plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("tauri".into()),
}),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
])
.build(),
)?;
// ── Programmatic widget window creation ──────────────────────
// Tauri 2's config-array creation silently dropped the widget
// window (declared in tauri.conf.json with create:false to
// make the handoff explicit). Some combination of transparent
// + decorations:false + visible:false + always-on-top was being
// rejected without an error. Building via WebviewWindowBuilder
// works and surfaces real errors on failure.
{
use tauri::{WebviewWindowBuilder, WebviewUrl};
let result = WebviewWindowBuilder::new(
app,
"widget",
WebviewUrl::App("index.html".into()),
)
.title("Capture")
.inner_size(300.0, 64.0)
.resizable(false)
.transparent(true)
.decorations(false)
.always_on_top(true)
.visible(false)
.skip_taskbar(true)
.center()
.build();
if let Err(e) = result {
log::error!("Failed to create widget window: {e:?}");
}
}
app.manage(AppFlags {
quitting: AtomicBool::new(false),
});
app.manage(TrayHandle {
tray: Mutex::new(None),
});
app.manage(DictationShortcutState {
current: Mutex::new(None),
});
// ── Global dictation shortcut (hold-to-talk) ─────────────────
{
use std::str::FromStr;
use tauri_plugin_global_shortcut::{
GlobalShortcutExt, Shortcut, ShortcutState,
};
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app_handle, _shortcut, event| {
match event.state {
ShortcutState::Pressed => {
log::info!("Global shortcut pressed: dictation start");
// Show the widget window (works in both pill + studio mode)
if let Some(win) = app_handle.get_webview_window("widget") {
// Position pill at bottom-center — WhisperFlow / Ghost-Pepper
// style. 80px margin from bottom clears macOS dock + Windows
// taskbar + most Linux panels. Same math on all platforms.
if let Ok(Some(monitor)) = win.primary_monitor() {
let size = monitor.size();
let scale = monitor.scale_factor();
let logical_w = size.width as f64 / scale;
let logical_h = size.height as f64 / scale;
let x = (logical_w / 2.0 - 150.0) as i32;
let y = (logical_h - 64.0 - 80.0) as i32;
let _ = win.set_position(tauri::Position::Logical(
tauri::LogicalPosition::new(x as f64, y as f64),
));
} else {
let _ = win.center();
}
let _ = win.show();
// Don't steal focus on macOS: the simulated ⌘V from
// simulate_paste() must land in the app the user is
// dictating into — focusing the widget would swallow
// it (#287).
#[cfg(not(target_os = "macos"))]
let _ = win.set_focus();
}
let _ = app_handle.emit("tray-dictate", ());
}
ShortcutState::Released => {
log::info!("Global shortcut released: dictation stop");
let _ = app_handle.emit("tray-dictate-stop", ());
}
}
})
.build(),
)?;
let cfg = load_config(app.handle());
let accel = cfg.dictation_shortcut.clone();
let parsed = Shortcut::from_str(&accel)
.or_else(|_| {
log::warn!(
"Saved shortcut '{accel}' unparseable — falling back to default"
);
Shortcut::from_str(&default_dictation_shortcut())
});
match parsed {
Ok(shortcut) => match app.global_shortcut().register(shortcut.clone()) {
Ok(()) => {
log::info!("Global shortcut '{accel}' registered");
if let Ok(mut slot) = app
.state::<DictationShortcutState>()
.current
.lock()
{
*slot = Some(shortcut);
}
}
Err(e) => log::warn!("Failed to register global shortcut: {e}"),
},
Err(e) => log::warn!("No usable dictation shortcut: {e}"),
}
}
// ── System tray ──────────────────────────────────────────────
let tray_menu = if pill_mode_tray {
// Pill mode: minimal tray with Open Studio + Dictate + Quit
let dictate_i = MenuItemBuilder::new("Start Dictation ⌘⇧Space")
.id("dictate")
.build(app)?;
let open_studio_i = MenuItemBuilder::new("Open OmniVoice Studio")
.id("open_studio")
.build(app)?;
let quit_i = MenuItemBuilder::new("Quit Dictation")
.id("quit")
.build(app)?;
MenuBuilder::new(app)
.item(&dictate_i)
.separator()
.item(&open_studio_i)
.separator()
.item(&quit_i)
.build()?
} else {
// Studio mode: full tray
let show_i = MenuItemBuilder::new("Show OmniVoice")
.id("show")
.build(app)?;
let dictate_i = MenuItemBuilder::new("Start Dictation ⌘⇧Space")
.id("dictate")
.build(app)?;
let switch_to_pill_i = MenuItemBuilder::new("Switch to Dictation Widget")
.id("switch_to_pill")
.build(app)?;
let settings_i = MenuItemBuilder::new("Settings")
.id("settings")
.build(app)?;
let quit_i = MenuItemBuilder::new("Quit OmniVoice")
.id("quit")
.build(app)?;
MenuBuilder::new(app)
.item(&show_i)
.separator()
.item(&dictate_i)
.item(&switch_to_pill_i)
.item(&settings_i)
.separator()
.item(&quit_i)
.build()?
};
let tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&tray_menu)
.tooltip(if pill_mode_tray { "OmniVoice Dictation" } else { "OmniVoice Studio" })
.on_menu_event(move |app, event| {
match event.id().as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.set_focus();
// Self-recovery: if the webview failed to load
// the dev/prod URL earlier (Vite restarted,
// backend not up yet at first show, etc.) the
// window shows a blank `<body></body>` with a
// "Could not connect to the server" console
// error. Reload only when the body is empty
// so a healthy window doesn't blink on every
// tray click.
let _ = win.eval(
"if (document.body && document.body.childElementCount === 0) { location.reload(); }",
);
}
}
"open_studio" => {
// Persist the preference (so next launch is studio, not pill)
// then spawn a new instance without --pill and exit this one.
let mut cfg = crate::config::load_config(app);
cfg.launch_as_widget = false;
crate::config::save_config(app, &cfg);
if let Ok(exe) = std::env::current_exe() {
let _ = std::process::Command::new(exe).spawn();
}
app.state::<AppFlags>()
.quitting
.store(true, Ordering::SeqCst);
app.exit(0);
}
"switch_to_pill" => {
// Mirror of "open_studio" but the other direction:
// persist launch_as_widget=true, relaunch with --pill,
// and exit the current (studio) instance.
let mut cfg = crate::config::load_config(app);
cfg.launch_as_widget = true;
crate::config::save_config(app, &cfg);
if let Ok(exe) = std::env::current_exe() {
let _ = std::process::Command::new(exe)
.arg("--pill")
.spawn();
}
app.state::<AppFlags>()
.quitting
.store(true, Ordering::SeqCst);
app.exit(0);
}
"dictate" => {
// Toggle: if the widget is visible (recording), stop;
// otherwise start dictation. On start, show + position
// + focus the widget BEFORE emitting tray-dictate so
// the user sees the pill instead of silent recording.
// Positioning mirrors the global-shortcut handler:
// bottom-center (WhisperFlow style).
if let Some(win) = app.get_webview_window("widget") {
if win.is_visible().unwrap_or(false) {
let _ = app.emit("tray-dictate-stop", ());
} else {
if let Ok(Some(monitor)) = win.primary_monitor() {
let size = monitor.size();
let scale = monitor.scale_factor();
let logical_w = size.width as f64 / scale;
let logical_h = size.height as f64 / scale;
let x = (logical_w / 2.0 - 150.0) as i32;
let y = (logical_h - 64.0 - 80.0) as i32;
let _ = win.set_position(tauri::Position::Logical(
tauri::LogicalPosition::new(x as f64, y as f64),
));
} else {
let _ = win.center();
}
let _ = win.show();
let _ = win.set_focus();
let _ = app.emit("tray-dictate", ());
}
} else {
log::warn!(
"Tray dictate: widget window not found — \
emitting tray-dictate without visible UI"
);
let _ = app.emit("tray-dictate", ());
}
}
"settings" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.set_focus();
}
let _ = app.emit("tray-navigate", "settings");
}
"quit" => {
app.state::<AppFlags>()
.quitting
.store(true, Ordering::SeqCst);
app.exit(0);
}
_ => {}
}
})
.build(app)?;
if let Ok(mut slot) = app.state::<TrayHandle>().tray.lock() {
*slot = Some(tray);
}
// ── Hide the unused window per mode ──────────────────────────
if pill_mode_setup {
// Pill mode: hide the main window
if let Some(main_win) = app.get_webview_window("main") {
let _ = main_win.hide();
let _ = main_win.set_skip_taskbar(true);
}
// On macOS, set activation policy to Accessory (no Dock icon)
#[cfg(target_os = "macos")]
{
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
// Pill mode: widget stays HIDDEN until activated by global
// shortcut or tray 'Start Dictation'. Pre-position it now so
// the first show appears at bottom-center without an
// animation/frame flicker. Trade-off accepted vs the original
// 'looks-launch-failed' concern: the tray icon + 'OmniVoice
// Dictation' tooltip provide the app-running signal.
match app.get_webview_window("widget") {
Some(win) => {
// Defensive: make sure the widget is hidden on startup
// regardless of what window-state restored. The denylist
// above should handle it, but belt-and-braces.
let _ = win.hide();
if let Ok(Some(monitor)) = win.primary_monitor() {
let size = monitor.size();
let scale = monitor.scale_factor();
let logical_w = size.width as f64 / scale;
let logical_h = size.height as f64 / scale;
let x = (logical_w / 2.0 - 150.0) as i32;
let y = (logical_h - 64.0 - 80.0) as i32;
let _ = win.set_position(tauri::Position::Logical(
tauri::LogicalPosition::new(x as f64, y as f64),
));
} else {
let _ = win.center();
}
log::info!("Pill mode: widget window pre-positioned at bottom-center (hidden until activated)");
}
None => log::error!(
"Pill mode: widget window NOT FOUND — get_webview_window(\"widget\") \
returned None. Check tauri.conf.json windows[label=\"widget\"]."
),
}
} else {
// Studio mode: widget window stays hidden but ready for the
// global shortcut. Belt-and-braces hide() in case any plugin
// or stale state would otherwise show it on startup.
if let Some(win) = app.get_webview_window("widget") {
let _ = win.hide();
}
}
// ── WebView media-capture permissions (mic for dictation) ────
// BOTH the main window (voice-clone recording) and the dictation
// widget need this: the widget is a separate WebView with its own
// permission handling. Previously only "main" was covered on
// Linux, and Windows had no handler at all — so getUserMedia() in
// the dictation pill rejected with NotAllowedError even when the
// OS-level mic permission was granted (#323).
for label in ["main", "widget"] {
if let Some(win) = app.get_webview_window(label) {
grant_webview_media_permissions(&win);
}
}
// ── Bootstrap ────────────────────────────────────────────────
let bootstrap_state = BootstrapState {
stage: Arc::new(Mutex::new(BootstrapStage::Checking)),
logs: Arc::new(Mutex::new(Vec::new())),
};
let stage_handle = bootstrap_state.stage.clone();
app.manage(bootstrap_state);
app.manage(BackendState {
process: Mutex::new(None),
});
let app_handle = app.handle().clone();
std::thread::spawn(move || {
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
if skip_spawn {
log::info!("TAURI_SKIP_BACKEND set — not spawning");
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
// `--setup` re-opens the install-plan screen on demand — it
// must win over the attach-to-healthy-backend shortcut, or a
// running backend would skip straight past it.
if std::env::args().any(|a| a == "--setup") {
log::info!("--setup flag — opening the setup screen");
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
return;
}
if backend::backend_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend — attaching",
backend_port()
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
if backend::port_in_use(backend_port()) {
log::warn!(
"Port {} in use — taking ownership (killing whatever's there)",
backend_port()
);
backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
// First-run gate: never auto-install. With nothing on disk to
// attach to, park on the setup screen and wait for the user to
// confirm an install plan — `complete_setup` restarts the
// bootstrap from there. Existing pre-setup-screen installs
// (venv present) are migrated here — the bootstrap thread is
// the only place that write happens — then pass straight
// through the read-only is_first_run check.
setup::migrate_existing_install_if_needed(&app_handle);
if setup::is_first_run(&app_handle) {
log::info!("First run — awaiting setup screen confirmation before installing");
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
return;
}
// Spawn + health-poll loop shared with the Retry button —
// includes the #314 broken-venv self-heal (quarantine the
// venv and rebuild once when the backend exits with
// "No pyvenv.cfg file" / code 106).
bootstrap::spawn_backend_and_wait(&app_handle, &stage_handle);
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" {
return;
}
let quitting = window
.app_handle()
.state::<AppFlags>()
.quitting
.load(Ordering::SeqCst);
if quitting {
return;
}
api.prevent_close();
let _ = window.hide();
#[cfg(not(target_os = "macos"))]
{
let _ = window.set_skip_taskbar(true);
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
if let Ok(mut lock) = app_handle.state::<BackendState>().process.lock() {
if let Some(ref mut child) = *lock {
let pid = child.id();
log::info!("Shutting down backend (pid {})", pid);
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() < Duration::from_secs(2) => {
std::thread::sleep(Duration::from_millis(100));
}
_ => {
log::warn!("Backend didn't exit in 2 s — SIGKILL");
let _ = child.kill();
break;
}
}
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let _ = child.wait();
}
}
}
});
}