Corrective re-apply of Trust-1. The original commita2b47dbcarried the wrong staged file due to a parallel-agent race that swapped the staged path between `git add` and `git commit` — fs.rs was never actually changed bya2b47dbdespite the message. This commit applies the Trust-1 fix properly. The Tauri command `write_text_file_cmd` previously took an arbitrary `path: String` and flowed it straight into `tokio::fs::write` with no main-window guard, no canonicalisation, and no scope check. A compromised webview could write anywhere the process had write access — overwriting shell init files, dropping a runner into `~/.config/autostart`, etc. This change: - adds `ensure_main_window(&window)?` so only the main webview can invoke the command; - canonicalises the requested path's parent (rejecting nonexistent parents and resolving symlinks) before joining the filename; - asserts the canonical target sits inside an allowlisted base (app data, app local data, downloads, documents, desktop), so a `"../../etc/passwd"` payload — even one obtained via symlink trickery in the chosen save dir — is refused with a clear error. Six unit tests cover: outside-allowlist rejection, path-traversal rejection, nested inside-allowlist acceptance, plain inside-allowlist acceptance, nonexistent-parent rejection, and the pure `is_inside_any_base` prefix check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
776 lines
37 KiB
Rust
776 lines
37 KiB
Rust
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<WorkerGuard> = OnceLock::new();
|
|
|
|
/// Shared app state holding the transcription engines and database pool.
|
|
pub struct AppState {
|
|
pub whisper_engine: Arc<LocalEngine>,
|
|
pub parakeet_engine: Arc<LocalEngine>,
|
|
pub db: SqlitePool,
|
|
pub llm_engine: Arc<LlmEngine>,
|
|
/// 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<tokio::sync::Mutex<()>>,
|
|
}
|
|
|
|
/// 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>) -> 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<RollingFileAppender> {
|
|
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(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
|
}
|
|
|
|
/// 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<WorkerGuard> {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
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| {
|
|
// Tauri 2 keys both `app_data_dir` and the webview's data
|
|
// store (localStorage, IndexedDB, cookies, service worker
|
|
// storage, cache) plus all Tauri plugin state files
|
|
// (window-state geometry, autostart enable flag) by the
|
|
// bundle identifier. Commit `14313cf` renamed the bundle
|
|
// from `uk.co.corbel.magnotia` to `consulting.corbel.lumotia`,
|
|
// which would silently orphan every existing user's
|
|
// webview state under the new identifier. Migrate the
|
|
// legacy identifier-keyed app_data_dir first thing in
|
|
// setup, before the webview loads its first page and
|
|
// touches the on-disk store. Failure is logged but does
|
|
// NOT block startup: the hand-rolled `paths.rs` migration
|
|
// below covers the user's transcripts and models, so a
|
|
// total failure here only loses webview-keyed state.
|
|
//
|
|
// This is a separate concern from the
|
|
// `~/.local/share/magnotia` → `~/.local/share/lumotia`
|
|
// migration just below — that one lives under a
|
|
// hand-rolled path the app picks itself; this one lives
|
|
// under Tauri's identifier-keyed convention.
|
|
let t_app_data = Instant::now();
|
|
match app.path().app_data_dir() {
|
|
Ok(new_app_data) => {
|
|
use crate::tauri_app_data_migration::{
|
|
legacy_tauri_app_data_dir, migrate_tauri_app_data_dir_with_paths,
|
|
AppDataMigrationStatus,
|
|
};
|
|
if let Some(legacy) = legacy_tauri_app_data_dir() {
|
|
match migrate_tauri_app_data_dir_with_paths(&legacy, &new_app_data) {
|
|
AppDataMigrationStatus::Migrated { old, new } => {
|
|
tracing::info!(
|
|
target: "lumotia_startup",
|
|
elapsed_ms = t_app_data.elapsed().as_millis(),
|
|
old = %old.display(),
|
|
new = %new.display(),
|
|
"migrated Tauri app_data_dir from legacy bundle identifier; legacy preserved as backup"
|
|
);
|
|
}
|
|
AppDataMigrationStatus::BothExistLegacyPreserved { old, new } => {
|
|
tracing::warn!(
|
|
target: "lumotia_startup",
|
|
old = %old.display(),
|
|
new = %new.display(),
|
|
"both legacy and new Tauri app_data_dir exist; using new, legacy preserved"
|
|
);
|
|
}
|
|
AppDataMigrationStatus::NoLegacyFound => {}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "lumotia_startup",
|
|
error = %e,
|
|
"could not resolve Tauri app_data_dir; skipping bundle-identifier migration"
|
|
);
|
|
}
|
|
}
|
|
|
|
// One-shot legacy data-dir migration: rename ~/.local/share/magnotia
|
|
// (and macOS/Windows equivalents) to the convention-preserving
|
|
// lumotia path on first launch after the rebrand. Idempotent —
|
|
// safe to call on every boot. A migration error is fatal: silently
|
|
// continuing past it would orphan the user's transcripts and
|
|
// settings behind a fresh empty lumotia dir.
|
|
let t_migrate = Instant::now();
|
|
match migrate_legacy_data_dir() {
|
|
Ok(statuses) => {
|
|
// Drive every legacy candidate independently: on Linux a
|
|
// user may have both `~/.magnotia` and
|
|
// `~/.local/share/magnotia`, and migrating only one
|
|
// would orphan the other forever.
|
|
for status in &statuses {
|
|
match status {
|
|
MigrationStatus::Migrated { from, to, renamed_db } => {
|
|
tracing::info!(
|
|
target: "lumotia_startup",
|
|
elapsed_ms = t_migrate.elapsed().as_millis(),
|
|
from = %from.display(),
|
|
to = %to.display(),
|
|
renamed_db = *renamed_db,
|
|
"migrated legacy magnotia data dir to lumotia"
|
|
);
|
|
}
|
|
MigrationStatus::TargetAlreadyExists { .. } => {}
|
|
MigrationStatus::NoLegacyFound => {}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
target: "lumotia_startup",
|
|
error = %e,
|
|
"legacy data dir migration failed — refusing to start (would orphan user data)"
|
|
);
|
|
return Err(Box::new(e) as Box<dyn std::error::Error>);
|
|
}
|
|
}
|
|
|
|
// After migration, refuse to start if more than one lumotia
|
|
// target candidate exists on disk (e.g. both `~/.lumotia` AND
|
|
// `~/.local/share/lumotia`). Silently picking one would point
|
|
// the app at the wrong half of a split data directory.
|
|
if let Err(amb) = check_target_ambiguity() {
|
|
tracing::error!(
|
|
target: "lumotia_startup",
|
|
error = %amb,
|
|
"ambiguous lumotia data directory — refusing to start"
|
|
);
|
|
return Err(Box::new(amb) as Box<dyn std::error::Error>);
|
|
}
|
|
|
|
// 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<dyn std::error::Error>)?;
|
|
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<dyn std::error::Error>>((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::<UserMediaPermissionRequest>()
|
|
{
|
|
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,
|
|
// 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::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,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running Lumotia");
|
|
}
|