Files
Lumotia/src-tauri/src/lib.rs

319 lines
14 KiB
Rust

mod commands;
mod tray;
use std::sync::Arc;
use std::time::Instant;
use sqlx::SqlitePool;
use tauri::Manager;
use kon_core::types::EngineName;
use kon_llm::LlmEngine;
use kon_storage::{database_path, get_setting, init as init_db, set_setting};
use kon_transcription::LocalEngine;
/// 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>,
}
/// 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();
}
// Serialise the JSON string as a JS string literal for safe embedding
let js_str = serde_json::to_string(&json).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"(function() {{
try {{
var p = JSON.parse({js_str});
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); }}
}})();"#
)
}
/// 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, "kon_preferences", &preferences)
.await
.map_err(|e| e.to_string())
}
/// On Linux Wayland sessions (KDE, GNOME) WebKitGTK + DMABUF rendering hits
/// known crashes that the HANDOVER documents working around with a manual
/// env-var prefix:
///
/// env GDK_BACKEND=x11 WINIT_UNIX_BACKEND=x11 \
/// WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev
///
/// Detect the Wayland session at startup and apply the env vars before
/// anything else loads, so users do not need to remember the prefix and
/// the dev launcher / packaged app both work out of the box.
///
/// Inspired by Open-Whispr's similar Chromium self-relaunch (with
/// --ozone-platform=x11). Day 6 of the upgrade plan.
#[cfg(target_os = "linux")]
fn ensure_x11_on_wayland() {
// If the user explicitly opted in to Wayland (XDG_SESSION_TYPE set by
// their session, KDE / GNOME compositor flags), force WebKitGTK + GDK
// onto X11 via XWayland. Idempotent: if a value is already set, keep
// it (the user knows best).
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
let is_wayland = session_type.eq_ignore_ascii_case("wayland");
if !is_wayland {
return;
}
let set_if_unset = |key: &str, value: &str| {
if std::env::var_os(key).is_none() {
// SAFETY: setting env vars before any threads spawn (we are
// pre-Tauri-Builder here). This block is the only place these
// are written.
unsafe { std::env::set_var(key, value); }
eprintln!("[startup] Wayland workaround: {key}={value}");
}
};
set_if_unset("GDK_BACKEND", "x11");
set_if_unset("WINIT_UNIX_BACKEND", "x11");
set_if_unset("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
// PipeWire screen-capture portal still works fine through XWayland;
// we only redirect the GUI rendering path.
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
ensure_x11_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();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
// Initialise database (blocking in setup — runs once at startup)
let db_path = database_path();
let t0 = Instant::now();
let db = tauri::async_runtime::block_on(async {
init_db(&db_path).await
})
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
eprintln!("[startup] DB init: {:?}", t0.elapsed());
// Load saved preferences for webview injection
let t1 = Instant::now();
let prefs_json = tauri::async_runtime::block_on(async {
get_setting(&db, "kon_preferences").await.unwrap_or(None)
});
eprintln!("[startup] Preferences load: {:?}", t1.elapsed());
let init_script = build_preferences_script(prefs_json);
// 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::{
PermissionRequest, PermissionRequestExt,
SettingsExt, 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);
}
// Auto-grant all permission requests (audio/video capture)
WebViewExt::connect_permission_request(&wv, |_wv, request: &PermissionRequest| {
request.allow();
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.
eprintln!(
"[startup] failed to configure webview media permissions: {e}",
);
});
}
// Close-to-tray: hide window instead of exiting
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::audio::NativeCaptureState::new());
app.manage(commands::live::LiveTranscriptionState::default());
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()),
});
{
let whisper = app.state::<AppState>().whisper_engine.clone();
crate::commands::models::prewarm_default_model(whisper);
}
if let Err(e) = tray::setup(app) {
eprintln!("Failed to setup tray: {e}");
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Preferences
save_preferences,
// Whisper model management
commands::models::download_model,
commands::models::check_model,
commands::models::list_models,
commands::models::load_model,
commands::models::check_engine,
commands::models::get_runtime_capabilities,
// 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_pcm,
commands::transcription::transcribe_file,
commands::transcription::transcribe_pcm_parakeet,
// Audio
commands::audio::save_audio,
commands::audio::start_native_capture,
commands::audio::stop_native_capture,
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::decompose_and_store,
commands::tasks::list_subtasks_cmd,
commands::tasks::complete_subtask_cmd,
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
commands::profiles::list_profiles_cmd,
commands::profiles::get_profile_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::delete_profile_term_cmd,
// Transcripts (canonical SQLite-backed history) — Day 4
commands::transcripts::add_transcript,
commands::transcripts::list_transcripts,
commands::transcripts::count_transcripts_command,
commands::transcripts::get_transcript,
commands::transcripts::update_transcript,
commands::transcripts::update_transcript_meta_cmd,
commands::transcripts::delete_transcript,
commands::transcripts::search_transcripts,
commands::transcripts::list_dictionary_command,
commands::transcripts::add_dictionary_entry_command,
commands::transcripts::delete_dictionary_entry_command,
// 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,
// Clipboard
commands::clipboard::copy_to_clipboard,
// 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,
commands::update::install_update,
])
.run(tauri::generate_context!())
.expect("error while running Kon");
}