Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions openless-all/app/src-tauri/src/persistence/preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,36 @@ 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()
);
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,但还必须把迁移标记落盘,否则每次启动都停留在
Expand Down Expand Up @@ -50,6 +78,26 @@ fn read_preferences(path: &Path) -> Result<UserPreferences> {
Ok(prefs)
}

/// 把无法解析的 preferences.json 原样备份为 `preferences.corrupt-<unix>.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<UserPreferences>,
Expand Down
57 changes: 57 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(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::<UserPreferencesWire>(probe).is_ok() {
cleaned.insert(key, value);
} else {
log::warn!("[prefs] salvage dropping unparseable field: {key}");
}
}

serde_json::from_value::<Self>(serde_json::Value::Object(cleaned)).unwrap_or_default()
}
}

fn default_qa_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding::default_qa())
}
Expand Down Expand Up @@ -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::<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 non_tsf_insertion_fallback_defaults_to_enabled() {
let prefs = UserPreferences::default();
Expand Down
Loading