//! Diagnostics: panic hook, frontend error capture, and the manual //! diagnostic-report bundler used by Settings → About. //! //! Privacy posture (matches Kon's local-first positioning): //! - All capture is to disk only. Nothing is transmitted. //! - The manual report bundler shows the user exactly what would be //! shared and lets them choose to copy/save/email it. //! - No remote endpoint, no Sentry, no opt-out telemetry. use std::fs; use std::io::Write; use std::panic; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use kon_storage::{ app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow, }; use crate::commands::power::active_assertions_snapshot; use crate::commands::security::ensure_main_window; use crate::AppState; const DEFAULT_RECENT_ERRORS: i64 = 50; const KON_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Install the Rust panic hook. Writes each panic to a separate file in /// crashes_dir so the diagnostic-report bundler can attach them. Also /// keeps the default panic message on stderr so terminal users see it. /// /// Called once at startup before tauri::Builder. pub fn install_panic_hook() { if let Err(e) = fs::create_dir_all(crashes_dir()) { eprintln!("[diagnostics] could not create crashes dir: {e}"); return; } let default_hook = panic::take_hook(); panic::set_hook(Box::new(move |info| { // Write a minimal text dump. Backtraces require RUST_BACKTRACE=1 // and the std::backtrace API; we capture what's free. let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let short = (ts % 0xFFFF) as u16; let path = crashes_dir().join(format!("{ts}-{short:04x}.crash")); let payload = format!( "Kon crash dump\n\ ==============\n\ Version: {ver}\n\ Timestamp: {ts} (UTC seconds)\n\ Thread: {thread}\n\ \n\ Panic message:\n\ {info}\n\ \n\ OS: {os} {arch}\n\ RUST_BACKTRACE: {bt}\n", ver = KON_VERSION, ts = ts, thread = std::thread::current().name().unwrap_or(""), info = info, os = std::env::consts::OS, arch = std::env::consts::ARCH, bt = std::env::var("RUST_BACKTRACE").unwrap_or_else(|_| "0".to_string()), ); if let Ok(mut f) = fs::File::create(&path) { let _ = f.write_all(payload.as_bytes()); } // Still call the original hook so the panic shows in the terminal. default_hook(info); })); } /// Tauri command: persist a frontend-side error to error_log. /// /// Wired from the global window.onerror + window.unhandledrejection /// handlers in the Svelte root layout. Also called explicitly by /// invokeWithToast on failure paths so we have a unified record. #[tauri::command] pub async fn log_frontend_error( state: tauri::State<'_, AppState>, context: String, message: String, stack: Option, ) -> Result<(), String> { let metadata = stack.map(|s| { // Truncate stacks to keep the table from bloating; the full stack // is also visible in the browser console anyway. s.chars().take(2000).collect::() }); log_error( &state.db, if context.is_empty() { "frontend" } else { &context }, Some("FRONTEND_ERROR"), &message, metadata.as_deref(), ) .await .map_err(|e| e.to_string()) } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ErrorLogDto { pub id: i64, pub timestamp: String, pub context: String, pub error_code: Option, pub message: String, pub metadata: Option, } impl From for ErrorLogDto { fn from(r: ErrorLogRow) -> Self { Self { id: r.id, timestamp: r.timestamp, context: r.context, error_code: r.error_code, message: r.message, metadata: r.metadata, } } } #[tauri::command] pub async fn list_recent_errors_command( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, limit: Option, ) -> Result, String> { ensure_main_window(&window)?; let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000); list_recent_errors(&state.db, n) .await .map(|rows| rows.into_iter().map(ErrorLogDto::from).collect()) .map_err(|e| e.to_string()) } /// One crash file on disk. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CrashFile { pub filename: String, pub mtime_secs: u64, pub size_bytes: u64, pub preview: String, // first ~600 chars } #[tauri::command] pub async fn list_crash_files(window: tauri::WebviewWindow) -> Result, String> { ensure_main_window(&window)?; list_crash_files_inner().await } async fn list_crash_files_inner() -> Result, String> { let dir = crashes_dir(); let entries = match fs::read_dir(&dir) { Ok(e) => e, Err(_) => return Ok(Vec::new()), // no crashes dir = no crashes, perfect }; let mut out: Vec = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) != Some("crash") { continue; } let meta = match fs::metadata(&path) { Ok(m) => m, Err(_) => continue, }; let mtime_secs = meta .modified() .ok() .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let size_bytes = meta.len(); let preview = match fs::read_to_string(&path) { Ok(s) => s.chars().take(600).collect::(), Err(_) => String::new(), }; out.push(CrashFile { filename: path .file_name() .and_then(|s| s.to_str()) .map(String::from) .unwrap_or_default(), mtime_secs, size_bytes, preview, }); } // Newest first out.sort_by(|a, b| b.mtime_secs.cmp(&a.mtime_secs)); Ok(out) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReportOptions { pub include_recent_errors: bool, pub include_crashes: bool, pub include_settings: bool, pub include_log_tail: bool, } impl Default for ReportOptions { fn default() -> Self { Self { include_recent_errors: true, include_crashes: true, include_settings: false, include_log_tail: false, } } } fn redact_home(input: &str) -> String { match std::env::var("HOME") { Ok(home) if !home.is_empty() => input.replace(&home, "~"), _ => input.to_string(), } } fn looks_sensitive_key(key: &str) -> bool { let lower = key.to_ascii_lowercase(); lower.contains("path") || lower.contains("folder") || lower.contains("directory") || lower.contains("device") || lower.contains("microphone") } fn redact_json_value(value: &mut serde_json::Value, key_hint: Option<&str>) { match value { serde_json::Value::Object(map) => { for (key, child) in map.iter_mut() { redact_json_value(child, Some(key)); } } serde_json::Value::Array(items) => { for item in items { redact_json_value(item, key_hint); } } serde_json::Value::String(text) => { if key_hint.map(looks_sensitive_key).unwrap_or(false) || text.starts_with('/') || text.contains(":\\") || text.contains("/home/") || text.contains("/Users/") { *text = redact_home(text); } } _ => {} } } fn sanitise_preferences_json(raw: &str) -> String { match serde_json::from_str::(raw) { Ok(mut value) => { redact_json_value(&mut value, None); serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()) } Err(_) => "_preferences could not be parsed as JSON_".to_string(), } } fn redact_line(input: &str) -> String { redact_home(input) .chars() .take(400) .collect::() .replace('\n', " ") } /// Build a single human-readable diagnostic-report string. The user inspects /// this in Settings → About before deciding whether to copy/save/email it. /// /// The string is plain markdown so it can be pasted into an email, a /// Discord thread, or a GitHub issue without further conversion. #[tauri::command] pub async fn generate_diagnostic_report( window: tauri::WebviewWindow, state: tauri::State<'_, AppState>, options: Option, ) -> Result { ensure_main_window(&window)?; generate_diagnostic_report_inner(&state, options).await } async fn generate_diagnostic_report_inner( state: &tauri::State<'_, AppState>, options: Option, ) -> Result { let opts = options.unwrap_or_default(); let mut out = String::new(); out.push_str("# Kon diagnostic report\n\n"); out.push_str(&format!("- Version: `{}`\n", KON_VERSION)); out.push_str(&format!( "- OS / arch: `{} / {}`\n", std::env::consts::OS, std::env::consts::ARCH )); out.push_str(&format!( "- App data dir: `{}`\n", redact_home(&app_data_dir().display().to_string()) )); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); out.push_str(&format!("- Generated: unix `{}`\n", now)); out.push('\n'); out.push_str( "> This report is local-only until you choose to share it. \ Review the contents below before sending to anyone.\n\n", ); if opts.include_settings { out.push_str("## Settings (sanitised)\n\n"); match kon_storage::get_setting(&state.db, "kon_preferences").await { Ok(Some(json)) => { out.push_str("```json\n"); out.push_str(&sanitise_preferences_json(&json)); out.push_str("\n```\n\n"); } Ok(None) => out.push_str("_(no preferences saved)_\n\n"), Err(e) => out.push_str(&format!("_(could not read preferences: {e})_\n\n")), } } if opts.include_recent_errors { out.push_str("## Recent errors (newest first)\n\n"); match list_recent_errors(&state.db, DEFAULT_RECENT_ERRORS).await { Ok(rows) if rows.is_empty() => out.push_str("_(no errors logged)_\n\n"), Ok(rows) => { for r in rows { out.push_str(&format!( "- `{}` **{}** [{}]{}: {}\n", r.timestamp, r.context, r.error_code.as_deref().unwrap_or("?"), if r.metadata.is_some() { " (+meta)" } else { "" }, redact_line(&r.message), )); } out.push('\n'); } Err(e) => out.push_str(&format!("_(could not read error log: {e})_\n\n")), } } out.push_str("## Power assertions\n\n"); let power_assertions = active_assertions_snapshot(); if power_assertions.is_empty() { out.push_str("_(no active power assertions at report time)_\n\n"); } else { for assertion in power_assertions { out.push_str(&format!( "- `#{}` reason=`{}` backend=`{}` acquired=`{}`\n", assertion.id, assertion.reason, assertion.backend, assertion.acquired )); } out.push('\n'); } if opts.include_crashes { out.push_str("## Crash dumps\n\n"); let crashes = list_crash_files_inner().await.unwrap_or_default(); if crashes.is_empty() { out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n"); } else { for c in crashes.iter().take(5) { out.push_str(&format!( "### `{}` ({} bytes)\n\n```\n{}\n```\n\n", c.filename, c.size_bytes, c.preview )); } if crashes.len() > 5 { out.push_str(&format!( "_(plus {} older crash dumps in `{}`)_\n\n", crashes.len() - 5, redact_home(&crashes_dir().display().to_string()) )); } } } if opts.include_log_tail { out.push_str("## Log tail\n\n"); let log_path = logs_dir().join("kon.log"); match read_tail(&log_path, 8000) { Ok(tail) if tail.is_empty() => { out.push_str("_(log file empty or not yet written)_\n\n"); } Ok(tail) => { out.push_str("```\n"); out.push_str(&redact_home(&tail)); out.push_str("\n```\n\n"); } Err(_) => out.push_str("_(no log file found)_\n\n"), } } out.push_str("---\n\n"); out.push_str( "Generated by Kon. To share, copy the entire markdown above \ and paste it into an email or issue. Email: jake@corbel.consulting.\n", ); Ok(out) } /// Read the last `n_bytes` of a file as a UTF-8 string, lossy-decoded. /// Used to show recent log content without loading huge files. fn read_tail(path: &PathBuf, n_bytes: u64) -> std::io::Result { use std::io::{Read, Seek, SeekFrom}; let mut f = fs::File::open(path)?; let len = f.metadata()?.len(); let start = len.saturating_sub(n_bytes); f.seek(SeekFrom::Start(start))?; let mut buf = Vec::new(); f.read_to_end(&mut buf)?; Ok(String::from_utf8_lossy(&buf).to_string()) } /// Cross-platform OS info exposed to the frontend so the UI can adapt: /// hotkey labels (Cmd vs Ctrl), file-picker text, "open file location" /// terminology, etc. Returned on demand via the `get_os_info` Tauri /// command rather than recomputed in JS so we have a single source of /// truth in the Rust layer. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OsInfo { /// "windows" | "macos" | "linux" | "ios" | "android" | other pub os: String, /// "x86_64" | "aarch64" | other pub arch: String, /// Human-readable platform family for tooltips: "Windows", "macOS", /// "Linux" pub family: String, /// True if the platform uses the Cmd modifier rather than Ctrl as /// the primary accelerator (macOS). pub uses_cmd: bool, /// True if the user is currently in a Wayland session (Linux only). /// Useful for status display; the actual workaround is automatic. pub is_wayland: bool, /// True if the custom evdev hotkey listener is the primary backend /// (Linux only). Otherwise the Tauri global-shortcut plugin is used. pub custom_hotkey_backend: bool, /// Convenience: a localised label for the primary modifier /// ("Cmd" on macOS, "Ctrl" elsewhere). pub primary_modifier_label: String, } #[tauri::command] pub fn get_os_info() -> OsInfo { let os = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); let family = match os.as_str() { "windows" => "Windows", "macos" => "macOS", "linux" => "Linux", "ios" => "iOS", "android" => "Android", _ => "Unknown", } .to_string(); let uses_cmd = os == "macos"; let primary_modifier_label = if uses_cmd { "Cmd" } else { "Ctrl" }.to_string(); let is_wayland = if os == "linux" { std::env::var("XDG_SESSION_TYPE") .map(|v| v.eq_ignore_ascii_case("wayland")) .unwrap_or(false) } else { false }; let custom_hotkey_backend = os == "linux"; OsInfo { os, arch, family, uses_cmd, is_wayland, custom_hotkey_backend, primary_modifier_label, } } /// Write the report to a file and return the path. Lets the user save it /// somewhere they can attach to an email. #[tauri::command] pub async fn save_diagnostic_report( window: tauri::WebviewWindow, app: tauri::AppHandle, state: tauri::State<'_, AppState>, options: Option, ) -> Result { ensure_main_window(&window)?; let _ = app; // reserved for future dialog integration let report = generate_diagnostic_report_inner(&state, options).await?; let dir = kon_storage::app_data_dir().join("diagnostic-reports"); fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?; let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let path = dir.join(format!("kon-diagnostic-{ts}.md")); fs::write(&path, &report).map_err(|e| format!("write file: {e}"))?; Ok(path.to_string_lossy().to_string()) }