Files
VoiceStudio/frontend/src-tauri/src/lib.rs
T
Palash Debnath ae46f187d0 fix(security): move host-path authority into the desktop shell (#1448)
Close unauthenticated remote mutation paths and keep filesystem destinations behind one-shot Tauri capabilities. CodeRabbit and Greptile findings were fixed on-branch; all review threads are resolved. Full CI, Security, Rust, and cross-platform smoke checks are green.
2026-08-09 21:55:43 +00:00

972 lines
46 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.
//! VoiceStudio — 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 crash;
pub mod reset;
pub mod uninstall;
pub mod updater_channel;
pub mod blank_guard;
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 tauri_plugin_positioner::{Position, WindowExt};
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>>,
/// When the tracked child was spawned — feeds the crash marker's
/// `uptime_s` (#941). Set alongside `process` in bootstrap.rs.
pub spawned_at: Mutex<Option<std::time::Instant>>,
}
pub struct AppFlags {
pub quitting: AtomicBool,
/// Whether dictation is currently recording. The tray's Start/Stop item
/// used to infer this from `widget.is_visible()`, which stopped meaning
/// anything once the widget became a permanently hidden host. The frontend
/// already reports every start and stop via `set_tray_recording` (it drives
/// the tray icon), so that same call keeps this in step.
pub dictating: 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 in code — wry's own WKUIDelegate
// (WryWebViewUIDelegate::request_media_capture_permission) already grants
// every media-capture request unconditionally at the WebKit/JS layer. But
// that alone isn't sufficient (#1013): Tauri's macOS bundle defaults
// `hardenedRuntime` to true, and Hardened Runtime blocks camera/microphone
// hardware access unless the matching entitlement is present — without it,
// TCC never even registers a request, so the app never appears in System
// Settings → Privacy & Security → Microphone for the user to enable. See
// src-tauri/entitlements.plist (wired in via tauri.conf.json's
// bundle.macOS.entitlements) for the actual grant; NSMicrophoneUsageDescription
// in Info.plist only supplies the *prompt text* TCC shows, it doesn't
// substitute for the entitlement.
/// 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(""));
}
}
// ── Windows: dictation pill must never take foreground focus (#982) ────────
//
// Windows counterpart of #287 (macOS auto-paste — don't steal focus). The
// pill is `.always_on_top(true).skip_taskbar(true)` and is documented above
// (see `grant_webview_media_permissions`) as "deliberately unfocused so the
// auto-paste lands in the target app" — true on macOS, but on Windows,
// showing an always-on-top top-level window gives it Win32 foreground
// activation by default (ordinary Windows window-manager behavior; macOS
// doesn't force-activate a shown window the same way). Nothing marked the
// pill non-activating, so on Windows it stole foreground on every show —
// the synthesized Ctrl+V from `simulate_paste` landed back in the pill
// instead of the app the user was dictating into, and because the pill
// wrongly held focus for the whole session the target app never got it back
// until the pill's auto-dismiss timer eventually hid it.
//
// Two pieces, both required (verified by reading how `.show()` is used at
// the call sites below — several are followed by an explicit `set_focus()`
// that would fight the style bit on its own):
// 1. WS_EX_NOACTIVATE on the HWND, applied once right after creation, so
// the OS never grants this window foreground activation implicitly.
// 2. `ShowWindow(SW_SHOWNOACTIVATE)` in place of `WebviewWindow::show()` at
// the pill's dictation-trigger call sites, and the explicit
// `set_focus()` calls at those same sites are skipped on Windows (the
// same way they already are on macOS below).
//
// The flag math (`with_noactivate_style`) is a plain function so it's
// unit-testable on every platform — the actual Win32 syscalls that use it
// are Windows-only and can't run under `cargo test` on a non-Windows runner.
/// `WS_EX_NOACTIVATE` (winuser.h: `#define WS_EX_NOACTIVATE 0x08000000L`).
/// Hardcoded rather than imported from the `windows` crate so `with_noactivate_style`
/// below stays free of the Windows-only dependency and is testable everywhere.
/// Only consumed by Windows-only code (or the platform-agnostic test module
/// below) — `#[allow(dead_code)]` elsewhere, same as `is_app_origin` above.
#[cfg_attr(not(windows), allow(dead_code))]
const WS_EX_NOACTIVATE_BIT: isize = 0x0800_0000;
/// OR `WS_EX_NOACTIVATE` into an existing extended window style, preserving
/// every other bit already set (topmost, layered, etc. — the pill's
/// `always_on_top(true)` sets one of these). Pure so it's unit-testable
/// without a real HWND. See module comment above for why this exists.
#[cfg_attr(not(windows), allow(dead_code))]
fn with_noactivate_style(current_ex_style: isize) -> isize {
current_ex_style | WS_EX_NOACTIVATE_BIT
}
/// Mark the pill's HWND `WS_EX_NOACTIVATE`, once, right after creation — this
/// holds for every later `.show()` regardless of call site (belt-and-braces
/// alongside `show_pill_noactivate` below, which some call sites also need
/// because they pair `.show()` with an explicit `set_focus()`).
#[cfg(target_os = "windows")]
fn mark_pill_noactivate(win: &tauri::WebviewWindow) {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE,
};
let Ok(hwnd) = win.hwnd() else {
log::warn!("pill: could not resolve HWND to apply WS_EX_NOACTIVATE (#982)");
return;
};
unsafe {
let current = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, with_noactivate_style(current));
}
}
/// Show the pill without granting it foreground activation.
///
/// Retained but unused: the widget window is never shown (owner decision,
/// 2026-08-07), so there is no call site left. Kept because it is the correct
/// answer to #982 and the only place that knowledge is written down — if a
/// visible pill ever comes back, showing it any other way re-breaks paste on
/// Windows by stealing foreground from the app being dictated into.
#[cfg(target_os = "windows")]
#[allow(dead_code)]
fn show_pill_noactivate(win: &tauri::WebviewWindow) {
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
let Ok(hwnd) = win.hwnd() else {
log::warn!("pill: could not resolve HWND for non-activating show (#982)");
return;
};
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
}
#[cfg(test)]
mod pill_noactivate_tests {
use super::{with_noactivate_style, WS_EX_NOACTIVATE_BIT};
#[test]
fn adds_noactivate_bit_without_clobbering_existing_style() {
// Stand-in for whatever bits the pill's always_on_top/skip_taskbar
// window already carries (e.g. WS_EX_TOPMOST = 0x00000008) —
// NOACTIVATE must be added on top, never replace them.
let topmost = 0x0000_0008isize;
let updated = with_noactivate_style(topmost);
assert_eq!(
updated & WS_EX_NOACTIVATE_BIT,
WS_EX_NOACTIVATE_BIT,
"NOACTIVATE bit must be set"
);
assert_eq!(updated & topmost, topmost, "pre-existing style bits must survive");
}
#[test]
fn idempotent_if_already_noactivate() {
assert_eq!(with_noactivate_style(WS_EX_NOACTIVATE_BIT), WS_EX_NOACTIVATE_BIT);
}
#[test]
fn matches_documented_win32_value() {
// winuser.h: #define WS_EX_NOACTIVATE 0x08000000L
assert_eq!(WS_EX_NOACTIVATE_BIT, 0x0800_0000);
}
}
// ── Tauri entry ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// #879: if the previous run requested a WebView cache repair (splash
// recovery panel → clear_webview_cache_and_relaunch), perform it now —
// before any webview exists, so WebView2 holds no locks on the profile.
commands::clear_webview_cache_if_marked();
// ── 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");
// Always the studio window, never the widget. In pill mode this
// used to target "widget" and show() it — which is precisely the
// empty rectangle the hidden-host contract exists to prevent, and
// relaunching the app is a plausible thing to do when one is stuck
// on your desktop. Pill mode hides the studio window rather than
// closing it, and a second launch is the user asking for the app,
// so show that instead — the same thing the tray's "Open
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.unminimize();
let _ = win.set_focus();
}
// #1156: when the backend died at startup, relaunching the app
// used to just refocus the dead window — the user was stuck
// unless they found the Retry button (or Task Manager). A
// second-instance attempt IS the user asking for a restart, so
// in the Failed stage run the same recovery as the Retry button.
// (respawn_backend attaches to an already-healthy backend rather
// than double-spawning, so a stray double-click stays harmless.)
let state = app.state::<bootstrap::BootstrapState>();
if bootstrap::already_diagnosed(&state.stage) {
log::info!(
"Second instance while bootstrap is Failed — retrying backend spawn (#1156)"
);
bootstrap::respawn_backend(
app.clone(),
state.stage.clone(),
state.logs.clone(),
);
}
}))
.plugin(tauri_plugin_positioner::init())
.invoke_handler(tauri::generate_handler![
bootstrap::bootstrap_status,
bootstrap::last_bootstrap_failure,
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::authorize_host_path,
commands::read_log_tail,
commands::hf_cache_scan,
commands::simulate_paste,
commands::simulate_type,
commands::check_accessibility,
commands::open_accessibility_settings,
commands::check_microphone,
commands::open_microphone_settings,
commands::open_input_monitoring_settings,
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::clear_webview_cache_and_relaunch,
crash::get_last_backend_crash,
crash::acknowledge_backend_crash,
uninstall::uninstall_scan,
uninstall::uninstall_purge,
reset::reset_scan,
reset::reset_purge,
blank_guard::report_render_state,
blank_guard::recover_main_window,
])
.setup(move |app| {
// Blank-window guard: watch the main window and, if nothing ever
// renders, reload and finally paint a built-in explanation rather
// than leaving the user with a dark rectangle (#1178 class).
blank_guard::arm(app.handle());
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.
// "main" is denylisted too (owner decision, 2026-07-02): the app
// must ALWAYS open maximized — not fullscreen — per
// tauri.conf.json (`maximized: true`, `fullscreen: false`).
// Persisting geometry meant one manual resize made every later
// launch reopen at that smaller size, overriding the config.
app.handle().plugin(
tauri_plugin_window_state::Builder::default()
.with_denylist(&["widget", "main"])
.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()
// Stamp the window's identity BEFORE any app script runs.
// main-app.jsx used to learn it from `getCurrentWindow().label`,
// which throws if `__TAURI_INTERNALS__` isn't injected yet; the
// catch then fell back to a URL query Tauri 2 cannot set, so the
// widget silently decided it was the main window. It then
// rendered <App/> instead of <CaptureWidget/>: no
// `data-window="widget"` (opaque chrome background), no pill,
// and — because the idle-hide reconcile lives in CaptureWidget —
// nothing left that could ever hide it. That is the dark
// rectangle stuck on the desktop until the app was killed.
// An init script cannot race: it is evaluated before page load.
.initialization_script("window.__OV_WINDOW__ = 'widget';")
.build();
if let Err(e) = &result {
log::error!("Failed to create widget window: {e:?}");
}
// Windows: mark the pill non-activating right away so it holds
// for every later `.show()` regardless of call site (#982).
#[cfg(target_os = "windows")]
if let Ok(win) = &result {
mark_pill_noactivate(win);
}
}
app.manage(AppFlags {
quitting: AtomicBool::new(false),
dictating: 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");
// The widget window is never shown (owner
// decision, 2026-08-07): it stays a hidden
// host for the recorder, and dictation gives
// no on-screen pill. See the window builder
// above for why it must still exist.
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 VoiceStudio")
.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 VoiceStudio")
.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 VoiceStudio")
.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 { "VoiceStudio Dictation" } else { "VoiceStudio" })
.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 start/stop. This used to ask the widget
// window whether it was visible; the widget is now a
// permanently hidden host, so visibility says nothing
// about whether we are recording. `dictating` is kept
// current by the frontend's existing
// `set_tray_recording` call on every start and stop.
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
let _ = app.emit("tray-dictate-stop", ());
} else {
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 + 'VoiceStudio
// 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 win.move_window(Position::BottomCenter).is_err() {
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();
}
// Enforce the always-open-maximized contract (#881) at
// runtime: macOS can ignore `maximized: true` from
// tauri.conf.json at window creation when combined with the
// Overlay title-bar style, so the config flag alone isn't
// reliable. maximize() zooms the window — it never enters a
// fullscreen Space. Guarded by tests/test_window_launch_state.py.
if let Some(main_win) = app.get_webview_window("main") {
if !main_win.is_maximized().unwrap_or(false) {
let _ = main_win.maximize();
}
}
}
// ── 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),
spawned_at: 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;
}
match backend::running_backend_version(backend_port()) {
Some(v) if backend::same_app_version(&v) => {
if backend::backend_deep_healthy(backend_port()) {
log::info!(
"Port {} already serving VoiceStudio backend v{} — attaching",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
// Same version but a DB-touching probe fails: a backend whose
// install was wiped/corrupted while it kept running. Attaching
// would look alive and 500 on everything — replace it.
log::warn!(
"Port {} serves VoiceStudio v{} but failed the deep health probe — replacing it",
backend_port(), v
);
backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
Some(v) => {
// Healthy-but-stale backend from a previous version —
// the post-update orphan that made new installs run
// old backend code. Replace it (see backend.rs
// same_app_version for the full story).
log::warn!(
"Port {} serves a stale VoiceStudio backend (v{} != app v{}) — replacing it",
backend_port(),
if v.is_empty() { "<unknown>" } else { v.as_str() },
env!("CARGO_PKG_VERSION"),
);
backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
None => {}
}
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 {
// Raise the quitting flag FIRST: exits that don't pass through the
// tray Quit item (macOS ⌘Q, OS session end) would otherwise let a
// death watcher observe our own SIGTERM below and record a false
// "backend crashed" marker (#941).
app_handle
.state::<AppFlags>()
.quitting
.store(true, Ordering::SeqCst);
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();
}
}
}
});
}