diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs index 13a600d3..c5c1c9d6 100644 --- a/openless-all/app/src-tauri/src/persistence/preferences.rs +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -19,8 +19,36 @@ fn read_preferences(path: &Path) -> Result { if bytes.is_empty() { return Ok(UserPreferences::default()); } - let prefs = serde_json::from_slice::(&bytes) - .with_context(|| format!("decode failed: {}", path.display()))?; + let prefs = match serde_json::from_slice::(&bytes) { + Ok(prefs) => prefs, + Err(err) => { + // 严格解析失败绝不能静默回落到 default——那样应用一启动就“忘光”所有设置, + // 用户随手改一项就把整份 preferences.json 覆盖成默认,历史设置永久丢失 + // (用户反馈:每次重装 app 后热键等设置读不到的根因路径)。 + // 改为:① 原样备份坏文件,永不销毁;② 逐字段抢救所有仍合法的设置; + // ③ 把抢救结果写回,得到一份干净可解析的文件,后续走正常路径。 + log::error!( + "[prefs] strict decode of {} failed: {err:#}; backing up original and salvaging valid fields", + path.display() + ); + backup_unparseable_preferences(path, &bytes); + let salvaged = UserPreferences::salvage_from_json_bytes(&bytes); + match serde_json::to_vec_pretty(&salvaged) + .context("encode salvaged prefs failed") + .and_then(|json| atomic_write(path, &json)) + { + Ok(()) => log::info!( + "[prefs] salvaged preferences written back to {}", + path.display() + ), + Err(err) => log::warn!( + "[prefs] failed to persist salvaged preferences to {}: {err}", + path.display() + ), + } + return Ok(salvaged); + } + }; // issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。 // 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在 @@ -50,6 +78,26 @@ fn read_preferences(path: &Path) -> Result { Ok(prefs) } +/// 把无法解析的 preferences.json 原样备份为 `preferences.corrupt-.json`, +/// 保证抢救/写回之前用户的原始设置永远有一份可人工核对的副本,绝不静默销毁。 +fn backup_unparseable_preferences(path: &Path, bytes: &[u8]) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let backup = path.with_file_name(format!("preferences.corrupt-{ts}.json")); + match fs::write(&backup, bytes) { + Ok(()) => log::error!( + "[prefs] original unparseable preferences backed up to {}", + backup.display() + ), + Err(err) => log::warn!( + "[prefs] failed to back up unparseable preferences to {}: {err}", + backup.display() + ), + } +} + pub struct PreferencesStore { path: PathBuf, state: Mutex, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index cd0a69ad..9ce809f5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1297,6 +1297,42 @@ impl<'de> Deserialize<'de> for UserPreferences { } } +impl UserPreferences { + /// 逐字段抢救一份无法严格反序列化的 preferences.json。 + /// + /// 背景:`UserPreferencesWire` 容器级 `#[serde(default)]` 已能容忍「缺字段」 + /// (老文件读新版本)。真正会让整份解析失败、进而静默回落默认值(= 用户所有 + /// 设置一次性丢光)的,是「字段存在但值非法」——例如某次重构改了枚举变体名 / + /// 字段类型,旧文件里的旧值在新版本里不再合法。这正是用户反馈「每次重装 app + /// 之后热键等设置就读不到」的根因路径。 + /// + /// 抢救策略:把 JSON 当作对象逐 key 试解析。因为 Wire 对所有字段都有 default, + /// 单键对象 `{k: v}` 只有当 `v` 对字段 `k` 的类型非法时才会失败——据此精确剔除 + /// 坏字段,保留其余全部有效设置(热键、模型选择、风格等都能活下来),最后再走 + /// 一次正常反序列化。无法当作对象解析时才彻底回落默认。 + pub fn salvage_from_json_bytes(bytes: &[u8]) -> Self { + let Ok(serde_json::Value::Object(map)) = + serde_json::from_slice::(bytes) + else { + return Self::default(); + }; + + let mut cleaned = serde_json::Map::new(); + for (key, value) in map { + let probe = serde_json::Value::Object( + std::iter::once((key.clone(), value.clone())).collect(), + ); + if serde_json::from_value::(probe).is_ok() { + cleaned.insert(key, value); + } else { + log::warn!("[prefs] salvage dropping unparseable field: {key}"); + } + } + + serde_json::from_value::(serde_json::Value::Object(cleaned)).unwrap_or_default() + } +} + fn default_qa_hotkey() -> Option { Some(ShortcutBinding::default_qa()) } @@ -2731,6 +2767,27 @@ pub struct QaChatMessage { mod tests { use super::*; + #[test] + fn salvage_preserves_valid_fields_when_one_value_is_invalid() { + // 模拟「某次重构改了枚举变体名」后的旧文件:defaultMode 是新版本已不存在的值, + // 但 dictationHotkey / activeAsrProvider 仍然合法。抢救必须保住合法字段, + // 只把非法字段回落默认——而不是整份丢光。 + let json = br#"{ + "defaultMode": "totally-removed-mode", + "dictationHotkey": { "primary": "LeftOption", "modifiers": [] }, + "activeAsrProvider": "bailian-qwen3-realtime" + }"#; + + // 严格解析必失败(否则这个测试没意义)。 + assert!(serde_json::from_slice::(json).is_err()); + + let salvaged = UserPreferences::salvage_from_json_bytes(json); + assert_eq!(salvaged.dictation_hotkey.primary, "LeftOption"); + assert_eq!(salvaged.active_asr_provider, "bailian-qwen3-realtime"); + // 非法字段回落到默认,而不是让整份解析失败。 + assert_eq!(salvaged.default_mode, UserPreferences::default().default_mode); + } + #[test] fn non_tsf_insertion_fallback_defaults_to_enabled() { let prefs = UserPreferences::default();