fix(m174): 复审 r2 —— 写入口收敛成一个(语言入口同缺陷)+ 只读文件上保存不再谎报成功

P2-1(同一类缺陷的第三个入口):`persist_ui_language` 与 `persist` 共用同一份长生命
store、`save()` 同样整份落盘,但语言入口没先 `reload()` ⇒ 保存 AI 配置后 1 秒内切界面语言,
`ai.*` 连密钥一起被 1 秒前的旧快照盖掉,而且函数返回 true。

修法选「收敛成唯一写入口」而不是「再补一次 reload()」:
- 新文件 `presenter/src/settings_bridge/write.rs` 提供 `SettingsBridge::write_with(|store| Edit)`
  ——现读 → 改 → 落盘 → 刷 mtime,`Edit::{Changed,Unchanged,Rejected}` 表达闭包的结果,
  `WriteOutcome::{NoStorage,Unchanged,Saved,Skipped(reason),Failed}` 表达写的结果(只有 `Saved`
  算成功)。两个入口都改成传闭包,**入口自己再也拿不到「不重读的 save」**——下一个写入口不会
  再靠「记得」。r1 就是「补一次 reload」的做法,隔一个文件又漏了一次。

第二条(用户可见谎话):`ai::store::save` 注释写着「落盘后再读一遍才算数」,实际返回的是内存
doc;`SettingStore::save` 还有 `Skipped(ReadOnly/Unreadable/NotDirty)` 一档(`Ok`,不是 `Err`),
旧代码 `.is_ok()` 会把它当成功 ⇒ 在「配置文件被更新版本启动器写过」的机器上什么都没写,界面报
「已保存」,重启回旧值。现在按 `SaveOutcome` 分支(只有 `Saved` 算成功,`Skipped` 给具名原因),
并且真的 `SettingStore::load` 一次从磁盘回读。

回归用例(都做了变异验证):
- `settings_bridge_lang::tests::persisting_the_language_keeps_keys_another_writer_just_saved`
  (去掉 `write_with` 的 `reload()` ⇒ 红,输出即「语言写回把刚保存的密钥盖掉了」+ 被覆盖的文件)
- `settings_bridge_lang::tests::persisting_the_language_reports_false_when_the_file_is_read_only`
  (`persist_ui_language` 恒返回 true ⇒ 红)
- `ai::store::tests::read_only_config_is_reported_as_a_failure`(`save()` 恒返回 Ok ⇒ 红)
- `ai::store::tests::skip_reasons_are_named`
This commit is contained in:
AskaEth committed 2026-11-08 17:03:13 +08:00
1 parent 93d02ab12b
commit 6f78e0cf7e
5 files changed
+301 -53

No files matched your search

+25 -28
View File
@@ -21,6 +21,11 @@ use unlucky_core::setting::store::SettingStore;
use crate::prefs::{AppearanceBase, AppearancePrefs, ThemeMode};
#[path = "settings_bridge/write.rs"]
mod write;
pub(crate) use write::Edit;
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
@@ -119,36 +124,28 @@ impl SettingsBridge {
}
}
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
/// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
///
/// **写前先 `reload()`**(M174 r1 复审 P2-4):本桥的 store 是长生命的(启动时 load 一次),
/// 而 `SettingStore::save` 是**整份文档**落盘(`core/src/setting/store.rs` 的 `write_document`
/// 序列化 `doc.to_value()`,不是只写脏键)。不先重读就会拿「≤1 秒前的旧快照」整体覆盖文件:
/// 用户刚在设置页存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒被外观写回抹掉
/// (R1 复审给的是这个现场:保存 AI 配置后 1 秒内改一次主题,密钥连影子都不剩)。
///
/// 与 `HostEnv::store()` 的口径对齐:**写之前现读**,不信任任何长生命快照。
/// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的
/// (启动时 load 一次),而 `SettingStore::save` 是**整份文档**落盘。不先重读就会拿「≤1 秒前
/// 的旧快照」整体覆盖文件:用户刚存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒
/// 被外观写回抹掉。现在「现读」这一步收在唯一写入口里,本函数只管改哪些键。
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
let Some(store) = self.store.as_mut() else {
return;
};
store.reload();
let mut snapshot = Appearance::from_store(store);
snapshot.theme_mode = theme.as_str().to_owned();
snapshot.bg_image_enabled = appearance.bg_image_enabled;
snapshot.bg_image = appearance.bg_image.clone();
snapshot.bg_fill = appearance.bg_fill;
snapshot.bg_dim = f64::from(appearance.bg_dim);
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
snapshot.glass = appearance.glass;
snapshot.accent_color = appearance.accent_color.clone();
if snapshot.write(store).is_err() {
return;
}
let _ = store.save();
if let Some(path) = self.path() {
self.last_mtime = mtime(path);
}
let _ = self.write_with(|store| {
let mut snapshot = Appearance::from_store(store);
snapshot.theme_mode = theme.as_str().to_owned();
snapshot.bg_image_enabled = appearance.bg_image_enabled;
snapshot.bg_image = appearance.bg_image.clone();
snapshot.bg_fill = appearance.bg_fill;
snapshot.bg_dim = f64::from(appearance.bg_dim);
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
snapshot.glass = appearance.glass;
snapshot.accent_color = appearance.accent_color.clone();
if snapshot.write(store).is_err() {
return Edit::Rejected;
}
Edit::Changed
});
}
/// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。