Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
30.6 kB
//! 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<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");
// ── 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::<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();
}
}
"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();
}
}
// ── 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::<BackendState>().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::<BackendState>().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::<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();
}
}
}
});
}