Dictation via the global shortcut transcribed fine but the text never reached the target app on macOS, due to two stacked bugs (diagnosed, patched, and verified by @geektf in #287): 1. The ShortcutState::Pressed handler called win.set_focus(), making the widget frontmost — the simulated ⌘V from simulate_paste() landed in the widget instead of the app being dictated into. Skip set_focus() on macOS (same #[cfg(not(target_os = "macos"))] guard the other widget call sites already use). 2. With the widget unfocused, the WebView clipboard APIs (navigator.clipboard.writeText / execCommand('copy')) fail silently in WKWebView, so ⌘V pasted whatever was previously on the clipboard. simulate_paste now takes Option<String> and writes the transcript to the clipboard natively (arboard) before sending the keystroke — no window focus required. CaptureWidget passes the transcript; copyText() stays as best-effort for browser (non-Tauri) mode, and the optional param keeps any text-less call sites working. cargo check clean (the unreachable_code warning in setup.rs is pre-existing from #286); frontend node:test suite passes. End-to-end behavior verified by the reporter on macOS 26 / M4 Pro with both patches applied. Fixes #287 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
676 lines
33 KiB
Rust
676 lines
33 KiB
Rust
//! 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");
|
||
|
||
// ── 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::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();
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
// `--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 installs (venv present) are
|
||
// detected inside is_first_run and migrate straight through.
|
||
if setup::is_first_run(&app_handle) {
|
||
log::info!("First run — awaiting setup screen confirmation before installing");
|
||
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
|
||
return;
|
||
}
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|