The error_log table had no retention policy: every backend error was
appended forever, so across months of dogfooding it grew unbounded. That
silently bloats the diagnostic-bundle export and slows the
list_recent_errors query the Settings → About panel runs.
- New `kon_storage::prune_error_log(pool, keep_days)` does a single
`DELETE FROM error_log WHERE timestamp < datetime('now', '-Nd days')`
and returns the row count removed.
- src-tauri/src/lib.rs runs it once during setup() with a const
ERROR_LOG_RETENTION_DAYS = 90. Failure is logged to stderr but does not
block startup — a prune that fails is strictly less important than the
app coming up.
- Test: insert three rows at now / -30d / -200d, verify a 90-day prune
removes only the oldest, and a subsequent 14-day prune removes the
-30d row. Storage suite at 60/60.
https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
424 lines
19 KiB
Rust
424 lines
19 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, prune_error_log, 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;
|
|
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:
|
|
///
|
|
/// ```sh
|
|
/// 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())
|
|
// 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,
|
|
))
|
|
// 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())
|
|
// 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())
|
|
.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());
|
|
|
|
// 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();
|
|
let pruned = tauri::async_runtime::block_on(async {
|
|
prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await
|
|
});
|
|
match pruned {
|
|
Ok(n) if n > 0 => eprintln!(
|
|
"[startup] Error log prune: {n} rows removed (>{ERROR_LOG_RETENTION_DAYS}d) in {:?}",
|
|
t_prune.elapsed()
|
|
),
|
|
Ok(_) => {}
|
|
Err(e) => eprintln!("[startup] Error log prune failed: {e}"),
|
|
}
|
|
|
|
// 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::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);
|
|
}
|
|
|
|
// 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.
|
|
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(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()),
|
|
});
|
|
|
|
// 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());
|
|
|
|
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::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_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::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,
|
|
commands::feedback::list_feedback_examples_cmd,
|
|
// 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::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::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::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,
|
|
// 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,
|
|
commands::windows::close_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,
|
|
commands::update::install_update,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running Kon");
|
|
}
|