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:
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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user