Skip to content
Merged
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
150 changes: 133 additions & 17 deletions openless-all/app/src-tauri/src/persistence/preferences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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,但还必须把迁移标记落盘,否则每次启动都停留在
Expand Down Expand Up @@ -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>,
Expand All @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down
175 changes: 175 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,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())
}
Expand Down Expand Up @@ -2731,6 +2847,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();
Expand Down Expand Up @@ -3089,6 +3250,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(
Expand Down
Loading