Переглянути джерело

fix(app-state): 防止 app-state.json 截断后空配置覆盖

启动时从 bak 恢复损坏或被掏空的配置,关掉 plugin-store 的截断写入,
改为原子 persist,并拒绝用无实质配置覆盖现有配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 місяць тому
батько
коміт
b39acf85e3

+ 431 - 0
src-tauri/src/app_state.rs

@@ -0,0 +1,431 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde_json::{Map, Value};
+use tauri::{AppHandle, Manager, Runtime};
+use tauri_plugin_store::StoreExt;
+
+use crate::atomic_file::write_bytes_atomically;
+
+pub const PRIMARY_FILE_NAME: &str = "app-state.json";
+pub const BAK_FILE_NAME: &str = "app-state.json.bak";
+
+const SUBSTANTIVE_KEYS: &[&str] = &[
+    "llmConfig",
+    "providerConfigs",
+    "recentProjects",
+    "lastProject",
+];
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RecoverAction {
+    Unchanged,
+    RefreshedBak,
+    RestoredFromBak,
+    Quarantined,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RecoverReport {
+    pub action: RecoverAction,
+    pub message: String,
+}
+
+enum Classified {
+    Missing,
+    Unusable,
+    Object { value: Value, has_substance: bool },
+}
+
+fn value_has_substance(value: &Value) -> bool {
+    let Some(object) = value.as_object() else {
+        return false;
+    };
+    SUBSTANTIVE_KEYS.iter().any(|key| match object.get(*key) {
+        None | Some(Value::Null) => false,
+        Some(Value::String(text)) => !text.is_empty(),
+        Some(Value::Array(items)) => !items.is_empty(),
+        Some(Value::Object(nested)) => !nested.is_empty(),
+        Some(_) => true,
+    })
+}
+
+fn classify(path: &Path) -> Classified {
+    if !path.exists() {
+        return Classified::Missing;
+    }
+    let Ok(bytes) = fs::read(path) else {
+        return Classified::Unusable;
+    };
+    if bytes.iter().all(u8::is_ascii_whitespace) {
+        return Classified::Unusable;
+    }
+    match serde_json::from_slice::<Value>(&bytes) {
+        Ok(value) if value.is_object() => {
+            let has_substance = value_has_substance(&value);
+            Classified::Object {
+                value,
+                has_substance,
+            }
+        }
+        _ => Classified::Unusable,
+    }
+}
+
+fn object_bytes(value: &Value) -> Result<Vec<u8>, String> {
+    serde_json::to_vec_pretty(value).map_err(|error| format!("序列化 app-state 失败: {error}"))
+}
+
+fn quarantine_primary(path: &Path) -> Result<(), String> {
+    if !path.exists() {
+        return Ok(());
+    }
+    let stamp = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|duration| duration.as_millis())
+        .unwrap_or(0);
+    let dest = path.with_file_name(format!("app-state.json.corrupt-{stamp}"));
+    fs::rename(path, &dest)
+        .or_else(|_| {
+            fs::copy(path, &dest)
+                .and_then(|_| fs::remove_file(path))
+                .map(|_| ())
+        })
+        .map_err(|error| {
+            format!(
+                "隔离损坏的 app-state 失败 '{}' -> '{}': {error}",
+                path.display(),
+                dest.display()
+            )
+        })
+}
+
+fn write_value(path: &Path, value: &Value) -> Result<(), String> {
+    write_bytes_atomically(path, &object_bytes(value)?)
+}
+
+pub fn recover_app_state_file(dir: &Path) -> RecoverReport {
+    let primary_path = dir.join(PRIMARY_FILE_NAME);
+    let bak_path = dir.join(BAK_FILE_NAME);
+    let primary = classify(&primary_path);
+    let bak = classify(&bak_path);
+
+    let primary_unusable = matches!(primary, Classified::Missing | Classified::Unusable);
+    let primary_has_substance = matches!(
+        primary,
+        Classified::Object {
+            has_substance: true,
+            ..
+        }
+    );
+    let bak_has_substance = matches!(
+        bak,
+        Classified::Object {
+            has_substance: true,
+            ..
+        }
+    );
+    let bak_usable_value = match &bak {
+        Classified::Object {
+            has_substance: true,
+            value,
+        } => Some(value.clone()),
+        _ => None,
+    };
+
+    if (!primary_has_substance && bak_has_substance) || (primary_unusable && bak_has_substance) {
+        if let Some(value) = bak_usable_value {
+            if let Err(error) = quarantine_primary(&primary_path) {
+                return RecoverReport {
+                    action: RecoverAction::Unchanged,
+                    message: format!("无法隔离损坏的 app-state.json: {error}"),
+                };
+            }
+            return match write_value(&primary_path, &value) {
+                Ok(()) => RecoverReport {
+                    action: RecoverAction::RestoredFromBak,
+                    message: "已从 app-state.json.bak 恢复全局配置".to_string(),
+                },
+                Err(error) => RecoverReport {
+                    action: RecoverAction::Unchanged,
+                    message: format!("从 bak 恢复 app-state.json 失败: {error}"),
+                },
+            };
+        }
+    }
+
+    if primary_has_substance {
+        let bak_needs_refresh = !matches!(
+            bak,
+            Classified::Object {
+                has_substance: true,
+                ..
+            }
+        );
+        if bak_needs_refresh {
+            if let Classified::Object { value, .. } = primary {
+                return match write_value(&bak_path, &value) {
+                    Ok(()) => RecoverReport {
+                        action: RecoverAction::RefreshedBak,
+                        message: "已用当前配置刷新 app-state.json.bak".to_string(),
+                    },
+                    Err(error) => RecoverReport {
+                        action: RecoverAction::Unchanged,
+                        message: format!("刷新 app-state.json.bak 失败: {error}"),
+                    },
+                };
+            }
+        }
+        return RecoverReport {
+            action: RecoverAction::Unchanged,
+            message: "app-state.json 完好".to_string(),
+        };
+    }
+
+    if matches!(primary, Classified::Unusable) {
+        return match quarantine_primary(&primary_path) {
+            Ok(()) => RecoverReport {
+                action: RecoverAction::Quarantined,
+                message: "已隔离损坏的 app-state.json,未写入空配置".to_string(),
+            },
+            Err(error) => RecoverReport {
+                action: RecoverAction::Unchanged,
+                message: format!("隔离损坏的 app-state.json 失败: {error}"),
+            },
+        };
+    }
+
+    RecoverReport {
+        action: RecoverAction::Unchanged,
+        message: "app-state.json 不存在或尚无实质配置".to_string(),
+    }
+}
+
+pub fn persist_app_state_object(dir: &Path, value: &Value) -> Result<(), String> {
+    if !value.is_object() {
+        return Err("app-state 必须是 JSON 对象".to_string());
+    }
+    let primary_path = dir.join(PRIMARY_FILE_NAME);
+    let incoming_has_substance = value_has_substance(value);
+    if let Classified::Object {
+        has_substance: true,
+        ..
+    } = classify(&primary_path)
+    {
+        if !incoming_has_substance {
+            eprintln!("[app-state] 拒绝用无实质配置的内容覆盖现有 app-state.json");
+            return Err("拒绝用空配置覆盖现有应用配置".to_string());
+        }
+    }
+
+    let bytes = object_bytes(value)?;
+    write_bytes_atomically(&primary_path, &bytes)?;
+    if incoming_has_substance {
+        write_bytes_atomically(&dir.join(BAK_FILE_NAME), &bytes)?;
+    }
+    Ok(())
+}
+
+fn open_app_state_store<R: Runtime>(
+    app: &AppHandle<R>,
+) -> Result<std::sync::Arc<tauri_plugin_store::Store<R>>, String> {
+    app.store_builder(PRIMARY_FILE_NAME)
+        .disable_auto_save()
+        .build()
+        .map_err(|error| format!("无法打开应用状态存储: {error}"))
+}
+
+pub fn persist_plugin_store<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
+    let dir = app
+        .path()
+        .app_data_dir()
+        .map_err(|error| format!("无法获取 app_data_dir: {error}"))?;
+    let path = dir.join(PRIMARY_FILE_NAME);
+    let store = open_app_state_store(app)?;
+    let mut map = Map::new();
+    for (key, value) in store.entries() {
+        map.insert(key, value);
+    }
+    persist_app_state_object(&dir, &Value::Object(map))?;
+    Ok(path)
+}
+
+pub fn prepare_app_state_store<R: Runtime>(app: &AppHandle<R>) {
+    let Ok(dir) = app.path().app_data_dir() else {
+        eprintln!("[app-state] could not resolve app_data_dir");
+        return;
+    };
+    let report = recover_app_state_file(&dir);
+    eprintln!("[app-state] {}", report.message);
+    if let Err(error) = open_app_state_store(app) {
+        eprintln!("[app-state] 打开存储失败: {error}");
+    }
+}
+
+#[tauri::command]
+pub async fn write_app_state_atomic(
+    app: AppHandle,
+    entries: Value,
+) -> Result<(), String> {
+    let dir = app
+        .path()
+        .app_data_dir()
+        .map_err(|error| format!("无法获取 app_data_dir: {error}"))?;
+    tauri::async_runtime::spawn_blocking(move || persist_app_state_object(&dir, &entries))
+        .await
+        .map_err(|error| format!("write_app_state_atomic join error: {error}"))?
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::atomic_file::write_bytes_atomically_with_replace;
+
+    fn unique_test_dir(name: &str) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!(
+            "qmai_app_state_{name}_{}",
+            uuid::Uuid::new_v4()
+        ));
+        fs::create_dir_all(&dir).unwrap();
+        dir
+    }
+
+    fn write_raw(dir: &Path, name: &str, contents: &str) {
+        fs::write(dir.join(name), contents).unwrap();
+    }
+
+    fn read(dir: &Path, name: &str) -> String {
+        fs::read_to_string(dir.join(name)).unwrap()
+    }
+
+    fn corrupt_names(dir: &Path) -> Vec<String> {
+        fs::read_dir(dir)
+            .unwrap()
+            .filter_map(|entry| entry.ok())
+            .map(|entry| entry.file_name().to_string_lossy().into_owned())
+            .filter(|name| name.starts_with("app-state.json.corrupt-"))
+            .collect()
+    }
+
+    #[test]
+    fn restores_truncated_primary_from_bak() {
+        let dir = unique_test_dir("truncated");
+        write_raw(&dir, PRIMARY_FILE_NAME, r#"{"llmConfig":{"model":""#);
+        write_raw(
+            &dir,
+            BAK_FILE_NAME,
+            r#"{"llmConfig":{"model":"kept"},"recentProjects":[{"path":"/novel"}]}"#,
+        );
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::RestoredFromBak);
+        let restored: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        assert_eq!(restored["llmConfig"]["model"], "kept");
+        assert_eq!(corrupt_names(&dir).len(), 1);
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn restores_when_primary_lost_substantive_keys() {
+        let dir = unique_test_dir("shrink");
+        write_raw(
+            &dir,
+            PRIMARY_FILE_NAME,
+            r#"{"analytics_device_uuid":"abc"}"#,
+        );
+        write_raw(
+            &dir,
+            BAK_FILE_NAME,
+            r#"{"llmConfig":{"provider":"openai","model":"gpt"},"providerConfigs":{"openai":{}}}"#,
+        );
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::RestoredFromBak);
+        let restored: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        assert!(restored.get("llmConfig").is_some());
+        assert!(restored.get("analytics_device_uuid").is_none());
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn quarantines_unusable_primary_without_writing_empty_json() {
+        let dir = unique_test_dir("quarantine");
+        write_raw(&dir, PRIMARY_FILE_NAME, "{not-json");
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::Quarantined);
+        assert!(!dir.join(PRIMARY_FILE_NAME).exists());
+        assert_eq!(corrupt_names(&dir).len(), 1);
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn refreshes_missing_bak_from_healthy_primary() {
+        let dir = unique_test_dir("bak");
+        write_raw(
+            &dir,
+            PRIMARY_FILE_NAME,
+            r#"{"lastProject":{"path":"/novel","name":"n"}}"#,
+        );
+
+        let report = recover_app_state_file(&dir);
+        assert_eq!(report.action, RecoverAction::RefreshedBak);
+        let bak: Value = serde_json::from_str(&read(&dir, BAK_FILE_NAME)).unwrap();
+        assert_eq!(bak["lastProject"]["path"], "/novel");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn refuses_to_overwrite_substantive_state_with_empty_payload() {
+        let dir = unique_test_dir("refuse");
+        write_raw(
+            &dir,
+            PRIMARY_FILE_NAME,
+            r#"{"llmConfig":{"model":"keep"},"recentProjects":[{"path":"/a"}]}"#,
+        );
+
+        let error = persist_app_state_object(
+            &dir,
+            &serde_json::json!({"analytics_device_uuid":"x"}),
+        )
+        .unwrap_err();
+        assert!(error.contains("拒绝"));
+        let kept: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        assert_eq!(kept["llmConfig"]["model"], "keep");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn persist_writes_primary_and_bak() {
+        let dir = unique_test_dir("persist");
+        persist_app_state_object(
+            &dir,
+            &serde_json::json!({"providerConfigs":{"openai":{"apiKey":"k"}}}),
+        )
+        .unwrap();
+        let primary: Value = serde_json::from_str(&read(&dir, PRIMARY_FILE_NAME)).unwrap();
+        let bak: Value = serde_json::from_str(&read(&dir, BAK_FILE_NAME)).unwrap();
+        assert_eq!(primary, bak);
+        assert_eq!(primary["providerConfigs"]["openai"]["apiKey"], "k");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn failed_atomic_replace_keeps_original_app_state() {
+        let dir = unique_test_dir("persist_fail");
+        let path = dir.join(PRIMARY_FILE_NAME);
+        fs::write(&path, r#"{"llmConfig":{"model":"keep"}}"#).unwrap();
+        let result = write_bytes_atomically_with_replace(
+            &path,
+            br#"{"llmConfig":{"model":"new"}}"#,
+            |_temp, _dest| Err("injected".to_string()),
+        );
+        assert!(result.is_err());
+        assert_eq!(
+            fs::read_to_string(&path).unwrap(),
+            r#"{"llmConfig":{"model":"keep"}}"#
+        );
+        let _ = fs::remove_dir_all(&dir);
+    }
+}

+ 136 - 0
src-tauri/src/atomic_file.rs

@@ -0,0 +1,136 @@
+use std::fs::{self, OpenOptions};
+use std::io::Write;
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Atomically replace `destination` with a sibling temporary file.
+#[cfg(windows)]
+pub fn replace_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
+    use std::os::windows::ffi::OsStrExt;
+
+    #[link(name = "Kernel32")]
+    extern "system" {
+        fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
+    }
+
+    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
+    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
+    let existing: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
+    let new: Vec<u16> = destination.as_os_str().encode_wide().chain(Some(0)).collect();
+    let result = unsafe {
+        MoveFileExW(
+            existing.as_ptr(),
+            new.as_ptr(),
+            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+        )
+    };
+    if result == 0 {
+        Err(format!(
+            "原子替换文件失败: {}",
+            std::io::Error::last_os_error()
+        ))
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(not(windows))]
+pub fn replace_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
+    fs::rename(temp, destination).map_err(|e| format!("原子替换文件失败: {e}"))
+}
+
+pub fn write_bytes_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
+    write_bytes_atomically_with_replace(path, bytes, replace_file_atomically)
+}
+
+pub fn write_bytes_atomically_with_replace<F>(
+    path: &Path,
+    bytes: &[u8],
+    replace: F,
+) -> Result<(), String>
+where
+    F: FnOnce(&Path, &Path) -> Result<(), String>,
+{
+    let parent = path
+        .parent()
+        .ok_or_else(|| "路径缺少父目录".to_string())?;
+    fs::create_dir_all(parent).map_err(|e| format!("创建目录失败 '{}': {e}", parent.display()))?;
+    let file_name = path
+        .file_name()
+        .map(|name| name.to_string_lossy().to_string())
+        .unwrap_or_else(|| "app-state.json".to_string());
+    let stamp = SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map(|duration| duration.as_nanos())
+        .unwrap_or(0);
+    let temp = parent.join(format!(".{file_name}.{stamp}.tmp"));
+    let write_result = (|| {
+        let mut file = OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(&temp)
+            .map_err(|e| format!("创建临时文件失败 '{}': {e}", temp.display()))?;
+        file.write_all(bytes)
+            .map_err(|e| format!("写入临时文件失败 '{}': {e}", temp.display()))?;
+        file.sync_all()
+            .map_err(|e| format!("同步临时文件失败 '{}': {e}", temp.display()))?;
+        drop(file);
+        replace(&temp, path)
+    })();
+    if write_result.is_err() {
+        let _ = fs::remove_file(&temp);
+    }
+    write_result
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::path::PathBuf;
+
+    fn unique_test_dir(name: &str) -> PathBuf {
+        let dir = std::env::temp_dir().join(format!(
+            "qmai_atomic_file_{name}_{}",
+            uuid::Uuid::new_v4()
+        ));
+        fs::create_dir_all(&dir).unwrap();
+        dir
+    }
+
+    #[test]
+    fn failed_replace_leaves_the_original_file() {
+        let dir = unique_test_dir("replace_fail");
+        let path = dir.join("app-state.json");
+        fs::write(&path, r#"{"llmConfig":{"model":"keep"}}"#).unwrap();
+
+        let result = write_bytes_atomically_with_replace(
+            &path,
+            br#"{"llmConfig":{"model":"new"}}"#,
+            |_temp, _dest| Err("injected replace failure".to_string()),
+        );
+
+        assert!(result.is_err());
+        assert_eq!(
+            fs::read_to_string(&path).unwrap(),
+            r#"{"llmConfig":{"model":"keep"}}"#
+        );
+        let leftovers: Vec<_> = fs::read_dir(&dir)
+            .unwrap()
+            .filter_map(|entry| entry.ok())
+            .map(|entry| entry.file_name().to_string_lossy().into_owned())
+            .filter(|name| name.ends_with(".tmp"))
+            .collect();
+        assert!(leftovers.is_empty(), "temp files leftover: {leftovers:?}");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn atomic_write_replaces_existing_content() {
+        let dir = unique_test_dir("replace_ok");
+        let path = dir.join("app-state.json");
+        fs::write(&path, "{}").unwrap();
+        write_bytes_atomically(&path, br#"{"ok":true}"#).unwrap();
+        assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"ok":true}"#);
+        let _ = fs::remove_dir_all(&dir);
+    }
+}

+ 11 - 19
src-tauri/src/commands/backup.rs

@@ -10,6 +10,7 @@ use walkdir::WalkDir;
 use zip::write::ZipWriter;
 use zip::CompressionMethod;
 
+use crate::app_state;
 use crate::panic_guard::run_guarded;
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -295,7 +296,9 @@ fn restore_app_state_via_store(
     app_state_json: &serde_json::Value,
 ) -> Result<(), String> {
     let store = app
-        .store("app-state.json")
+        .store_builder("app-state.json")
+        .disable_auto_save()
+        .build()
         .map_err(|e| format!("无法加载应用状态存储: {e}"))?;
 
     store.clear();
@@ -308,8 +311,7 @@ fn restore_app_state_via_store(
         store.set(key.clone(), value.clone());
     }
 
-    store
-        .save()
+    crate::app_state::persist_plugin_store(app)
         .map_err(|e| format!("保存应用状态存储失败: {e}"))?;
 
     Ok(())
@@ -1011,9 +1013,7 @@ pub fn do_import_backup<F: Fn(&BackupProgressPayload)>(
                     && manifest_contents.credentials);
             let merged_app_state =
                 merge_app_state_file(&app_state_path, app_state_json, replace_app_state)?;
-            let app_state_str = serde_json::to_string_pretty(&merged_app_state)
-                .map_err(|e| format!("序列化 app-state 失败: {e}"))?;
-            fs::write(&app_state_path, app_state_str.as_bytes())
+            crate::app_state::persist_app_state_object(app_state_dir, &merged_app_state)
                 .map_err(|e| format!("写入 app-state.json 失败: {e}"))?;
 
             app_state = Some(merged_app_state);
@@ -1203,15 +1203,10 @@ pub async fn export_backup(
             .app_data_dir()
             .map_err(|err| format!("无法获取 app_data_dir: {err}"))?;
 
-        let app_state_path = match app.store("app-state.json") {
-            Ok(store) => {
-                if let Err(e) = store.save() {
-                    eprintln!("保存 app-state 存储失败: {e}");
-                }
-                app_data_dir.join("app-state.json")
-            }
+        let app_state_path = match app_state::persist_plugin_store(&app) {
+            Ok(path) => path,
             Err(e) => {
-                eprintln!("无法获取 app-state 存储句柄: {e}");
+                eprintln!("保存 app-state 存储失败: {e}");
                 app_data_dir.join("app-state.json")
             }
         };
@@ -1241,11 +1236,8 @@ pub async fn import_backup(
             .app_data_dir()
             .map_err(|err| format!("无法获取 app_data_dir: {err}"))?;
 
-        if let Ok(store) = app.store("app-state.json") {
-            store
-                .save()
-                .map_err(|error| format!("导入前保存当前应用状态失败: {error}"))?;
-        }
+        app_state::persist_plugin_store(&app)
+            .map_err(|error| format!("导入前保存当前应用状态失败: {error}"))?;
 
         let app_clone = app.clone();
         let result = do_import_backup(params, &app_data_dir, move |payload| {

+ 4 - 0
src-tauri/src/lib.rs

@@ -1,3 +1,5 @@
+mod app_state;
+mod atomic_file;
 mod commands;
 mod panic_guard;
 mod proxy;
@@ -46,6 +48,7 @@ pub fn run() {
             if let Ok(dir) = app.path().resource_dir() {
                 commands::fs::set_resource_dir_hint(dir);
             }
+            app_state::prepare_app_state_store(app.handle());
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -121,6 +124,7 @@ pub fn run() {
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
+            app_state::write_app_state_atomic,
             commands::writing_wake_lock::acquire_writing_wake_lock,
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,

+ 4 - 0
src-tauri/src/main.rs

@@ -1,5 +1,7 @@
 #![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
 
+mod app_state;
+mod atomic_file;
 mod commands;
 mod panic_guard;
 mod proxy;
@@ -48,6 +50,7 @@ fn main() {
             if let Ok(dir) = app.path().resource_dir() {
                 commands::fs::set_resource_dir_hint(dir);
             }
+            app_state::prepare_app_state_store(app.handle());
             if let Ok(dir) = app.path().app_data_dir() {
                 let store_path = dir.join("app-state.json");
                 eprintln!("[proxy] reading from {}", store_path.display());
@@ -123,6 +126,7 @@ fn main() {
             commands::backup::export_backup,
             commands::backup::import_backup,
             commands::backup::read_backup_manifest,
+            app_state::write_app_state_atomic,
             commands::writing_wake_lock::acquire_writing_wake_lock,
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,

+ 8 - 8
src/lib/project-store.ts

@@ -260,14 +260,14 @@ const PROXY_CONFIG_KEY = "proxyConfig"
 export async function saveProxyConfig(config: ProxyConfig): Promise<void> {
   const store = await getStore()
   await store.set(PROXY_CONFIG_KEY, config)
-  // Force-flush to disk. The store is opened with `autoSave: true`,
-  // which is a 100ms debounce — not an immediate write. For most
-  // settings that's fine, but the proxy config is on the startup
-  // critical path: the Rust setup hook reads `app-state.json` on
-  // launch to apply HTTP_PROXY / HTTPS_PROXY / NO_PROXY. If the
-  // user saves and quits within the debounce window the disk
-  // value would lag behind in-memory, and the next launch would
-  // boot with the wrong proxy.
+  // Force-flush to disk. `set()` only schedules a 100ms debounce
+  // persist — not an immediate write. For most settings that's
+  // fine, but the proxy config is on the startup critical path:
+  // the Rust setup hook reads `app-state.json` on launch to apply
+  // HTTP_PROXY / HTTPS_PROXY / NO_PROXY. If the user saves and
+  // quits within the debounce window the disk value would lag
+  // behind in-memory, and the next launch would boot with the
+  // wrong proxy.
   await store.save()
 }
 

+ 83 - 0
src/lib/web-store.spec.ts

@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  APP_STATE_ATOMIC_WRITE_COMMAND,
+  wrapStoreForAtomicPersist,
+} from "./web-store"
+
+describe("wrapStoreForAtomicPersist", () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+  })
+
+  afterEach(() => {
+    vi.useRealTimers()
+  })
+
+  function createInner(initial: Record<string, unknown> = {}) {
+    const values = new Map(Object.entries(initial))
+    return {
+      values,
+      save: vi.fn(async () => {
+        throw new Error("plugin save must not be called")
+      }),
+      store: {
+        get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
+        set: vi.fn(async (key: string, value: unknown) => {
+          values.set(key, value)
+        }),
+        delete: vi.fn(async (key: string) => values.delete(key)),
+        entries: vi.fn(async () => [...values.entries()] as Array<[string, unknown]>),
+      },
+    }
+  }
+
+  it("persists full entries through the atomic command and never calls plugin save", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner({ llmConfig: { model: "keep" } })
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("theme", "dark")
+    expect(persist).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(100)
+
+    expect(inner.save).not.toHaveBeenCalled()
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({
+      llmConfig: { model: "keep" },
+      theme: "dark",
+    })
+  })
+
+  it("save flushes immediately with the latest entries", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner()
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("language", "zh")
+    await store.save()
+
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({ language: "zh" })
+    await vi.advanceTimersByTimeAsync(100)
+    expect(persist).toHaveBeenCalledTimes(1)
+  })
+
+  it("keeps the latest value when overlapping writes resolve out of order", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner()
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("aiOutlineModel", "first")
+    await store.set("aiOutlineModel", "second")
+    await store.save()
+
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({ aiOutlineModel: "second" })
+  })
+})
+
+describe("atomic write command name", () => {
+  it("matches the Rust command", () => {
+    expect(APP_STATE_ATOMIC_WRITE_COMMAND).toBe("write_app_state_atomic")
+  })
+})

+ 77 - 3
src/lib/web-store.ts

@@ -1,4 +1,78 @@
-export async function getStore() {
-  const { load } = await import("@tauri-apps/plugin-store")
-  return load("app-state.json", { autoSave: true, defaults: {} })
+import { invoke } from "@tauri-apps/api/core"
+
+export const APP_STATE_ATOMIC_WRITE_COMMAND = "write_app_state_atomic"
+export const APP_STATE_PERSIST_DEBOUNCE_MS = 100
+
+export interface AtomicPersistStore {
+  get: <T>(key: string) => Promise<T | undefined>
+  set: (key: string, value: unknown) => Promise<void>
+  delete: (key: string) => Promise<boolean>
+  entries: <T>() => Promise<Array<[string, T]>>
+}
+
+export interface AtomicAppStateStore {
+  get: <T>(key: string) => Promise<T | undefined>
+  set: (key: string, value: unknown) => Promise<void>
+  delete: (key: string) => Promise<boolean>
+  save: () => Promise<void>
+}
+
+export function wrapStoreForAtomicPersist(
+  inner: AtomicPersistStore,
+  persistEntries: (entries: Record<string, unknown>) => Promise<void>,
+  debounceMs = APP_STATE_PERSIST_DEBOUNCE_MS,
+): AtomicAppStateStore {
+  let persistTimer: ReturnType<typeof setTimeout> | null = null
+  let persistChain = Promise.resolve()
+
+  const flushPersist = () => {
+    if (persistTimer) {
+      clearTimeout(persistTimer)
+      persistTimer = null
+    }
+    persistChain = persistChain
+      .catch(() => undefined)
+      .then(async () => {
+        const pairs = await inner.entries()
+        await persistEntries(Object.fromEntries(pairs))
+      })
+    return persistChain
+  }
+
+  const schedulePersist = () => {
+    if (persistTimer) clearTimeout(persistTimer)
+    persistTimer = setTimeout(() => {
+      persistTimer = null
+      void flushPersist()
+    }, debounceMs)
+  }
+
+  return {
+    get: (key) => inner.get(key),
+    set: async (key, value) => {
+      await inner.set(key, value)
+      schedulePersist()
+    },
+    delete: async (key) => {
+      const deleted = await inner.delete(key)
+      schedulePersist()
+      return deleted
+    },
+    save: () => flushPersist(),
+  }
+}
+
+let storePromise: Promise<AtomicAppStateStore> | null = null
+
+export async function getStore(): Promise<AtomicAppStateStore> {
+  if (!storePromise) {
+    storePromise = (async () => {
+      const { load } = await import("@tauri-apps/plugin-store")
+      const inner = await load("app-state.json", { autoSave: false })
+      return wrapStoreForAtomicPersist(inner, async (entries) => {
+        await invoke(APP_STATE_ATOMIC_WRITE_COMMAND, { entries })
+      })
+    })()
+  }
+  return storePromise
 }