mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-30 16:03:21 +08:00
Compare commits
2 Commits
fix/sql-im
...
feat/crash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb8e7b8d8 | ||
|
|
36f20eb06e |
@@ -77,7 +77,8 @@ objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
panic = "abort"
|
# 使用 unwind 以便 panic hook 能捕获 backtrace(abort 会直接终止无法捕获)
|
||||||
|
panic = "unwind"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod gemini_config;
|
|||||||
mod gemini_mcp;
|
mod gemini_mcp;
|
||||||
mod init_status;
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
|
mod panic_hook;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod prompt_files;
|
mod prompt_files;
|
||||||
mod provider;
|
mod provider;
|
||||||
@@ -54,6 +55,37 @@ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
|||||||
use tauri::RunEvent;
|
use tauri::RunEvent;
|
||||||
use tauri::{Emitter, Manager};
|
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<String> = 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
|
/// 统一处理 ccswitch:// 深链接 URL
|
||||||
///
|
///
|
||||||
/// - 解析 URL
|
/// - 解析 URL
|
||||||
@@ -69,7 +101,9 @@ fn handle_deeplink_url(
|
|||||||
return false;
|
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) {
|
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
@@ -150,15 +184,18 @@ fn macos_tray_icon() -> Option<Image<'static>> {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// 设置 panic hook,在应用崩溃时记录日志到 <app_config_dir>/crash.log(默认 ~/.cc-switch/crash.log)
|
||||||
|
panic_hook::setup_panic_hook();
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default();
|
let mut builder = tauri::Builder::default();
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
log::info!("=== Single Instance Callback Triggered ===");
|
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() {
|
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)
|
// 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_opener::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.setup(|app| {
|
.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 插件(桌面端)
|
// 注册 Updater 插件(桌面端)
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
@@ -223,17 +264,34 @@ pub fn run() {
|
|||||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 初始化日志
|
// 初始化日志(Debug 和 Release 模式都启用 Info 级别)
|
||||||
if cfg!(debug_assertions) {
|
// 日志同时输出到控制台和文件(<app_config_dir>/logs/;若设置了覆盖则使用覆盖目录)
|
||||||
|
{
|
||||||
|
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||||
|
|
||||||
|
let log_dir = panic_hook::get_log_dir();
|
||||||
|
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
tauri_plugin_log::Builder::default()
|
tauri_plugin_log::Builder::default()
|
||||||
.level(log::LevelFilter::Info)
|
.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(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
}
|
|
||||||
|
|
||||||
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
// 清理旧日志文件,只保留最近 2 个
|
||||||
app_store::refresh_app_config_dir_override(app.handle());
|
panic_hook::cleanup_old_logs();
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化数据库
|
// 初始化数据库
|
||||||
let app_config_dir = crate::config::get_app_config_dir();
|
let app_config_dir = crate::config::get_app_config_dir();
|
||||||
@@ -529,7 +587,7 @@ pub fn run() {
|
|||||||
|
|
||||||
for (i, url) in urls.iter().enumerate() {
|
for (i, url) in urls.iter().enumerate() {
|
||||||
let url_str = url.as_str();
|
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") {
|
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||||
break; // Process only first ccswitch:// URL
|
break; // Process only first ccswitch:// URL
|
||||||
|
|||||||
250
src-tauri/src/panic_hook.rs
Normal file
250
src-tauri/src/panic_hook.rs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
//! Panic Hook 模块
|
||||||
|
//!
|
||||||
|
//! 在应用崩溃时捕获 panic 信息并记录到 `<app_config_dir>/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<PathBuf> = 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::<String>() {
|
||||||
|
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:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user