//! 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 bootstrap; pub mod tools; pub mod backend; pub mod commands; 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>, } pub struct AppFlags { pub quitting: AtomicBool, } pub struct TrayHandle { pub tray: Mutex>, } pub struct DictationShortcutState { pub current: Mutex>, } pub const TRAY_ICON_DEFAULT: &[u8] = include_bytes!("../icons/32x32.png"); pub const TRAY_ICON_RECORDING: &[u8] = include_bytes!("../icons/tray-recording.png"); // ── 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, config::get_region, config::set_region, commands::get_sysinfo, commands::read_log_tail, commands::hf_cache_scan, commands::simulate_paste, commands::set_tray_recording, commands::quit_app, 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(); 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::() .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(); } } "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::() .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::() .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::() .quitting .store(true, Ordering::SeqCst); app.exit(0); } _ => {} } }) .build(app)?; if let Ok(mut slot) = app.state::().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(); } } // ── Enable microphone / camera on Linux (WebKitGTK) ────────── #[cfg(target_os = "linux")] { if let Some(win) = app.get_webview_window("main") { let _ = win.with_webview(|webview| { use webkit2gtk::{WebViewExt, SettingsExt, PermissionRequestExt}; 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"); } wk.connect_permission_request(|_, request| { request.allow(); true }); }); } } // ── 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; } 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)); } let child = backend::spawn_backend(&app_handle, Some(&stage_handle)); if let Ok(mut guard) = app_handle.state::().process.lock() { *guard = child; } let start = std::time::Instant::now(); while start.elapsed() < Duration::from_secs(300) { if backend::backend_healthy(backend_port()) { set_stage(&stage_handle, BootstrapStage::Ready); return; } let process_dead = if let Ok(mut guard) = app_handle.state::().process.lock() { match guard.as_mut() { Some(child) => match child.try_wait() { Ok(Some(status)) => Some(status.to_string()), Ok(None) => None, Err(_) => Some("unknown".to_string()), }, None => Some("never started".to_string()), } } else { None }; if let Some(exit_info) = process_dead { let err_tail = backend::read_error_log_tail(30); let msg = if err_tail.is_empty() { format!("Backend process exited ({}) — no error output captured", exit_info) } else { format!( "Backend process exited ({}):\n{}", exit_info, err_tail ) }; log::error!("Backend died early: {}", msg); set_stage( &stage_handle, BootstrapStage::Failed { message: msg }, ); return; } std::thread::sleep(Duration::from_millis(500)); } let err_tail = backend::read_error_log_tail(20); let msg = if err_tail.is_empty() { "Backend did not respond within 300 s".to_string() } else { format!( "Backend did not respond within 300 s. Last stderr output:\n{}", err_tail ) }; set_stage( &stage_handle, BootstrapStage::Failed { message: msg }, ); }); Ok(()) }) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() != "main" { return; } let quitting = window .app_handle() .state::() .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::().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(); } } } }); }