Files
Unlucky-Launcher/crates/presenter/src/settings_bridge_lang.rs
T
AskaEth 6f78e0cf7e 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`
2026-11-08 17:03:13 +08:00

242 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 界面语言这一个键的读写(M106):`ui.language` 的**显式值**。
//!
//! ## 为什么单独一个文件
//!
//! `settings_bridge.rs` 是外观 / 主题 / 数据根那一摊的落点,已经贴着 400 行硬线;语言的读写只有
//! 两个方法 + 一个「删键」辅助,独立出来之后两边都还能长。`impl SettingsBridge` 写在别的模块是
//! Rust 允许的(同一 crate 的固有实现),只需要主文件把三个字段放成 `pub(super)`。
//!
//! ## 两条判据(都跟 schema 的默认值有关)
//!
//! 1. **读的是显式值**,不是 `effective_str`:`ui.language` 的 schema 默认值是 `zh_cn`,用它会把
//! 「没配过」读成「用户手选中文」——英文机器一启动就被强行切成中文(D28 违约)。
//! 2. **「跟随系统」= 删掉这个键**,不是写哨兵值:允许值只有 `zh_cn` / `en_us`(枚举校验会拒别的),
//! 而「跟随系统」的语义本来就是「没有显式选择」。
//!
//! 这两条都在 `.tower/comms/findings/` 里登了记(建议把 schema 默认值改成 `null` 或加
//! `follow_system` 取值,那时本文件的删键路径可以简化成一次 `store.set`)。
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::settings_bridge::{Edit, SettingsBridge};
/// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。
///
/// **不写这个键 = 跟随系统**,所以它没有「system」这种取值,见 [`SettingsBridge::persist_ui_language`]。
const UI_LANGUAGE: &str = "ui.language";
impl SettingsBridge {
/// 配置里 `ui.language` 的**显式值**(M106):`None` = 没写过 = **跟随系统**。
///
/// 不用 `effective_str`:那会把 schema 默认值(`zh_cn`)当成「用户手选中文」,于是**没配过**
/// 的英文机器会一启动就被强行切成中文(D28 的「纯英文环境不用管」直接违约)。
#[must_use]
pub fn load_ui_language(&self) -> Option<String> {
let store = self.store.as_ref()?;
store
.doc()
.get(UI_LANGUAGE)
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
}
/// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。
///
/// 返回是否**真的落了盘**(内存态 / 只读 / 文件被更新版本写过 ⇒ `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 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()
}
}
/// 把 `ui.language` 从文档里摘掉:`SettingDocument` 没有 remove API,而落盘写的是**全量视图**,
/// 所以把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键也原样带回)。
fn remove_ui_language(store: &mut SettingStore) -> bool {
let mut value = store.doc().to_value();
let removed = value
.get_mut("ui")
.and_then(serde_json::Value::as_object_mut)
.and_then(|ui| ui.remove("language"))
.is_some();
if !removed {
return false;
}
let (mut doc, _problems) = SettingDocument::load(&launcher_schema(), &value);
// 落盘的闸门是 `is_dirty()`(`write_document` 写全量视图,不是脏字段补丁)。
doc.mark_all_dirty();
*store.doc_mut() = doc;
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-bridge-lang-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
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` 的往返测试)。
#[test]
fn ui_language_round_trips_and_follow_system_removes_only_that_key() {
let path = temp_config("roundtrip");
std::fs::write(
&path,
r#"{"appearance":{"theme_mode":"dark"},"custom":{"keep":true}}"#,
)
.expect("写配置");
// 没写过 = 跟随系统(不是默认值 zh_cn)
let bridge = SettingsBridge::with_path(&path);
assert_eq!(bridge.load_ui_language(), None);
// 手选落盘 → 新开的 bridge(= 重启)读得回
let mut bridge = SettingsBridge::with_path(&path);
assert!(bridge.persist_ui_language(Some("zh_cn")));
assert_eq!(
SettingsBridge::with_path(&path)
.load_ui_language()
.as_deref(),
Some("zh_cn")
);
// 切回跟随系统 = 删键,且别的键一个不动
let mut bridge = SettingsBridge::with_path(&path);
assert!(bridge.persist_ui_language(None));
assert_eq!(SettingsBridge::with_path(&path).load_ui_language(), None);
let text = std::fs::read_to_string(&path).expect("读配置");
assert!(text.contains("theme_mode"), "外观键被删了:{text}");
assert!(text.contains("custom"), "用户手写的未知键被删了:{text}");
// 幂等:没有键可删 = 没有落盘
let mut bridge = SettingsBridge::with_path(&path);
assert!(!bridge.persist_ui_language(None));
// 内存态桥不许 panic
assert_eq!(SettingsBridge::memory_only().load_ui_language(), None);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
}