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

+112 -21
View File
@@ -20,7 +20,7 @@ use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::settings_bridge::SettingsBridge;
use crate::settings_bridge::{Edit, SettingsBridge};
/// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。
///
@@ -44,27 +44,39 @@ impl SettingsBridge {
/// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。
///
/// 返回是否真的落盘(只读 / 内存态时是 `false`,配置写不进去不该把 UI 卡住)。
/// 返回是否**真的落了盘**(内存态 / 只读 / 文件被更新版本写过 ⇒ `false`,配置写不进去不该把
/// UI 卡住)。写法走 [`SettingsBridge::write_with`](M174 r2 复审 P2-1):与外观写回同一个
/// 唯一出口,**入口自己不再碰 `store.save()`**——r1 修了外观那条、这一条漏着,于是「保存 AI
/// 配置后 1 秒内切界面语言」照样把 `ai.*` 连密钥一起抹掉,而且本函数还返回 `true`。
///
/// 为什么不自己 `store.reload()` 了事:这是**同一类缺陷的第二个入口**(第一个是
/// [`SettingsBridge::persist`])。收敛成 `write_with` 之后,下一个写入口在类型上就拿不到
/// 「不重读的 save」——而不是靠每个人记得。
pub fn persist_ui_language(&mut self, value: Option<&str>) -> bool {
let Some(store) = self.store.as_mut() else {
return false;
};
let changed = match value {
Some(language) => store
.set(UI_LANGUAGE, serde_json::Value::String(language.to_owned()))
.is_ok(),
None => store.doc().get(UI_LANGUAGE).is_some() && remove_ui_language(store),
};
if !changed {
return false;
}
let saved = store.save().is_ok();
if let Some(path) = self.path() {
self.last_mtime = std::fs::metadata(path)
.ok()
.and_then(|meta| meta.modified().ok());
}
saved
let outcome = self.write_with(|store| match value {
Some(language) => {
if store
.set(UI_LANGUAGE, serde_json::Value::String(language.to_owned()))
.is_err()
{
return Edit::Rejected;
}
Edit::Changed
}
// 「跟随系统」= 删键:`SettingDocument` 没有 remove API,而落盘写的是全量视图,所以
// 把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键原样带回)。
None => {
if store.doc().get(UI_LANGUAGE).is_none() {
return Edit::Unchanged;
}
if remove_ui_language(store) {
Edit::Changed
} else {
Edit::Unchanged
}
}
});
outcome.is_saved()
}
}
@@ -105,6 +117,85 @@ mod tests {
path
}
/// **P2-1(r2)的回归**:语言写回不许盖掉别人刚写的键。
///
/// 现场(复审探针 `rev2_ai_keys_survive_a_language_write`):保存 AI 配置后 1 秒内在同一页把
/// 界面语言切成 English ⇒ `persist_ui_language` 拿旧快照整份覆盖 ⇒ 文件里只剩 `appearance` +
/// `ui`,`ai` 整段消失,而本函数**返回 true**。
///
/// 抓得住:`write_with` 少了 `store.reload()`(或某个入口又自己 `store.save()` 绕过它)。
#[test]
fn persisting_the_language_keeps_keys_another_writer_just_saved() {
let path = temp_config("lang-keeps-ai");
let mut bridge = SettingsBridge::with_path(&path);
// 先让本桥真的加载过一次文件(长生命 store 的旧快照从这里来)。
bridge.persist(
&crate::prefs::AppearancePrefs::factory(),
crate::prefs::ThemeMode::Light,
);
// 另一个写者(M174 的助手设置页)写进三个 AI 键。
{
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
let mut store = SettingStore::load(&path, launcher_schema());
for (key, value) in [
("ai.base_url", "https://api.deepseek.com/v1"),
("ai.model", "deepseek-chat"),
("ai.api_key", "sk-must-survive-lang"),
] {
store.set(key, value.to_string().into()).expect("写 AI 键");
}
store.save().expect("落盘");
}
// 同一秒内切界面语言(语言入口写回它那份整文档)。
assert!(
bridge.persist_ui_language(Some("en_us")),
"语言这一项该写进去"
);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive-lang"),
"语言写回把刚保存的密钥盖掉了:{text}"
);
assert!(text.contains("deepseek-chat"), "模型名也被盖掉了:{text}");
assert!(text.contains("\"en_us\""), "语言这一项该写进去:{text}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// 「真的落盘了吗」不许撒谎:只读文件(`$schema` 认不出 ⇒ `FileAccess::ReadOnly`)上
/// `persist_ui_language` 必须是 `false`。
#[test]
fn persisting_the_language_reports_false_when_the_file_is_read_only() {
let path = temp_config("lang-read-only");
// `Invalid` 兼容档 ⇒ 只读:值照读,但不许被我们改写。
std::fs::write(
&path,
r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ui":{"language":"zh_cn"}}"#,
)
.expect("写配置");
let before = std::fs::read_to_string(&path).expect("读配置");
let mut bridge = SettingsBridge::with_path(&path);
assert_eq!(
bridge.load_ui_language().as_deref(),
Some("zh_cn"),
"值照读"
);
assert!(
!bridge.persist_ui_language(Some("en_us")),
"只读文件上写成功是谎话"
);
assert_eq!(
std::fs::read_to_string(&path).expect("读配置"),
before,
"被只读闸门拦下的写不许改动文件"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// 抓得住:①把 `effective_str` 当读法(英文机器没配过 → 被默认值 `zh_cn` 强行切成中文);
/// ②「跟随系统」写成哨兵值(枚举校验会拒、下次启动会撒谎);③删键时连别的键一起删。
/// 抓不住:UI 是否真的按这个值选目录(那是 `app-iced/src/ui/lang.rs` 的往返测试)。