From 412906fb09bbd3ebaffa8a4d5b6516a0499e3542 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 9 Jan 2026 16:23:59 +0800 Subject: [PATCH] feat(logging): add crash logging and improve log management (#562) * feat(logging): add crash logging and improve log management - Add panic hook to capture crash info to ~/.cc-switch/crash.log - Records timestamp, app version, OS/arch, thread info - Full stack trace with force_capture for release builds - Safe error handling (no nested panics) - Enable logging for both Debug and Release builds - Info level for all builds - Output to console and ~/.cc-switch/logs/ - 5MB max file size with rotation - Add log cleanup on startup - Keep only 2 most recent log files - Works on all platforms - Change panic strategy from "abort" to "unwind" - Required for backtrace capture in release builds * fix(logging): use OnceLock for config dir and add URL redaction - Use OnceLock to support custom config directory override for crash.log - Add redact_url_for_log() to protect sensitive URL parameters in logs - Change verbose deep link logs from info to debug level - Move Store refresh before panic_hook init to ensure correct path --------- Co-authored-by: Jason --- src-tauri/Cargo.toml | 3 +- src-tauri/src/lib.rs | 76 +++++++++-- src-tauri/src/panic_hook.rs | 250 ++++++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 10 deletions(-) create mode 100644 src-tauri/src/panic_hook.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ecf85c69..1b70c178 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -77,7 +77,8 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] } codegen-units = 1 lto = "thin" opt-level = "s" -panic = "abort" +# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获) +panic = "unwind" strip = "symbols" [dev-dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6e1e9b7..1869a9b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod mcp; +mod panic_hook; mod prompt; mod prompt_files; mod provider; @@ -54,6 +55,37 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::RunEvent; use tauri::{Emitter, Manager}; +fn redact_url_for_log(url_str: &str) -> String { + match url::Url::parse(url_str) { + Ok(url) => { + let mut output = format!("{}://", url.scheme()); + if let Some(host) = url.host_str() { + output.push_str(host); + } + output.push_str(url.path()); + + let mut keys: Vec = url.query_pairs().map(|(k, _)| k.to_string()).collect(); + keys.sort(); + keys.dedup(); + + if !keys.is_empty() { + output.push_str("?[keys:"); + output.push_str(&keys.join(",")); + output.push(']'); + } + + output + } + Err(_) => { + let base = url_str.split('#').next().unwrap_or(url_str); + match base.split_once('?') { + Some((prefix, _)) => format!("{prefix}?[redacted]"), + None => base.to_string(), + } + } + } +} + /// 统一处理 ccswitch:// 深链接 URL /// /// - 解析 URL @@ -69,7 +101,9 @@ fn handle_deeplink_url( return false; } - log::info!("✓ Deep link URL detected from {source}: {url_str}"); + let redacted_url = redact_url_for_log(url_str); + log::info!("✓ Deep link URL detected from {source}: {redacted_url}"); + log::debug!("Deep link URL (raw) from {source}: {url_str}"); match crate::deeplink::parse_deeplink_url(url_str) { Ok(request) => { @@ -150,15 +184,18 @@ fn macos_tray_icon() -> Option> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // 设置 panic hook,在应用崩溃时记录日志到 /crash.log(默认 ~/.cc-switch/crash.log) + panic_hook::setup_panic_hook(); + let mut builder = tauri::Builder::default(); #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { log::info!("=== Single Instance Callback Triggered ==="); - log::info!("Args count: {}", args.len()); + log::debug!("Args count: {}", args.len()); for (i, arg) in args.iter().enumerate() { - log::info!(" arg[{i}]: {arg}"); + log::debug!(" arg[{i}]: {}", redact_url_for_log(arg)); } // Check for deep link URL in args (mainly for Windows/Linux command line) @@ -212,6 +249,10 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { + // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等) + app_store::refresh_app_config_dir_override(app.handle()); + panic_hook::init_app_config_dir(crate::config::get_app_config_dir()); + // 注册 Updater 插件(桌面端) #[cfg(desktop)] { @@ -223,17 +264,34 @@ pub fn run() { log::warn!("初始化 Updater 插件失败,已跳过:{e}"); } } - // 初始化日志 - if cfg!(debug_assertions) { + // 初始化日志(Debug 和 Release 模式都启用 Info 级别) + // 日志同时输出到控制台和文件(/logs/;若设置了覆盖则使用覆盖目录) + { + use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy}; + + let log_dir = panic_hook::get_log_dir(); + app.handle().plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) + .targets([ + // 输出到控制台 + Target::new(TargetKind::Stdout), + // 输出到日志文件 + Target::new(TargetKind::Folder { + path: log_dir, + file_name: Some("cc-switch".into()), + }), + ]) + .rotation_strategy(RotationStrategy::KeepAll) + .max_file_size(5_000_000) // 5MB 单文件上限 + .timezone_strategy(TimezoneStrategy::UseLocal) .build(), )?; - } - // 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径 - app_store::refresh_app_config_dir_override(app.handle()); + // 清理旧日志文件,只保留最近 2 个 + panic_hook::cleanup_old_logs(); + } // 初始化数据库 let app_config_dir = crate::config::get_app_config_dir(); @@ -529,7 +587,7 @@ pub fn run() { for (i, url) in urls.iter().enumerate() { let url_str = url.as_str(); - log::info!(" URL[{i}]: {url_str}"); + log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str)); if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") { break; // Process only first ccswitch:// URL diff --git a/src-tauri/src/panic_hook.rs b/src-tauri/src/panic_hook.rs new file mode 100644 index 00000000..b157e3bf --- /dev/null +++ b/src-tauri/src/panic_hook.rs @@ -0,0 +1,250 @@ +//! Panic Hook 模块 +//! +//! 在应用崩溃时捕获 panic 信息并记录到 `/crash.log` 文件中(默认 `~/.cc-switch/crash.log`)。 +//! 便于用户和开发者诊断闪退问题。 + +use std::fs::OpenOptions; +use std::io::Write; +use std::panic; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// 应用版本号(从 Cargo.toml 读取) +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// 日志文件保留数量 +const LOG_FILES_TO_KEEP: usize = 2; + +static APP_CONFIG_DIR: OnceLock = OnceLock::new(); + +pub fn init_app_config_dir(dir: PathBuf) { + let _ = APP_CONFIG_DIR.set(dir); +} + +/// 获取默认应用配置目录(不会 panic) +fn default_app_config_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cc-switch") +} + +/// 获取应用配置目录(优先使用初始化时写入的值;不会 panic) +fn get_app_config_dir() -> PathBuf { + APP_CONFIG_DIR + .get() + .cloned() + .unwrap_or_else(default_app_config_dir) +} + +/// 获取崩溃日志文件路径 +fn get_crash_log_path() -> PathBuf { + get_app_config_dir().join("crash.log") +} + +/// 获取日志目录路径 +pub fn get_log_dir() -> PathBuf { + get_app_config_dir().join("logs") +} + +/// 清理旧日志文件,只保留最近 N 个 +/// +/// 在应用启动时调用,确保日志文件不会无限增长。 +pub fn cleanup_old_logs() { + let log_dir = get_log_dir(); + + if !log_dir.exists() { + return; + } + + // 读取目录中的所有 .log 文件 + let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false)) + .collect(), + Err(_) => return, + }; + + // 如果文件数量不超过保留数量,无需清理 + if log_files.len() <= LOG_FILES_TO_KEEP { + return; + } + + // 按修改时间排序(最新的在前) + log_files.sort_by(|a, b| { + let time_a = a.metadata().and_then(|m| m.modified()).ok(); + let time_b = b.metadata().and_then(|m| m.modified()).ok(); + time_b.cmp(&time_a) // 降序 + }); + + // 删除多余的旧文件 + for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) { + if let Err(e) = std::fs::remove_file(&old_file) { + log::warn!("清理旧日志文件失败 {}: {e}", old_file.display()); + } else { + log::info!("已清理旧日志文件: {}", old_file.display()); + } + } +} + +/// 安全获取环境信息(不会 panic) +fn get_system_info() -> String { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + let family = std::env::consts::FAMILY; + + // 安全获取当前工作目录 + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + // 安全获取当前线程信息 + let thread = std::thread::current(); + let thread_name = thread.name().unwrap_or("unnamed"); + let thread_id = format!("{:?}", thread.id()); + + format!( + "OS: {os} ({family})\n\ + Arch: {arch}\n\ + App Version: {APP_VERSION}\n\ + Working Dir: {cwd}\n\ + Thread: {thread_name} (ID: {thread_id})" + ) +} + +/// 设置 panic hook,捕获崩溃信息并写入日志文件 +/// +/// 在应用启动时调用此函数,确保任何 panic 都会被记录。 +/// 日志格式包含: +/// - 时间戳 +/// - 应用版本和系统信息 +/// - Panic 信息 +/// - 发生位置(文件:行号) +/// - Backtrace(完整调用栈) +pub fn setup_panic_hook() { + // 启用 backtrace(确保 release 模式也能捕获) + if std::env::var("RUST_BACKTRACE").is_err() { + std::env::set_var("RUST_BACKTRACE", "1"); + } + + let default_hook = panic::take_hook(); + + panic::set_hook(Box::new(move |panic_info| { + let log_path = get_crash_log_path(); + + // 确保目录存在 + if let Some(parent) = log_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + + // 构建崩溃信息(使用 catch_unwind 保护时间格式化,避免嵌套 panic) + let timestamp = std::panic::catch_unwind(|| { + chrono::Local::now() + .format("%Y-%m-%d %H:%M:%S%.3f") + .to_string() + }) + .unwrap_or_else(|_| { + // chrono panic 时回退到 unix timestamp + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("unix:{}.{:03}", d.as_secs(), d.subsec_millis())) + .unwrap_or_else(|_| "unknown".to_string()) + }); + + // 获取系统信息 + let system_info = std::panic::catch_unwind(get_system_info) + .unwrap_or_else(|_| "Failed to get system info".to_string()); + + // 获取 panic 消息(尝试多种方式提取) + let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_info.payload().downcast_ref::() { + s.clone() + } else { + // 尝试使用 Display trait + format!("{panic_info}") + }; + + // 获取位置信息 + let location = if let Some(loc) = panic_info.location() { + format!( + "File: {}\n Line: {}\n Column: {}", + loc.file(), + loc.line(), + loc.column() + ) + } else { + "Unknown location".to_string() + }; + + // 捕获 backtrace(完整调用栈) + let backtrace = std::backtrace::Backtrace::force_capture(); + let backtrace_str = format!("{backtrace}"); + + // 格式化日志条目 + let separator = "=".repeat(80); + let sub_separator = "-".repeat(40); + let crash_entry = format!( + r#" +{separator} +[CRASH REPORT] {timestamp} +{separator} + +{sub_separator} +System Information +{sub_separator} +{system_info} + +{sub_separator} +Error Details +{sub_separator} +Message: {message} + +Location: {location} + +{sub_separator} +Stack Trace (Backtrace) +{sub_separator} +{backtrace_str} + +{separator} +"# + ); + + // 写入文件(追加模式) + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&log_path) { + let _ = file.write_all(crash_entry.as_bytes()); + let _ = file.flush(); + + // 记录日志文件位置到 stderr + eprintln!("\n[CC-Switch] Crash log saved to: {}", log_path.display()); + } + + // 同时输出到 stderr(便于开发调试) + eprintln!("{crash_entry}"); + + // 调用默认 hook + default_hook(panic_info); + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_crash_log_path() { + let path = get_crash_log_path(); + assert!(path.ends_with("crash.log")); + assert!(path.to_string_lossy().contains(".cc-switch")); + } + + #[test] + fn test_system_info() { + let info = get_system_info(); + assert!(info.contains("OS:")); + assert!(info.contains("Arch:")); + assert!(info.contains("App Version:")); + } +}