From 4afa68eac6cea6bcb96464bf78a512966e528514 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 3 Nov 2025 22:33:10 +0800 Subject: [PATCH] fix: prevent silent config fallback and data loss on startup This commit introduces fail-fast error handling for config loading failures, replacing the previous silent fallback to default config which could cause data loss (e.g., all user providers disappearing). Key changes: Backend (Rust): - Replace AppState::new() with AppState::try_new() to explicitly propagate errors - Remove Default trait to prevent accidental empty state creation - Add init_status module as global error cache (OnceLock + RwLock) - Implement dual-channel error notification: 1. Event emission (low-latency, may race with frontend subscription) 2. Command-based polling (reliable, guaranteed delivery) - Remove unconditional save on startup to prevent overwriting corrupted config Frontend (TypeScript): - Add event listener for "configLoadError" (fast path) - Add bootstrap-time polling via get_init_error command (reliable path) - Display detailed error dialog with recovery instructions - Prompt user to exit for manual repair Impact: - First-time users: No change (load() returns Ok(default) when file missing) - Corrupted config: Application shows error and exits gracefully - Prevents accidental config overwrite during initialization Fixes the only critical issue identified in previous code review (silent fallback causing data loss). --- src-tauri/src/commands/misc.rs | 8 ++++ src-tauri/src/init_status.rs | 42 +++++++++++++++++ src-tauri/src/lib.rs | 28 +++++++++-- src-tauri/src/store.rs | 19 ++------ src/main.tsx | 86 +++++++++++++++++++++++++++++----- 5 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 src-tauri/src/init_status.rs diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 9a97f75..946cb26 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -2,6 +2,7 @@ use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; +use crate::init_status::{get_init_error, InitErrorPayload}; /// 打开外部链接 #[tauri::command] @@ -43,3 +44,10 @@ pub async fn is_portable_mode() -> Result { Ok(false) } } + +/// 获取应用启动阶段的初始化错误(若有)。 +/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。 +#[tauri::command] +pub async fn get_init_error() -> Result, String> { + Ok(get_init_error()) +} diff --git a/src-tauri/src/init_status.rs b/src-tauri/src/init_status.rs new file mode 100644 index 0000000..95ccb62 --- /dev/null +++ b/src-tauri/src/init_status.rs @@ -0,0 +1,42 @@ +use serde::Serialize; +use std::sync::{OnceLock, RwLock}; + +#[derive(Debug, Clone, Serialize)] +pub struct InitErrorPayload { + pub path: String, + pub error: String, +} + +static INIT_ERROR: OnceLock>> = OnceLock::new(); + +fn cell() -> &'static RwLock> { + INIT_ERROR.get_or_init(|| RwLock::new(None)) +} + +pub fn set_init_error(payload: InitErrorPayload) { + if let Ok(mut guard) = cell().write() { + *guard = Some(payload); + } +} + +pub fn get_init_error() -> Option { + cell().read().ok()?.clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_error_roundtrip() { + let payload = InitErrorPayload { + path: "/tmp/config.json".into(), + error: "broken json".into(), + }; + set_init_error(payload.clone()); + let got = get_init_error().expect("should get payload back"); + assert_eq!(got.path, payload.path); + assert_eq!(got.error, payload.error); + } +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e9ff51c..78092a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod codex_config; mod commands; mod config; mod error; +mod init_status; mod mcp; mod migration; mod provider; @@ -437,7 +438,28 @@ pub fn run() { app_store::refresh_app_config_dir_override(app.handle()); // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage) - let app_state = AppState::new(); + // 如果配置解析失败,则向前端发送错误事件并提前结束 setup(不落盘、不覆盖配置)。 + let app_state = match AppState::try_new() { + Ok(state) => state, + Err(err) => { + let path = crate::config::get_app_config_path(); + let payload_json = serde_json::json!({ + "path": path.display().to_string(), + "error": err.to_string(), + }); + // 事件通知(可能早于前端订阅,不保证送达) + if let Err(e) = app.emit("configLoadError", payload_json) { + log::error!("发射配置加载错误事件失败: {}", e); + } + // 同时缓存错误,供前端启动阶段主动拉取 + crate::init_status::set_init_error(crate::init_status::InitErrorPayload { + path: path.display().to_string(), + error: err.to_string(), + }); + // 不再继续构建托盘/命令依赖的状态,交由前端提示后退出。 + return Ok(()); + } + }; // 迁移旧的 app_config_dir 配置到 Store if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) { @@ -456,8 +478,7 @@ pub fn run() { config_guard.ensure_app(&app_config::AppType::Codex); } - // 保存配置 - let _ = app_state.save(); + // 启动阶段不再无条件保存,避免意外覆盖用户配置。 // 创建动态托盘菜单 let menu = create_tray_menu(app.handle(), &app_state)?; @@ -498,6 +519,7 @@ pub fn run() { commands::open_config_folder, commands::pick_directory, commands::open_external, + commands::get_init_error, commands::get_app_config_path, commands::open_app_config_folder, commands::read_live_provider_settings, diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index d5eef7c..4453d97 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -7,23 +7,14 @@ pub struct AppState { pub config: RwLock, } -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} - impl AppState { /// 创建新的应用状态 - pub fn new() -> Self { - let config = MultiAppConfig::load().unwrap_or_else(|e| { - log::warn!("加载配置失败: {}, 使用默认配置", e); - MultiAppConfig::default() - }); - - Self { + /// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。 + pub fn try_new() -> Result { + let config = MultiAppConfig::load()?; + Ok(Self { config: RwLock::new(config), - } + }) } /// 保存配置到文件 diff --git a/src/main.tsx b/src/main.tsx index 2aba1b4..0ba423a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,10 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/components/theme-provider"; import { queryClient } from "@/lib/query"; import { Toaster } from "@/components/ui/sonner"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import { confirm, message } from "@tauri-apps/plugin-dialog"; +import { exit } from "@tauri-apps/plugin-process"; // 根据平台添加 body class,便于平台特定样式 try { @@ -22,15 +26,73 @@ try { // 忽略平台检测失败 } -ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - - - - - - , -); +// 监听后端的配置加载错误事件:仅提醒用户并在确认后退出,不修改任何配置文件 +try { + void listen("configLoadError", async (evt) => { + const payload = evt.payload as { path?: string; error?: string } | null; + const path = payload?.path ?? "~/.cc-switch/config.json"; + const detail = payload?.error ?? "Unknown error"; + + await message( + `无法读取配置文件:\n${path}\n\n错误详情:\n${detail}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。`, + { title: "配置加载失败", kind: "error" }, + ); + + const shouldExit = await confirm("现在退出应用以进行修复?", { + title: "退出确认", + okLabel: "退出应用", + cancelLabel: "取消", + }); + + if (shouldExit) { + await exit(1); + } + }); +} catch (e) { + // 忽略事件订阅异常(例如在非 Tauri 环境下) + console.error("订阅 configLoadError 事件失败", e); +} + +async function bootstrap() { + // 启动早期主动查询后端初始化错误,避免事件竞态 + try { + const initError = (await invoke("get_init_error")) as + | { path?: string; error?: string } + | null; + if (initError && (initError.path || initError.error)) { + const path = initError.path ?? "~/.cc-switch/config.json"; + const detail = initError.error ?? "Unknown error"; + await message( + `无法读取配置文件:\n${path}\n\n错误详情:\n${detail}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。`, + { title: "配置加载失败", kind: "error" }, + ); + const shouldExit = await confirm("现在退出应用以进行修复?", { + title: "退出确认", + okLabel: "退出应用", + cancelLabel: "取消", + }); + if (shouldExit) { + await exit(1); + return; // 退出流程 + } + } + } catch (e) { + // 忽略拉取错误,继续渲染 + console.error("拉取初始化错误失败", e); + } + + ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + + + + , + ); +} + +void bootstrap();