mod commands; mod tauri_app_data_migration; // System tray uses Tauri's `tray-icon` feature which is desktop-only. // Android has no tray surface — drop the module entirely on that target. #[cfg(not(target_os = "android"))] mod tray; use std::path::Path; use std::sync::Arc; use std::sync::{Once, OnceLock}; use std::time::Instant; use sqlx::SqlitePool; use tauri::Manager; use tracing_appender::non_blocking::WorkerGuard; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, Layer}; use lumotia_core::paths::{check_target_ambiguity, migrate_legacy_data_dir, MigrationStatus}; use lumotia_core::types::EngineName; use lumotia_llm::LlmEngine; use lumotia_storage::{ database_path, get_setting, init as init_db, migrate_legacy_setting_keys, prune_error_log, purge_deleted_transcripts, set_setting, }; /// How long to retain `error_log` rows. Pruned once on startup. /// 90 days is long enough to triage "this happened a few weeks ago" /// reports and short enough that the table stays kilobyte-scale. const ERROR_LOG_RETENTION_DAYS: i64 = 90; /// How long soft-deleted transcripts live in the trash before /// `purge_deleted_transcripts` hard-removes them on startup. 30 days /// gives the user a comfortable undo window without keeping months of /// abandoned audio on disk. const TRANSCRIPT_TRASH_RETENTION_DAYS: i64 = 30; use lumotia_transcription::LocalEngine; static TRACING_INIT: Once = Once::new(); /// Parks the non-blocking file-appender's `WorkerGuard` for the process /// lifetime. Dropping the guard halts the background log-writing thread /// and discards any pending log lines, so it must outlive every /// `tracing::*!` call. A `OnceLock` here gives us a free `'static` /// home and matches the `Once`-guarded init pattern above. static FILE_APPENDER_GUARD: OnceLock = OnceLock::new(); /// Shared app state holding the transcription engines and database pool. pub struct AppState { pub whisper_engine: Arc, pub parakeet_engine: Arc, pub db: SqlitePool, pub llm_engine: Arc, /// Race-4/5 TOCTOU guard: two concurrent `ensure_model_loaded` /// callers (e.g. a double-clicked "Test" + "Transcribe" button) /// would both observe `loaded_model_id != requested`, both decide /// "not loaded — load it", and both spawn a multi-second /// `load_model_from_disk` whose result the second overwrites. /// Holding this `tokio::sync::Mutex` across the check-then-load /// critical section forces them to serialise; the second caller /// acquires the lock after the first finishes, observes the /// now-loaded state, and short-circuits. pub load_serialise: Arc>, } /// Holds the preferences init script for injection into secondary windows. pub struct PreferencesScript(pub String); /// Build a JavaScript snippet that applies saved preferences to the DOM /// before Svelte mounts, preventing any flash of unstyled content. fn build_preferences_script(prefs_json: Option) -> String { let json = prefs_json.unwrap_or_default(); if json.is_empty() { return String::new(); } let js_value: serde_json::Value = match serde_json::from_str(&json) { Ok(value) => value, Err(_) => return String::new(), }; let js_obj = serde_json::to_string(&js_value).unwrap_or_else(|_| "{}".to_string()); format!( r#"(function() {{ try {{ var p = {js_obj}; var el = document.documentElement; if (p.theme === 'system') {{ el.dataset.theme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; }} else if (p.theme) {{ el.dataset.theme = p.theme; }} if (p.zone && p.zone !== 'default') el.dataset.zone = p.zone; if (p.accessibility) {{ var a = p.accessibility; var fonts = {{ lexend: "'Lexend', system-ui, sans-serif", atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif", opendyslexic: "'OpenDyslexic', system-ui, sans-serif" }}; if (a.fontFamily) {{ el.style.setProperty('--font-family-body', fonts[a.fontFamily] || fonts.lexend); el.dataset.fontFamily = a.fontFamily; }} if (a.fontSize) el.style.setProperty('--font-size-body', a.fontSize + 'px'); if (a.letterSpacing != null) el.style.setProperty('--letter-spacing-body', a.letterSpacing + 'em'); if (a.lineHeight) el.style.setProperty('--line-height-body', String(a.lineHeight)); if (a.transcriptSize) el.style.setProperty('--text-transcript', a.transcriptSize + 'px'); if (a.bionicReading) el.dataset.bionicReading = 'true'; if (a.reduceMotion === 'on' || (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) el.dataset.reduceMotion = 'true'; }} }} catch (e) {{ console.warn('Preferences injection failed:', e); }} }})();"# ) } #[cfg(test)] mod tests { use super::build_preferences_script; #[test] fn preferences_script_injects_object_without_redundant_json_parse() { let script = build_preferences_script(Some(r#"{"theme":"dark"}"#.to_string())); assert!(script.contains("var p = {\"theme\":\"dark\"};")); assert!(!script.contains("JSON.parse")); } #[test] fn preferences_script_rejects_malformed_json() { assert!(build_preferences_script(Some("not json".to_string())).is_empty()); } } /// Save preferences JSON to the SQLite settings table. #[tauri::command] async fn save_preferences( state: tauri::State<'_, AppState>, preferences: String, ) -> Result<(), String> { set_setting(&state.db, "lumotia_preferences", &preferences) .await .map_err(|e| e.to_string()) } /// On Linux Wayland sessions (KDE, GNOME) WebKitGTK + DMABUF rendering hits /// known crashes that the dev launcher works around by setting rendering /// env vars before WebKitGTK/WINIT initialise. In development, launch via: /// /// ```sh /// npm run dev:tauri /// ``` /// /// Detect the Wayland session at startup and warn when the process was not /// launched with the safe Linux rendering env vars. Rust 2024 marks runtime /// environment mutation unsafe in multi-threaded programs, so the app must be /// launched with these values by `run.sh`, the desktop file, or a package /// wrapper rather than patching them here. /// /// Inspired by Open-Whispr's similar Chromium self-relaunch (with /// --ozone-platform=x11). Day 6 of the upgrade plan. #[cfg(target_os = "linux")] fn warn_if_x11_env_unset_on_wayland() { let warn_if_unset = |key: &str, value: &str, why: &str| { if std::env::var_os(key).is_none() { tracing::warn!( target: "lumotia_startup", key, expected = value, why, "Linux rendering workaround env var is not set; configure the launcher instead of mutating process env at runtime" ); } }; // Always-on Linux: disable WebKit's DMA-BUF renderer. On RADV / Renoir // class iGPUs and a number of NVIDIA driver stacks, the DMA-BUF path // burns ~10pp idle GPU and ~10pp idle CPU compared to the legacy // renderer for no visible quality difference. Empirically (env-var // matrix on Ryzen 5 4650U / Vega 6, May 2026) toggling this dropped // lumotia idle CPU 12.30% → 2.80% of one core and idle GPU 17% → 10%. // Applies to both X11 and Wayland sessions; users can opt back in by // setting WEBKIT_DISABLE_DMABUF_RENDERER=0 explicitly. warn_if_unset( "WEBKIT_DISABLE_DMABUF_RENDERER", "1", "iGPU idle-cost workaround", ); // If the user is on a Wayland session, also force WebKitGTK + GDK // onto X11 via XWayland. Idempotent: if a value is already set, keep // it (the user knows best). PipeWire screen-capture portal still // works fine through XWayland; we only redirect the GUI rendering // path. let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default(); if session_type.eq_ignore_ascii_case("wayland") { warn_if_unset("GDK_BACKEND", "x11", "Wayland XWayland fallback"); warn_if_unset("WINIT_UNIX_BACKEND", "x11", "Wayland XWayland fallback"); } } /// Default `EnvFilter` string for the stderr (developer-facing) layer. /// /// `lumotia_lib::commands::live=info` is listed explicitly so that the /// operator's documented triage filter (per /// docs/superpowers/audits/2026-05-10-phase10a-dogfood-notes.md) works /// without a manual `RUST_LOG=` override on every dogfooding session. const DEFAULT_STDERR_FILTER: &str = "warn,lumotia=info,lumotia_lib=info,lumotia_lib::commands::live=info,lumotia_core=info,lumotia_audio=info,lumotia_hotkey=info,lumotia_ai_formatting=info,lumotia_llm=info,lumotia_storage=info,lumotia_transcription=info,lumotia_startup=info"; /// Default `EnvFilter` string for the rolling-file (forensic) layer. /// /// More verbose than stderr by design: the file is what users attach to /// diagnostic reports, so DEBUG-level Lumotia internals are worth the /// disk cost. Third-party crates that spam at DEBUG (`sqlx`, `hyper`, /// `reqwest`, `h2`, `rustls`, `tokio_util`) are pinned at INFO so they /// don't drown out our own log lines. const DEFAULT_FILE_FILTER: &str = "info,lumotia=debug,lumotia_lib=debug,lumotia_lib::commands::live=debug,lumotia_core=debug,lumotia_audio=debug,lumotia_hotkey=debug,lumotia_ai_formatting=debug,lumotia_llm=debug,lumotia_storage=debug,lumotia_transcription=debug,lumotia_startup=debug,sqlx=info,hyper=info,reqwest=info,h2=info,rustls=info,tokio_util=info"; /// Build the stderr-side `EnvFilter`, honouring `RUST_LOG` if set. fn stderr_env_filter() -> EnvFilter { EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_STDERR_FILTER)) } /// Build the file-side `EnvFilter`, honouring `LUMOTIA_LOG_FILE` if set /// (falling back to `RUST_LOG`, then the verbose default). fn file_env_filter() -> EnvFilter { EnvFilter::try_from_env("LUMOTIA_LOG_FILE") .or_else(|_| EnvFilter::try_from_default_env()) .unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILE_FILTER)) } /// Build the rolling daily appender for `lumotia.log` inside `logs_dir`. /// /// Rotation policy: one file per day, keep 7 most-recent days. Older /// files are pruned by `tracing-appender` itself. The base filename is /// `lumotia.log`; rotated files get a date suffix (e.g. /// `lumotia.log.2026-05-12`). fn build_rolling_appender(logs_dir: &Path) -> std::io::Result { std::fs::create_dir_all(logs_dir)?; RollingFileAppender::builder() .rotation(Rotation::DAILY) .filename_prefix("lumotia") .filename_suffix("log") .max_log_files(7) .build(logs_dir) .map_err(std::io::Error::other) } /// Install a `tracing` subscriber that writes to stderr (developer /// stream, ANSI-coloured) AND to a rolling daily file at /// `logs_dir/lumotia.log` (forensic stream, plain text). The file /// stream is what diagnostic-report bundles attach. /// /// Returns the `WorkerGuard` for the file appender so the caller can /// park it for the process lifetime; dropping it discards pending logs. /// Returns `None` if the file appender could not be built (e.g. /// permissions); in that case only the stderr layer is installed. /// /// Exposed `pub` so the `tracing_appender_smoke` integration test can /// drive it against a tempdir; production code calls it via /// `init_tracing` against the platform-default `logs_dir()`. pub fn install_subscriber(logs_dir: &Path) -> Option { let stderr_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stderr) .with_filter(stderr_env_filter()); match build_rolling_appender(logs_dir) { Ok(appender) => { let (non_blocking, guard) = tracing_appender::non_blocking(appender); let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_ansi(false) .with_filter(file_env_filter()); let _ = tracing_subscriber::registry() .with(stderr_layer) .with(file_layer) .try_init(); Some(guard) } Err(e) => { // Best-effort: keep stderr logging even if the file path is // unwritable, and surface the failure to stderr so dogfooders // notice the missing forensic stream. let _ = tracing_subscriber::registry().with(stderr_layer).try_init(); eprintln!( "lumotia: failed to install rolling file log appender at {}: {e}", logs_dir.display() ); None } } } fn init_tracing() { TRACING_INIT.call_once(|| { let logs_dir = lumotia_storage::file_storage::logs_dir(); if let Some(guard) = install_subscriber(&logs_dir) { // Park the guard for the process lifetime. set() only fails // if already set (impossible inside Once::call_once), so we // ignore the result. let _ = FILE_APPENDER_GUARD.set(guard); } }); } /// One-shot data migration that runs BEFORE anything else in `run()`. /// /// CRITICAL ORDERING: this must come before `init_tracing`, /// `install_panic_hook`, AND `tauri::Builder::default()`. Each of those /// either calls `create_dir_all` on a child of `app_data_dir()` or causes /// Tauri/WebKitGTK to create its own bundle-identifier-keyed dir for /// plugin state. Either way, by the time the migration would otherwise /// run from inside the Tauri setup hook, the destination already exists /// and the migration short-circuits via `TargetAlreadyExists` / /// `BothExistLegacyPreserved` — silently leaving the user's legacy /// Magnotia data orphaned next to a fresh empty Lumotia install. The /// `scripts/dogfood-rebrand-drill.sh` integration probe is what surfaced /// this race; see commit history for the original buggy ordering. /// /// Tracing isn't initialised yet, so migration outcomes are written to /// stderr via `eprintln!`. The format mirrors the structured fields the /// setup-hook tracing layer would have emitted — same content, different /// transport. systemd-journald + a foreground terminal both capture /// stderr, which is the audit surface that matters at boot. /// /// Fatal failures (data-dir migration error, ambiguous lumotia paths on /// disk) call `process::exit(1)` rather than panicking. We refuse to /// start with the wrong path resolved — silently continuing would /// orphan user data, which is the failure mode this fix is closing. fn migrate_user_data_pre_runtime() { // 1. Hand-rolled data-dir migration: ~/.local/share/magnotia (and // macOS / Windows equivalents) -> ~/.local/share/lumotia. Drives // every legacy candidate independently so multi-legacy Linux // users (`~/.magnotia` AND `~/.local/share/magnotia` from // different historical builds) get all of them migrated, not // just the first one probed. let t = std::time::Instant::now(); match migrate_legacy_data_dir() { Ok(statuses) => { for status in &statuses { match status { MigrationStatus::Migrated { from, to, renamed_db, } => { eprintln!( "[lumotia-startup] migrated legacy magnotia data dir to lumotia: \ {from} -> {to} (renamed_db={renamed_db}, elapsed_ms={ms})", from = from.display(), to = to.display(), renamed_db = *renamed_db, ms = t.elapsed().as_millis(), ); } MigrationStatus::TargetAlreadyExists { .. } | MigrationStatus::NoLegacyFound => { // Steady state on the second-or-later boot. Chatty // logging here would dwarf the genuine first-boot // event, so we stay silent. } } } } Err(e) => { eprintln!( "[lumotia-startup] FATAL: legacy data dir migration failed — refusing \ to start (would orphan user data): {e}" ); std::process::exit(1); } } // 2. Tauri app_data_dir migration: ~/.local/share/uk.co.corbel.magnotia // -> ~/.local/share/consulting.corbel.lumotia. Copy-via-staging so // a half-written destination cannot appear on disk. Legacy is // preserved as a backup. use crate::tauri_app_data_migration::{ current_tauri_app_data_dir, legacy_tauri_app_data_dir, migrate_tauri_app_data_dir_with_paths, AppDataMigrationStatus, }; let t = std::time::Instant::now(); if let (Some(legacy), Some(current)) = (legacy_tauri_app_data_dir(), current_tauri_app_data_dir()) { match migrate_tauri_app_data_dir_with_paths(&legacy, ¤t) { AppDataMigrationStatus::Migrated { old, new } => { eprintln!( "[lumotia-startup] migrated Tauri app_data_dir from legacy bundle \ identifier: {old} -> {new} (elapsed_ms={ms})", old = old.display(), new = new.display(), ms = t.elapsed().as_millis(), ); } AppDataMigrationStatus::BothExistLegacyPreserved { old, new } => { // Reachable on the second-or-later boot OR if the user // ran a side-by-side install. Either is benign — we // preserve legacy, use new — so log at INFO not WARN. eprintln!( "[lumotia-startup] Tauri app_data_dir present at new path; legacy \ preserved: old={old} new={new}", old = old.display(), new = new.display(), ); } AppDataMigrationStatus::NoLegacyFound => {} } } // 3. Ambiguity guard. After migrations, if more than one lumotia // target candidate exists on disk (`~/.lumotia` AND // `~/.local/share/lumotia` from split runs), refuse to start // rather than silently pick one. The user must consolidate // manually. if let Err(amb) = check_target_ambiguity() { eprintln!( "[lumotia-startup] FATAL: ambiguous lumotia data directory — refusing to \ start: {amb}" ); std::process::exit(1); } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Step 1: migrate legacy magnotia data BEFORE anything else touches // the lumotia data dir. init_tracing, install_panic_hook, and // tauri::Builder::default() all lazily create directories under // app_data_dir on first use; if any of those run before migration, // every migrate_one() probe returns TargetAlreadyExists / both-exist // and the legacy data is silently orphaned. migrate_user_data_pre_runtime(); // Step 2: structured logging on the (now correctly migrated) logs dir. init_tracing(); #[cfg(target_os = "linux")] warn_if_x11_env_unset_on_wayland(); // Capture Rust panics to disk so the diagnostic-report bundler in // Settings → About can attach them. Local only; nothing transmitted. commands::diagnostics::install_panic_hook(); let builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) // Phase 6 nudges: OS-native notifications. The plugin exposes // isPermissionGranted / requestPermission / sendNotification on // the JS side; our deliver_nudge wrapper guards those calls // against the nudge-bus suppression rules and restricts // invocation to the main window via ensure_main_window. .plugin(tauri_plugin_notification::init()); // Desktop-only plugins. Each is either unsupported on Android // (global-shortcut, autostart) or structurally meaningless on a // single-window mobile app (window-state). Gating here matches the // Cargo.toml `cfg(not(target_os = "android"))` block that drops the // crates entirely on Android targets. #[cfg(not(target_os = "android"))] let builder = builder .plugin(tauri_plugin_global_shortcut::Builder::new().build()) // Phase 5 rituals: autostart. The plugin registers JS-facing // commands (isEnabled / enable / disable) that the Settings // toggle and first-run prompt invoke directly — no bespoke // Rust commands needed. `LaunchAgent` is the non-root macOS // install path (per-user, no sudo). .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, )) // Remember size + position of every window across app restarts. // Without this, secondary windows (preview overlay, task float, // transcript viewer) open at whatever spot the compositor picks, // which feels random. State is persisted per-window-label to // app-data/window-state.json. .plugin(tauri_plugin_window_state::Builder::default().build()); builder .setup(|app| { // Data migrations + ambiguity guard ran in // `migrate_user_data_pre_runtime()` BEFORE tauri::Builder // was constructed — see the doc comment on that function // for why setup-hook timing is too late. By the time we get // here every app_data_dir path is the post-rebrand one and // any legacy magnotia state has either been migrated or // preserved as a backup. let _ = app; // Initialise database and startup settings in one runtime entry. let db_path = database_path(); let (db, init_script) = tauri::async_runtime::block_on(async { let t0 = Instant::now(); let db = init_db(&db_path) .await .map_err(|e| Box::new(e) as Box)?; tracing::info!(target: "lumotia_startup", elapsed_ms = t0.elapsed().as_millis(), "DB init complete"); // One-shot settings-key migration: rename any leftover // `magnotia_*` rows from the magnotia era to `lumotia_*`. // Deletes any magnotia_ rows that are orphans of an existing // lumotia_ row. Idempotent; logs only when rows actually move. let t_keys = Instant::now(); match migrate_legacy_setting_keys(&db).await { Ok((0, 0)) => {} Ok((renamed, orphans_deleted)) => tracing::info!( target: "lumotia_startup", rows_renamed = renamed, orphans_deleted = orphans_deleted, elapsed_ms = t_keys.elapsed().as_millis(), "migrated legacy magnotia_* settings keys to lumotia_*" ), Err(e) => tracing::warn!( target: "lumotia_startup", error = %e, "settings-key migration failed — continuing" ), } // Prune old `error_log` rows so the table doesn't grow unbounded // across months of dogfooding. Best-effort — a prune failure is // not worth blocking startup over. let t_prune = Instant::now(); match prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await { Ok(n) if n > 0 => tracing::info!( target: "lumotia_startup", rows_removed = n, retention_days = ERROR_LOG_RETENTION_DAYS, elapsed_ms = t_prune.elapsed().as_millis(), "error log prune complete" ), Ok(_) => {} Err(e) => tracing::warn!(target: "lumotia_startup", error = %e, "error log prune failed"), } // Hard-delete soft-deleted transcripts past the retention // window (Rev-2 atomiser fix). Best-effort — a purge failure // means trash rows stick around an extra day, not a blocker. let t_purge_trash = Instant::now(); match purge_deleted_transcripts(&db, TRANSCRIPT_TRASH_RETENTION_DAYS).await { Ok(n) if n > 0 => tracing::info!( target: "lumotia_startup", rows_removed = n, retention_days = TRANSCRIPT_TRASH_RETENTION_DAYS, elapsed_ms = t_purge_trash.elapsed().as_millis(), "transcript trash purge complete" ), Ok(_) => {} Err(e) => tracing::warn!( target: "lumotia_startup", error = %e, "transcript trash purge failed — trash rows remain for next startup" ), } // Load saved preferences for webview injection. let t1 = Instant::now(); let prefs_json = get_setting(&db, "lumotia_preferences").await.unwrap_or(None); tracing::info!(target: "lumotia_startup", elapsed_ms = t1.elapsed().as_millis(), "preferences load complete"); let init_script = build_preferences_script(prefs_json); Ok::<_, Box>((db, init_script)) })?; // Apply preferences to the main window (defined in tauri.conf.json) if let Some(main_window) = app.get_webview_window("main") { if !init_script.is_empty() { // Tauri's WebviewWindow.eval() is the standard API for // running JS in a webview — not the JS eval() function let _ = main_window.eval(&init_script); } // Auto-grant microphone permission on Linux (WebKitGTK). // WebKitGTK doesn't show a permission dialog — it just denies // getUserMedia by default. We connect to the permission-request // signal and grant audio capture requests automatically. #[cfg(target_os = "linux")] { main_window .with_webview(|webview| { use webkit2gtk::glib::prelude::Cast; use webkit2gtk::{ PermissionRequest, PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, UserMediaPermissionRequestExt, WebViewExt, }; let wv: webkit2gtk::WebView = webview.inner().clone(); // Enable media stream in WebKit settings if let Some(settings) = WebViewExt::settings(&wv) { settings.set_enable_media_stream(true); settings.set_enable_media_capabilities(true); } tracing::warn!( target: "lumotia_startup", "Linux WebKitGTK microphone permission requests are auto-granted for audio-only capture; other permission classes remain denied" ); // Auto-grant microphone capture only. Other WebKitGTK // permission requests are denied so future surfaces do // not inherit camera/geolocation/pointer-lock access. WebViewExt::connect_permission_request( &wv, |_wv, request: &PermissionRequest| { if let Ok(media) = request.clone().downcast::() { if media.is_for_audio_device() && !media.is_for_video_device() { request.allow(); } else { request.deny(); } } else { request.deny(); } true }, ); }) .unwrap_or_else(|e| { // Non-fatal: WebKitGTK may already have media // capture wired by some compositors, or the // signal binding may fail on unusual builds. // Falling back means getUserMedia() prompts (or // silently denies) instead of auto-granting, // which is degraded but recoverable. tracing::warn!( target: "lumotia_startup", error = %e, "failed to configure webview media permissions" ); }); } // Close-to-tray: hide the window instead of exiting so the // tray icon stays as the canonical entry point. Desktop-only; // mobile has no tray and "hide on close" maps to the // platform's own background-app behaviour. #[cfg(not(target_os = "android"))] { let win = main_window.clone(); main_window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = win.hide(); } }); } } // Store init script for secondary windows (float, viewer) app.manage(PreferencesScript(init_script)); app.manage(commands::hotkey::HotkeyState::new()); app.manage(commands::live::LiveTranscriptionState::default()); app.manage(commands::tts::TtsState::new()); app.manage(commands::meeting::MeetingState::new()); app.manage(AppState { whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))), parakeet_engine: Arc::new(LocalEngine::new(EngineName::new("parakeet"))), db, llm_engine: Arc::new(LlmEngine::new()), load_serialise: Arc::new(tokio::sync::Mutex::new(())), }); // Runtime-warning banner: push CPU-feature + Vulkan-loader // fallbacks to the frontend so Settings can render a one-line // hint. No-ops on a fully-supported box. crate::commands::models::emit_runtime_warnings(app.handle()); #[cfg(not(target_os = "android"))] if let Err(e) = tray::setup(app) { tracing::warn!(target: "lumotia_startup", error = %e, "failed to setup tray"); } Ok(()) }) .invoke_handler(tauri::generate_handler![ // Preferences save_preferences, // Whisper model management commands::models::download_model, commands::models::list_models, commands::models::load_model, commands::models::prewarm_default_model_cmd, commands::models::check_engine, commands::models::get_runtime_capabilities, // Local LLM management commands::llm::recommend_llm_tier, commands::llm::check_llm_model, commands::llm::download_llm_model, commands::llm::load_llm_model, commands::llm::unload_llm_model, commands::llm::delete_llm_model, commands::llm::get_llm_status, commands::llm::test_llm_model, commands::llm::cleanup_transcript_text_cmd, // Parakeet model management commands::models::download_parakeet_model, commands::models::check_parakeet_model, commands::models::list_parakeet_models, commands::models::load_parakeet_model, commands::models::check_parakeet_engine, // Transcription commands::transcription::transcribe_file, // Audio commands::audio::list_audio_devices, // Tasks (canonical SQLite-backed task CRUD) commands::tasks::create_task_cmd, commands::tasks::list_tasks_cmd, commands::tasks::update_task_cmd, commands::tasks::complete_task_cmd, commands::tasks::delete_task_cmd, commands::tasks::uncomplete_task_cmd, commands::tasks::set_task_energy_cmd, commands::tasks::decompose_and_store, commands::tasks::extract_tasks_from_transcript_cmd, commands::tasks::list_subtasks_cmd, commands::tasks::complete_subtask_cmd, commands::tasks::list_recent_completions_cmd, // HITL feedback (Phase 2 roadmap) commands::feedback::record_feedback, // Read aloud (Phase 4 roadmap) commands::tts::tts_speak, commands::tts::tts_stop, commands::tts::tts_list_voices, // Rituals (Phase 5 roadmap) commands::rituals::get_last_morning_triage, commands::rituals::mark_morning_triage_shown, // Nudges (Phase 6 roadmap) commands::nudges::deliver_nudge, // Onboarding flow + opt-in activation log commands::onboarding::record_onboarding_event, commands::onboarding::list_onboarding_events, commands::onboarding::has_completed_onboarding, commands::onboarding::record_lumotia_event, commands::onboarding::list_lumotia_events, commands::onboarding::clear_lumotia_events, // Implementation intentions (Phase 7 roadmap) commands::intentions::list_implementation_rules, commands::intentions::create_implementation_rule, commands::intentions::set_implementation_rule_enabled, commands::intentions::mark_implementation_rule_fired, commands::intentions::delete_implementation_rule, // Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12 commands::profiles::list_profiles_cmd, commands::profiles::create_profile_cmd, commands::profiles::update_profile_cmd, commands::profiles::delete_profile_cmd, commands::profiles::list_profile_terms_cmd, commands::profiles::add_profile_term_cmd, commands::profiles::learn_profile_terms_from_edit_cmd, commands::profiles::delete_profile_term_cmd, // Transcripts (canonical SQLite-backed history) — Day 4 commands::transcripts::add_transcript, commands::transcripts::list_transcripts, commands::transcripts::get_transcript, commands::transcripts::update_transcript, commands::transcripts::update_transcript_meta_cmd, commands::transcripts::delete_transcript, commands::transcripts::list_trashed_transcripts, commands::transcripts::restore_transcript, commands::transcripts::search_transcripts, // Diagnostics (panic + error capture, manual report bundle) commands::diagnostics::log_frontend_error, commands::diagnostics::list_recent_errors_command, commands::diagnostics::list_crash_files, commands::diagnostics::generate_diagnostic_report, commands::diagnostics::save_diagnostic_report, commands::diagnostics::get_os_info, commands::diagnostics::generate_diagnostic_bundle, commands::live::start_live_transcription_session, commands::live::stop_live_transcription_session, // Windows commands::windows::open_task_window, commands::windows::open_viewer_window, commands::windows::open_preview_window, // Clipboard commands::clipboard::copy_to_clipboard, // Filesystem (Phase 9 save-dialog path) commands::fs::write_text_file_cmd, // LLM content tags (Phase 9) commands::llm::extract_content_tags_cmd, // Paste (auto-insert at cursor) commands::paste::paste_text, commands::paste::paste_text_replacing, commands::paste::detect_paste_backends, // Meeting auto-capture (process-list poll) commands::meeting::detect_meeting_processes, // Hardware commands::hardware::probe_system, commands::hardware::rank_models, // Hotkey (evdev — Wayland-compatible) commands::hotkey::is_wayland_session, commands::hotkey::check_hotkey_access, commands::hotkey::start_evdev_hotkey, commands::hotkey::update_evdev_hotkey, commands::hotkey::stop_evdev_hotkey, // Updater commands::update::check_for_update, // Power (KI-02 Linux idle inhibit, KI-03 Windows sleep prevention) commands::power::acquire_idle_inhibit, commands::power::release_idle_inhibit, ]) .run(tauri::generate_context!()) .expect("error while running Lumotia"); }