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).
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
use crate::init_status::{get_init_error, InitErrorPayload};
|
||||||
|
|
||||||
/// 打开外部链接
|
/// 打开外部链接
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -43,3 +44,10 @@ pub async fn is_portable_mode() -> Result<bool, String> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取应用启动阶段的初始化错误(若有)。
|
||||||
|
/// 用于前端在早期主动拉取,避免事件订阅竞态导致的提示缺失。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_init_error() -> Result<Option<InitErrorPayload>, String> {
|
||||||
|
Ok(get_init_error())
|
||||||
|
}
|
||||||
|
|||||||
42
src-tauri/src/init_status.rs
Normal file
42
src-tauri/src/init_status.rs
Normal file
@@ -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<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
|
||||||
|
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<InitErrorPayload> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ mod codex_config;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod init_status;
|
||||||
mod mcp;
|
mod mcp;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
@@ -437,7 +438,28 @@ pub fn run() {
|
|||||||
app_store::refresh_app_config_dir_override(app.handle());
|
app_store::refresh_app_config_dir_override(app.handle());
|
||||||
|
|
||||||
// 初始化应用状态(仅创建一次,并在本函数末尾注入 manage)
|
// 初始化应用状态(仅创建一次,并在本函数末尾注入 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
|
// 迁移旧的 app_config_dir 配置到 Store
|
||||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
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);
|
config_guard.ensure_app(&app_config::AppType::Codex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存配置
|
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
||||||
let _ = app_state.save();
|
|
||||||
|
|
||||||
// 创建动态托盘菜单
|
// 创建动态托盘菜单
|
||||||
let menu = create_tray_menu(app.handle(), &app_state)?;
|
let menu = create_tray_menu(app.handle(), &app_state)?;
|
||||||
@@ -498,6 +519,7 @@ pub fn run() {
|
|||||||
commands::open_config_folder,
|
commands::open_config_folder,
|
||||||
commands::pick_directory,
|
commands::pick_directory,
|
||||||
commands::open_external,
|
commands::open_external,
|
||||||
|
commands::get_init_error,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
|
|||||||
@@ -7,23 +7,14 @@ pub struct AppState {
|
|||||||
pub config: RwLock<MultiAppConfig>,
|
pub config: RwLock<MultiAppConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// 创建新的应用状态
|
/// 创建新的应用状态
|
||||||
pub fn new() -> Self {
|
/// 注意:仅在配置成功加载时返回;不会在失败时回退默认值。
|
||||||
let config = MultiAppConfig::load().unwrap_or_else(|e| {
|
pub fn try_new() -> Result<Self, AppError> {
|
||||||
log::warn!("加载配置失败: {}, 使用默认配置", e);
|
let config = MultiAppConfig::load()?;
|
||||||
MultiAppConfig::default()
|
Ok(Self {
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存配置到文件
|
/// 保存配置到文件
|
||||||
|
|||||||
86
src/main.tsx
86
src/main.tsx
@@ -9,6 +9,10 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { queryClient } from "@/lib/query";
|
import { queryClient } from "@/lib/query";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
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,便于平台特定样式
|
// 根据平台添加 body class,便于平台特定样式
|
||||||
try {
|
try {
|
||||||
@@ -22,15 +26,73 @@ try {
|
|||||||
// 忽略平台检测失败
|
// 忽略平台检测失败
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
// 监听后端的配置加载错误事件:仅提醒用户并在确认后退出,不修改任何配置文件
|
||||||
<React.StrictMode>
|
try {
|
||||||
<QueryClientProvider client={queryClient}>
|
void listen("configLoadError", async (evt) => {
|
||||||
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
const payload = evt.payload as { path?: string; error?: string } | null;
|
||||||
<UpdateProvider>
|
const path = payload?.path ?? "~/.cc-switch/config.json";
|
||||||
<App />
|
const detail = payload?.error ?? "Unknown error";
|
||||||
<Toaster />
|
|
||||||
</UpdateProvider>
|
await message(
|
||||||
</ThemeProvider>
|
`无法读取配置文件:\n${path}\n\n错误详情:\n${detail}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。`,
|
||||||
</QueryClientProvider>
|
{ title: "配置加载失败", kind: "error" },
|
||||||
</React.StrictMode>,
|
);
|
||||||
);
|
|
||||||
|
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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider defaultTheme="system" storageKey="cc-switch-theme">
|
||||||
|
<UpdateProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</UpdateProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user