From 01bff73cbeb14e2c2004b2dffc4d4aacaeb7cbd4 Mon Sep 17 00:00:00 2001 From: bigsong <35025755+bigsongeth@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:57:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(prefs):=20=E8=A7=A3=E6=9E=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E9=80=90=E5=AD=97=E6=AE=B5=E6=8A=A2=E6=95=91?= =?UTF-8?q?+=E5=A4=87=E4=BB=BD=EF=BC=8C=E9=81=BF=E5=85=8D=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=94=B9=20schema=20=E9=9D=99=E9=BB=98=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=E8=AE=BE=E7=BD=AE=20(#791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(prefs): 解析失败时逐字段抢救+备份,避免重构改 schema 静默清空设置 Co-Authored-By: Claude Opus 4.8 * fix(prefs): harden corrupt preference recovery --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Chris233 --- .../src-tauri/src/persistence/preferences.rs | 150 +++++++++++++-- openless-all/app/src-tauri/src/types.rs | 175 ++++++++++++++++++ 2 files changed, 308 insertions(+), 17 deletions(-) diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs index 13a600d3..33485277 100644 --- a/openless-all/app/src-tauri/src/persistence/preferences.rs +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -2,7 +2,8 @@ //! User preferences store: a single JSON document held in memory behind a lock, //! with a one-time `streamingInsert` default migration on load. -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -19,8 +20,41 @@ 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() + ); + let backup = backup_unparseable_preferences(path, &bytes) + .with_context(|| format!("backup failed: {}", path.display()))?; + log::info!( + "[prefs] original unparseable preferences backed up to {}", + backup.display() + ); + 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 +84,30 @@ fn read_preferences(path: &Path) -> Result { Ok(prefs) } +/// 把无法解析的 preferences.json 原样备份为唯一文件。 +/// +/// 使用 `create_new` 保证不会覆盖已有备份;备份失败时返回错误,调用方必须保留原文件。 +fn backup_unparseable_preferences(path: &Path, bytes: &[u8]) -> Result { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let backup = path.with_file_name(format!( + "preferences.corrupt-{ts}-{}.json", + uuid::Uuid::new_v4().simple() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup) + .with_context(|| format!("create backup failed: {}", backup.display()))?; + file.write_all(bytes) + .with_context(|| format!("write backup failed: {}", backup.display()))?; + file.sync_all() + .with_context(|| format!("flush backup failed: {}", backup.display()))?; + Ok(backup) +} + pub struct PreferencesStore { path: PathBuf, state: Mutex, @@ -59,19 +117,11 @@ impl PreferencesStore { pub fn new() -> Result { let dir = data_dir()?; ensure_dir(&dir)?; - let path = dir.join(PREFERENCES_FILE); - let prefs = if path.exists() { - read_preferences(&path).unwrap_or_else(|e| { - log::warn!( - "[prefs] load {} failed, using defaults: {}", - path.display(), - e - ); - UserPreferences::default() - }) - } else { - UserPreferences::default() - }; + Self::from_path(dir.join(PREFERENCES_FILE)) + } + + fn from_path(path: PathBuf) -> Result { + let prefs = read_preferences(&path)?; Ok(Self { path, state: Mutex::new(prefs), @@ -113,7 +163,7 @@ impl PreferencesStore { #[cfg(test)] mod tests { - use super::{read_preferences, PreferencesStore}; + use super::{backup_unparseable_preferences, read_preferences, PreferencesStore}; use crate::types::{builtin_style_pack_id, PolishMode, UserPreferences}; use parking_lot::Mutex; use std::fs; @@ -157,6 +207,72 @@ mod tests { let _ = fs::remove_dir_all(&tmp); } + #[test] + fn corrupt_preferences_are_backed_up_before_salvage() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp dir"); + let path = tmp.join("preferences.json"); + let original = br#"{ + "defaultMode": "totally-removed-mode", + "activeAsrProvider": "preserved-provider" + }"#; + fs::write(&path, original).expect("write corrupt prefs"); + + let prefs = read_preferences(&path).expect("salvage prefs"); + assert_eq!(prefs.active_asr_provider, "preserved-provider"); + + let mut backups = fs::read_dir(&tmp) + .expect("read temp dir") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.starts_with("preferences.corrupt-")) + .unwrap_or(false) + }) + .collect::>(); + assert_eq!(backups.len(), 1); + let backup = backups.pop().expect("backup path"); + assert_eq!(fs::read(&backup).expect("read backup"), original); + assert!(serde_json::from_slice::( + &fs::read(&path).expect("read salvaged prefs") + ) + .is_ok()); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn corrupt_preference_backups_are_unique_and_preserve_each_snapshot() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp dir"); + let path = tmp.join("preferences.json"); + + let first = backup_unparseable_preferences(&path, b"first").expect("first backup"); + let second = backup_unparseable_preferences(&path, b"second").expect("second backup"); + + assert_ne!(first, second); + assert_eq!(fs::read(first).expect("read first backup"), b"first"); + assert_eq!(fs::read(second).expect("read second backup"), b"second"); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn preferences_store_init_propagates_load_failures() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); + let path = tmp.join("preferences.json"); + fs::create_dir_all(&path).expect("create directory at preferences path"); + + let result = PreferencesStore::from_path(path); + assert!(result.is_err()); + + let _ = fs::remove_dir_all(&tmp); + } + #[test] fn set_preserving_current_style_preferences_keeps_store_style_fields() { let tmp: PathBuf = diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c6830099..069205db 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1297,6 +1297,122 @@ impl<'de> Deserialize<'de> for UserPreferences { } } +impl UserPreferences { + /// 逐字段抢救一份无法严格反序列化的 preferences.json。 + /// + /// 背景:`UserPreferencesWire` 容器级 `#[serde(default)]` 已能容忍「缺字段」 + /// (老文件读新版本)。真正会让整份解析失败、进而静默回落默认值(= 用户所有 + /// 设置一次性丢光)的,是「字段存在但值非法」——例如某次重构改了枚举变体名 / + /// 字段类型,旧文件里的旧值在新版本里不再合法。这正是用户反馈「每次重装 app + /// 之后热键等设置就读不到」的根因路径。 + /// + /// 抢救策略:把 JSON 当作对象,先归一化已知 alias,再逐 key 试解析。因为 Wire 对 + /// 所有字段都有 default,单键对象 `{k: v}` 只有当 `v` 对字段 `k` 的类型非法时才会 + /// 失败——据此精确剔除坏字段,保留其余全部有效设置(热键、模型选择、风格等都能 + /// 活下来),最后再走一次正常反序列化。无法当作对象解析时才彻底回落默认。 + pub(crate) fn salvage_from_json_bytes(bytes: &[u8]) -> Self { + let Ok(serde_json::Value::Object(mut map)) = + serde_json::from_slice::(bytes) + else { + return Self::default(); + }; + + normalize_preference_aliases(&mut map); + + let mut cleaned = serde_json::Map::new(); + for (key, value) in map { + if preference_field_is_valid(&key, &value) { + cleaned.insert(key, value); + } else { + log::warn!("[prefs] salvage dropping unparseable field: {key}"); + } + } + + match serde_json::from_value::(serde_json::Value::Object(cleaned.clone())) { + Ok(prefs) => prefs, + Err(err) => { + if let Some(prefs) = salvage_without_incomplete_legacy_hotkey(cleaned) { + return prefs; + } + log::warn!( + "[prefs] salvage still failed after field filtering: {err}; using defaults" + ); + Self::default() + } + } + } +} + +fn preference_field_is_valid(key: &str, value: &serde_json::Value) -> bool { + let probe = + serde_json::Value::Object(std::iter::once((key.to_string(), value.clone())).collect()); + serde_json::from_value::(probe).is_ok() +} + +fn normalize_preference_aliases(map: &mut serde_json::Map) { + for (canonical, alias) in [ + ("windowsSendInputNewlineMode", "windowsSendinputNewlineMode"), + ( + "windowsSendInputInsertionOnly", + "windowsSendinputInsertionOnly", + ), + ] { + let Some(alias_value) = map.remove(alias) else { + continue; + }; + let canonical_valid = map + .get(canonical) + .map(|value| preference_field_is_valid(canonical, value)); + let alias_valid = preference_field_is_valid(canonical, &alias_value); + + match canonical_valid { + None => { + map.insert(canonical.to_string(), alias_value); + } + Some(true) => log::warn!( + "[prefs] salvage dropping duplicate legacy alias {alias}; canonical {canonical} wins" + ), + Some(false) if alias_valid => { + log::warn!( + "[prefs] salvage replacing invalid canonical {canonical} with valid legacy alias {alias}" + ); + map.insert(canonical.to_string(), alias_value); + } + Some(false) => {} + } + } +} + +fn salvage_without_incomplete_legacy_hotkey( + mut map: serde_json::Map, +) -> Option { + let is_custom_legacy_hotkey = map + .get("hotkey") + .and_then(|value| value.get("trigger")) + .and_then(serde_json::Value::as_str) + == Some("custom"); + if !is_custom_legacy_hotkey { + return None; + } + + let has_dictation_hotkey = map + .get("dictationHotkey") + .and_then(|value| serde_json::from_value::>(value.clone()).ok()) + .flatten() + .is_some(); + let has_custom_combo_hotkey = map + .get("customComboHotkey") + .and_then(|value| serde_json::from_value::>(value.clone()).ok()) + .flatten() + .is_some(); + if has_dictation_hotkey || has_custom_combo_hotkey { + return None; + } + + map.remove("hotkey"); + serde_json::from_value::(serde_json::Value::Object(map)).ok() +} + fn default_qa_hotkey() -> Option { Some(ShortcutBinding::default_qa()) } @@ -2734,6 +2850,51 @@ 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 salvage_normalizes_duplicate_legacy_aliases_without_resetting_other_fields() { + let json = br#"{ + "windowsSendInputInsertionOnly": false, + "windowsSendinputInsertionOnly": true, + "windowsSendInputNewlineMode": "removed-mode", + "windowsSendinputNewlineMode": "shiftEnter", + "activeAsrProvider": "preserved-provider" + }"#; + + assert!(serde_json::from_slice::(json).is_err()); + + let salvaged = UserPreferences::salvage_from_json_bytes(json); + assert!(!salvaged.windows_sendinput_insertion_only); + assert_eq!( + salvaged.windows_sendinput_newline_mode, + WindowsSendInputNewlineMode::ShiftEnter + ); + assert_eq!(salvaged.active_asr_provider, "preserved-provider"); + } + #[test] fn non_tsf_insertion_fallback_defaults_to_enabled() { let prefs = UserPreferences::default(); @@ -3092,6 +3253,20 @@ mod tests { assert!(result.is_err()); } + #[test] + fn salvage_preserves_valid_fields_when_legacy_custom_hotkey_is_incomplete() { + let json = br#"{ + "hotkey": { "trigger": "custom", "mode": "toggle", "keys": null }, + "activeAsrProvider": "preserved-provider" + }"#; + + assert!(serde_json::from_slice::(json).is_err()); + + let salvaged = UserPreferences::salvage_from_json_bytes(json); + assert_eq!(salvaged.active_asr_provider, "preserved-provider"); + assert_eq!(salvaged.hotkey, UserPreferences::default().hotkey); + } + #[test] fn legacy_custom_hotkey_uses_custom_combo_binding() { let prefs: UserPreferences = serde_json::from_str(