Files
Lumotia/src-tauri/src/commands/diagnostics.rs

535 lines
17 KiB
Rust

//! 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("<unnamed>"),
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<String>,
) -> 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::<String>()
});
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<String>,
pub message: String,
pub metadata: Option<String>,
}
impl From<ErrorLogRow> 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<i64>,
) -> Result<Vec<ErrorLogDto>, 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<Vec<CrashFile>, String> {
ensure_main_window(&window)?;
list_crash_files_inner().await
}
async fn list_crash_files_inner() -> Result<Vec<CrashFile>, 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<CrashFile> = 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::<String>(),
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::<serde_json::Value>(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::<String>()
.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<ReportOptions>,
) -> Result<String, String> {
ensure_main_window(&window)?;
generate_diagnostic_report_inner(&state, options).await
}
async fn generate_diagnostic_report_inner(
state: &tauri::State<'_, AppState>,
options: Option<ReportOptions>,
) -> Result<String, String> {
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<String> {
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<ReportOptions>,
) -> Result<String, String> {
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())
}