fix(prefs): 解析失败时逐字段抢救+备份,避免升级改 schema 静默清空设置 (#791)

* fix(prefs): 解析失败时逐字段抢救+备份,避免重构改 schema 静默清空设置

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(prefs): harden corrupt preference recovery

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-07-13 10:57:44 +08:00
1 parent 414acf0dc7
commit 01bff73cbe
2 files changed
+308 -17

No files matched your search

@@ -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<UserPreferences> {
if bytes.is_empty() {
return Ok(UserPreferences::default());
}
let prefs = serde_json::from_slice::<UserPreferences>(&bytes)
.with_context(|| format!("decode failed: {}", path.display()))?;
let prefs = match serde_json::from_slice::<UserPreferences>(&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<UserPreferences> {
Ok(prefs)
}
/// 把无法解析的 preferences.json 原样备份为唯一文件。
///
/// 使用 `create_new` 保证不会覆盖已有备份;备份失败时返回错误,调用方必须保留原文件。
fn backup_unparseable_preferences(path: &Path, bytes: &[u8]) -> Result<PathBuf> {
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<UserPreferences>,
@@ -59,19 +117,11 @@ impl PreferencesStore {
pub fn new() -> Result<Self> {
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<Self> {
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::<Vec<_>>();
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::<UserPreferences>(
&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 =
+175
View File
@@ -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::<serde_json::Value>(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::<Self>(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::<UserPreferencesWire>(probe).is_ok()
}
fn normalize_preference_aliases(map: &mut serde_json::Map<String, serde_json::Value>) {
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<String, serde_json::Value>,
) -> Option<UserPreferences> {
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::<Option<ShortcutBinding>>(value.clone()).ok())
.flatten()
.is_some();
let has_custom_combo_hotkey = map
.get("customComboHotkey")
.and_then(|value| serde_json::from_value::<Option<ComboBinding>>(value.clone()).ok())
.flatten()
.is_some();
if has_dictation_hotkey || has_custom_combo_hotkey {
return None;
}
map.remove("hotkey");
serde_json::from_value::<UserPreferences>(serde_json::Value::Object(map)).ok()
}
fn default_qa_hotkey() -> Option<ShortcutBinding> {
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::<UserPreferences>(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::<UserPreferences>(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::<UserPreferences>(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(