From 6f78e0cf7e20120dd5f3f7d5a404f8f559304ffc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:03:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(m174):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=86=99=E5=85=A5=E5=8F=A3=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E6=88=90=E4=B8=80=E4=B8=AA=EF=BC=88=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=90=8C=E7=BC=BA=E9=99=B7=EF=BC=89+=20?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B0=8E=E6=8A=A5=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- .../app-iced/src/pages/settings/ai/store.rs | 30 +++- .../src/pages/settings/ai/store_tests.rs | 48 +++++++ crates/presenter/src/settings_bridge.rs | 53 ++++--- crates/presenter/src/settings_bridge/write.rs | 90 ++++++++++++ crates/presenter/src/settings_bridge_lang.rs | 133 +++++++++++++++--- 5 files changed, 301 insertions(+), 53 deletions(-) create mode 100644 crates/presenter/src/settings_bridge/write.rs diff --git a/crates/app-iced/src/pages/settings/ai/store.rs b/crates/app-iced/src/pages/settings/ai/store.rs index e3f4d0c..40c0f23 100644 --- a/crates/app-iced/src/pages/settings/ai/store.rs +++ b/crates/app-iced/src/pages/settings/ai/store.rs @@ -17,7 +17,7 @@ use std::path::Path; use unlucky_core::setting::keys::launcher_schema; -use unlucky_core::setting::store::SettingStore; +use unlucky_core::setting::store::{SaveOutcome, SettingStore}; use super::state::{ProviderId, Snapshot, State}; @@ -51,9 +51,31 @@ pub fn save(path: &Path, state: &State) -> Result { .map_err(|error| format!("{error}"))?; } - store.save().map_err(|error| format!("{error}"))?; - // 落盘后再读一遍:`set` 只证明「schema 收了」,`save` 之后文件里到底是什么才算数。 - Ok(snapshot(store.doc())) + // **只有 `SaveOutcome::Saved` 算成功**(M174 r2 复审):`save()` 还有一档 `Skipped(reason)` + // ——只读(`$schema` 认不出 / 文件被更新版本的启动器写过)、不可信文件、没有脏键。旧代码 + // 用 `.is_ok()` 把这一档也当成功,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。 + match store.save() { + Ok(SaveOutcome::Saved(_)) => {} + Ok(SaveOutcome::Skipped(reason)) => { + return Err(format!("未写入磁盘({})", skip_reason(reason))); + } + Err(error) => return Err(format!("{error}")), + } + + // 落盘之后**真的从磁盘再读一份**:`store.doc()` 是这次改过的内存视图,它证明不了「文件里 + // 现在是什么」(上面那句注释在 r1 就有,代码却没照做——r2 复审按这句判的)。 + let reloaded = SettingStore::load(path, launcher_schema()); + Ok(snapshot(reloaded.doc())) +} + +/// `SkipReason` → 人话(`core` 的稳定名对用户没意义)。 +fn skip_reason(reason: unlucky_core::setting::store::SkipReason) -> &'static str { + use unlucky_core::setting::store::SkipReason; + match reason { + SkipReason::NotDirty => "没有改动", + SkipReason::ReadOnly => "配置文件只读:它被更新版本的启动器写过,或被标记为不可信", + SkipReason::Unreadable => "配置文件不可信(加载时就降级成只读)", + } } /// 文档 → 快照(密钥只取长度,值不出本函数)。 diff --git a/crates/app-iced/src/pages/settings/ai/store_tests.rs b/crates/app-iced/src/pages/settings/ai/store_tests.rs index 4f10aba..293e016 100644 --- a/crates/app-iced/src/pages/settings/ai/store_tests.rs +++ b/crates/app-iced/src/pages/settings/ai/store_tests.rs @@ -8,6 +8,7 @@ //! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` | //! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 | //! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 | +//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok | use std::path::PathBuf; @@ -161,3 +162,50 @@ fn appearance_write_back_keeps_the_ai_keys() { assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count()); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); } + +/// **P2-1(r2)第二条**:只读配置上的保存必须报**失败**,而不是「已保存」。 +/// +/// 现场:`$schema` 认不出(或文件被更新版本的启动器写过)⇒ `FileAccess::ReadOnly`。 +/// `SettingStore::save` 在这种文件上会 `Skipped(ReadOnly)`(**不是** Err),旧代码用 `.is_ok()` +/// 判定,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。 +/// +/// 抓得住:①把 `save()` 的 `SaveOutcome` 分支去掉(恒返回 Ok);②把只读档当成功。 +/// 抓不住:`Skipped` 分支自身的文案(见 [`super::skip_reason`] 的单测)。 +#[test] +fn read_only_config_is_reported_as_a_failure() { + let path = temp_config("read-only"); + // `Invalid` 兼容档(`$schema` 前缀认不出)⇒ `FileAccess::ReadOnly`:值照读,不许改写。 + std::fs::write( + &path, + r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ai":{"model":"keep-me"}}"#, + ) + .expect("写配置"); + let before = std::fs::read_to_string(&path).expect("读配置"); + + let error = save(&path, &draft()).expect_err("只读配置上的保存必须报失败"); + assert!( + error.contains("只读") || error.contains("只读文件") || error.contains("9.9.9"), + "错误文案要说得清为什么没写:{error}" + ); + assert_eq!( + std::fs::read_to_string(&path).expect("读配置"), + before, + "被只读闸门拦下的保存不许改动文件" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +/// `skip_reason` 的三档都有人话(`Skipped` 不许被静默当成成功)。 +#[test] +fn skip_reasons_are_named() { + use super::skip_reason; + use unlucky_core::setting::store::SkipReason; + + for reason in [ + SkipReason::NotDirty, + SkipReason::ReadOnly, + SkipReason::Unreadable, + ] { + assert!(!skip_reason(reason).is_empty(), "{reason:?} 没有文案"); + } +} diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 1a56a7d..31a6196 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -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`)改过就重读;返回新的生效值。 diff --git a/crates/presenter/src/settings_bridge/write.rs b/crates/presenter/src/settings_bridge/write.rs new file mode 100644 index 0000000..528ced9 --- /dev/null +++ b/crates/presenter/src/settings_bridge/write.rs @@ -0,0 +1,90 @@ +//! 设置桥的**唯一写入口**(M174 r2 复审 P2-1)。 +//! +//! ## 为什么要把「写」收敛成一处 +//! +//! [`SettingsBridge`] 的 store 是**长生命**的(启动时 `SettingStore::load` 一次,之后只读), +//! 而 `SettingStore::save` 写的是**整份文档**(`core::setting::store` 的 `write_document` +//! 序列化 `doc.to_value()`,不是脏字段补丁)。于是任何「拿旧快照直接 save」的入口都会把别人 +//! 刚写进文件的键整片抹掉: +//! +//! | 轮次 | 入口 | 现象 | +//! |---|---|---| +//! | r1 | [`SettingsBridge::persist`](外观/主题) | 保存 AI 配置后 1 秒内改主题 ⇒ `ai.*` 连密钥一起消失 | +//! | r2 | `persist_ui_language`(界面语言) | 保存 AI 配置后 1 秒内在同一页切成 English ⇒ 同样消失,而且函数**返回 true**(静默) | +//! +//! 两轮都是「同一类缺陷隔一个文件复发一次」。所以写这件事只留 [`SettingsBridge::write_with`] +//! 一个出口:**现读 → 改 → 落盘 → 刷 mtime**,新入口只能传闭包,忘不了 `reload()`。 +//! +//! ## 为什么返回 [`WriteOutcome`] 而不是 `bool` +//! +//! 「没写」有三种原因(内存态 / 没有改动 / 被 schema 只读闸门拦下 / 写盘失败),调用方要能分清: +//! 语言入口要回答「真的落盘了吗」(旧版把 `Skipped` 也当成功),诊断要能说出原因。 + +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use super::{mtime, SettingsBridge}; + +/// 闭包对文档做的事。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Edit { + /// 文档改过了(需要落盘)。 + Changed, + /// 没有可改的(幂等路径):不落盘,也不算失败。 + Unchanged, + /// 改不动(`store.set` 被 schema / 只读闸门拒绝)。 + Rejected, +} + +/// 一次写的结果(`Skipped` 的载荷给诊断用;界面只关心 [`WriteOutcome::is_saved`])。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WriteOutcome { + /// 数据根解析不出来(内存态桥):什么都没写。 + NoStorage, + /// 闭包说没有改动:没落盘。 + Unchanged, + /// 真的写进磁盘了。 + Saved, + /// `save()` 跳过了(只读 / 不可信文件 / 没有脏键)。 + Skipped(SkipReason), + /// 写盘报错(序列化 / 备份 / IO)。 + Failed, +} + +impl WriteOutcome { + /// 磁盘上真的有新内容吗(**只有这一档算成功**)。 + #[must_use] + pub(crate) const fn is_saved(self) -> bool { + matches!(self, Self::Saved) + } +} + +impl SettingsBridge { + /// 长生命 store 的**唯一写入口**:现读 → 改 → 落盘 → 刷 mtime。 + /// + /// `edit` 拿到的 `&mut SettingStore` **已经是刚从磁盘重读的那一份**(这一句就是 r1/r2 两个 + /// 缺陷的修法:任何入口都不许拿启动时的旧快照整份覆盖)。 + pub(crate) fn write_with(&mut self, edit: F) -> WriteOutcome + where + F: FnOnce(&mut SettingStore) -> Edit, + { + let Some(store) = self.store.as_mut() else { + return WriteOutcome::NoStorage; + }; + // ← 这一行是两轮复审的修复本体。 + store.reload(); + match edit(store) { + Edit::Changed => {} + Edit::Unchanged => return WriteOutcome::Unchanged, + Edit::Rejected => return WriteOutcome::Failed, + } + let outcome = match store.save() { + Ok(SaveOutcome::Saved(_)) => WriteOutcome::Saved, + Ok(SaveOutcome::Skipped(reason)) => WriteOutcome::Skipped(reason), + Err(_) => WriteOutcome::Failed, + }; + if let Some(path) = self.path() { + self.last_mtime = mtime(path); + } + outcome + } +} diff --git a/crates/presenter/src/settings_bridge_lang.rs b/crates/presenter/src/settings_bridge_lang.rs index b433302..b1bb957 100644 --- a/crates/presenter/src/settings_bridge_lang.rs +++ b/crates/presenter/src/settings_bridge_lang.rs @@ -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` 的往返测试)。