From 64f7e47b20940d3114e9cd71848fd1044f31fa0e Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 4 Sep 2025 16:00:19 +0800 Subject: [PATCH] feat(fs): atomic writes for JSON and TOML saves\n\n- Introduce atomic_write utility and use it in write_json_file\n- Add write_text_file for TOML/strings and use in Codex paths\n- Reduce risk of partial writes and ensure directory creation --- src-tauri/src/codex_config.rs | 6 ++-- src-tauri/src/commands.rs | 6 ++-- src-tauri/src/config.rs | 57 +++++++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index f3cb151..8789860 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -79,8 +79,7 @@ pub fn save_codex_provider_config( toml::from_str::(config_str) .map_err(|e| format!("config.toml 格式错误: {}", e))?; } - fs::write(&config_path, config_str) - .map_err(|e| format!("写入供应商 config.toml 失败: {}", e))?; + crate::config::write_text_file(&config_path, config_str)?; } } @@ -126,7 +125,8 @@ pub fn restore_codex_provider_config(provider_id: &str, provider_name: &str) -> log::info!("已恢复 Codex config.toml"); } else { // 写入空文件 - fs::write(&config_path, "").map_err(|e| format!("创建空的 config.toml 失败: {}", e))?; + crate::config::write_text_file(&config_path, "") + .map_err(|e| format!("创建空的 config.toml 失败: {}", e))?; log::info!("供应商 config.toml 缺失,已创建空文件"); } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fd3f300..19bc9d1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -320,16 +320,16 @@ pub async fn switch_provider( toml::from_str::(cfg_str) .map_err(|e| format!("config.toml 格式错误: {}", e))?; } - std::fs::write(&config_path, cfg_str) + crate::config::write_text_file(&config_path, cfg_str) .map_err(|e| format!("写入 config.toml 失败: {}", e))?; } else { // 非字符串时,写空 - std::fs::write(&config_path, "") + crate::config::write_text_file(&config_path, "") .map_err(|e| format!("写入空的 config.toml 失败: {}", e))?; } } else { // 缺失则写空 - std::fs::write(&config_path, "") + crate::config::write_text_file(&config_path, "") .map_err(|e| format!("写入空的 config.toml 失败: {}", e))?; } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index e3766b7..762ab52 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; /// 获取 Claude Code 配置目录路径 @@ -79,7 +80,54 @@ pub fn write_json_file(path: &Path, data: &T) -> Result<(), String let json = serde_json::to_string_pretty(data).map_err(|e| format!("序列化 JSON 失败: {}", e))?; - fs::write(path, json).map_err(|e| format!("写入文件失败: {}", e)) + atomic_write(path, json.as_bytes()) +} + +/// 原子写入文本文件(用于 TOML/纯文本) +pub fn write_text_file(path: &Path, data: &str) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + atomic_write(path, data.as_bytes()) +} + +/// 原子写入:写入临时文件后 rename 替换,避免半写状态 +pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + + let parent = path.parent().ok_or_else(|| "无效的路径".to_string())?; + let mut tmp = parent.to_path_buf(); + let file_name = path + .file_name() + .ok_or_else(|| "无效的文件名".to_string())? + .to_string_lossy() + .to_string(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + tmp.push(format!("{}.tmp.{}", file_name, ts)); + + { + let mut f = fs::File::create(&tmp).map_err(|e| format!("创建临时文件失败: {}", e))?; + f.write_all(data) + .map_err(|e| format!("写入临时文件失败: {}", e))?; + f.flush().map_err(|e| format!("刷新临时文件失败: {}", e))?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = fs::metadata(path) { + let perm = meta.permissions().mode(); + let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); + } + } + + fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?; + Ok(()) } /// 复制文件 @@ -132,10 +180,7 @@ pub fn import_current_config_as_default() -> Result { // 读取当前配置 let settings_config: Value = read_json_file(&settings_path)?; - // 保存为 default 供应商 - let default_provider_path = get_provider_config_path("default", Some("default")); - write_json_file(&default_provider_path, &settings_config)?; - - log::info!("已导入当前配置为默认供应商"); + // 不再写入供应商副本文件,这里仅返回读取到的配置 + log::info!("已读取当前配置用于默认供应商导入"); Ok(settings_config) }