fix(i18n): M92 r1 —— Windows 真 locale 走注册表子进程(零 FFI)+ 删掉假守卫

复审 p1:`detect_env()` 在 Windows 上恒为 `en`(`LC_ALL/LC_MESSAGES/LANG` 都不存在),
D28 整条中文链(探测 → 扫 CJK → 需下载)在基线平台永不触发。复审在本机(zh-CN)实测
`lang=en raw=None`,这条我认。

**p1 修法(tower 定路线:子进程读注册表,不碰 FFI、不加依赖)**
- 新增 `crates/app-iced/src/platform/locale.rs`:`SystemLocale` 实现
  `unlucky_i18n::lang::LocaleSource`,内部 `reg.exe query "HKCU\Control Panel\International"
  /v LocaleName`(本机实测 `REG_SZ zh-CN`),读不到时退 `/v Locale` 的十六进制 LCID
  (本机 `00000804`),用一张 20 条的短表翻成标签,表外语言返回 `None`(宁可英文,不猜语种)。
  先例同 M73 壁纸探测(`core::platform::wallpaper` 的 `reg.exe` 路线)。
- 子进程走注入的 `RegIo` 接口,单测用 `FakeReg`(不 spawn);夹具是**本机真机抓的原文**
  (含 CRLF / 前导空白),另有一条 `#[cfg(windows)]` 真机测试证明 `reg.exe` 这条路真的通。
- i18n 仍是纯函数:`lang::resolve(setting, platform, env)` 是四级优先级的唯一实现——
  **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**,`Detection` 新增 `origin`
  (`setting` / 源自报的 `label()` / `env` / `default`),启动日志因此能写清 `source=registry`。
  配置值不认识时**跳过**继续往下走,不当成任何一种语言。
- `fonts::cjk::probe_with(setting, faces)` 把 `ui.language` 那一级的位置先留好(目前传 `None`:
  设置层还没有读取点,写在模块文档的诚实边界里);`probe()` 走全链。
- `platform::mod` 的模块表补一行说明 locale 也是「零 unsafe」那一类。

**本机(zh-CN)实跑证据**(`--software-renderer --screenshot …`,exit 0):
`[font] … | lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei UI"`
——探测出中文、且走的是「扫 CJK 字体」而不是 `not-needed`。

**p2**:删掉 `catalog` 那条 `compile_fail` 守卫(复审两个变异仍全绿 ⇒ 它守的是「这段代码
编译不过」,不是「编译期校验还在」)。改为陈述事实:「`Key` 是枚举,拼错 `E0599`」,
并写明这里**没有**守卫测试及原因。

未动 `ui.language` 默认值(`core` schema 不在本 mission scope,tower 已另记)。
This commit is contained in:
AskaEth committed 2026-11-02 16:58:09 +08:00
1 parent e77a5df6e8
commit ce2c42032b
8 files changed
+513 -39

No files matched your search

+5 -8
View File
@@ -3,15 +3,12 @@
//! ## 为什么 key 是枚举而不是字符串
//!
//! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。
//! 枚举 key 把这类错误全部前移到**编译期**:
//! 枚举 key 把这类错误前移到**编译期**:`Key::NavInstance`(真名是 `NavInstances`)直接
//! `error[E0599]`,不可能跑到运行时。
//!
//! ```compile_fail
//! // `Key::NavInstance` 不存在(真名是 `NavInstances`)⇒ E0599,不会编译过。
//! let _ = unlucky_i18n::Key::NavInstance;
//! ```
//!
//! 这条 `compile_fail` 用例本身就是一个测试:哪天有人把 `Key` 改成字符串包装类型,它会**变绿失败**
//! (能编译过 = 编译期校验没了)。
//! **这里没有守卫测试**:曾经写过一个 `compile_fail` 文档测试想守这条,但复审实测它对
//! 「把 `Key` 换成字符串包装类型」这类变异仍然全绿——它守的是「这段代码编译不过」,而不是
//! 「编译期校验还在」。说不清真假的守卫比没有更糟,所以删了;这条纪律只靠枚举这个事实本身。
//!
//! ## 为什么两套译文写在**同一行**
//!
+148 -19
View File
@@ -15,14 +15,22 @@
//! 后续 mission 加「Win32 `GetUserDefaultLocaleName`」或「读配置里的 `ui.language`」也只是再实现一遍
//! 这个 trait。
//!
//! ## 优先级([`resolve`])
//!
//! **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。本 crate 只有 [`EnvLocale`]
//! 一个具体实现——平台真值源(Windows 注册表那一路)在 UI 壳层,因为读注册表要开子进程,
//! 而本 crate 的纪律是不碰外部世界。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符);
//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);
//! - **抓不住**:**Windows 上这三个变量通常都不存在**,`GetUserDefaultLocaleName` 才是真值源。
//! 本 mission 不碰 FFI(`app-iced::platform` 才是全仓唯一 `unsafe` 落点),所以 Windows 目前
//! 一律落到 [`Lang::En`];配置里的 `ui.language` 覆盖也是后续接线 mission 的事。两者都是**已知缺口**,
//! 不是「已完成」。
//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);[`resolve`] 的四级优先级;
//! - **抓不住**:**Windows 上这三个变量通常都不存在**——那条路的真值源是
//! `crates/app-iced/src/platform/locale.rs`(`reg.exe` 读注册表,零 FFI),
//! 它不在本 crate 里(本 crate 零依赖、不 spawn 进程)。所以「Windows 上拿不到语言」
//! 这件事只在**没有接线平台源**时成立,接线后由 `resolve` 的二级兜住。
//! 配置里的 `ui.language` 读取属于设置层接线(本 crate 只提供 [`Lang::from_setting`] 与
//! [`resolve`] 的形状)。
use std::env;
@@ -112,9 +120,21 @@ impl Default for Lang {
pub trait LocaleSource {
/// 原始 locale 标签(`None` = 取不到,不是「英文」的断言)。
fn locale(&self) -> Option<String>;
/// 这个源在启动日志里的名字(`registry` / `env` / …)。默认 `injected`(测试用)。
fn label(&self) -> &'static str {
"injected"
}
}
/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到)。
/// [`Detection::origin`]:结论来自 `ui.language` 的显式值。
pub const ORIGIN_SETTING: &str = "setting";
/// [`Detection::origin`]:结论来自环境变量([`EnvLocale`])。
pub const ORIGIN_ENV: &str = "env";
/// [`Detection::origin`]:一个源都没取到,落到默认英文。
pub const ORIGIN_DEFAULT: &str = "default";
/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到,见模块文档)。
#[derive(Debug, Clone, Copy, Default)]
pub struct EnvLocale;
@@ -125,25 +145,32 @@ impl LocaleSource for EnvLocale {
.iter()
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
}
fn label(&self) -> &'static str {
ORIGIN_ENV
}
}
/// 探测结论(`raw` 一起带出来,是为了日志能解释「为什么是英文」)。
/// 探测结论(`raw` 与 `origin` 一起带出来,是为了日志能解释「为什么是英文」)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detection {
/// 结论。
pub lang: Lang,
/// 原始 locale 标签(`None` = 一个变量都没取到)。
/// 原始 locale 标签(`None` = 一个源都没取到)。
pub raw: Option<String>,
/// 结论的来源(`setting` / 平台源自己的 [`LocaleSource::label`] / `env` / `default`)。
pub origin: &'static str,
}
impl Detection {
/// 一行日志(`lang=zh-CN locale=zh_CN.UTF-8` / `lang=en locale=<未设置>`)。
/// 一行日志(`lang=zh-CN locale=zh-CN source=registry` / `lang=en locale=<未设置> source=default`)。
#[must_use]
pub fn describe(&self) -> String {
format!(
"lang={} locale={}",
"lang={} locale={} source={}",
self.lang.tag(),
self.raw.as_deref().unwrap_or("<未设置>")
self.raw.as_deref().unwrap_or("<未设置>"),
self.origin
)
}
}
@@ -151,17 +178,59 @@ impl Detection {
/// 用给定来源探测语言。
#[must_use]
pub fn detect(source: &dyn LocaleSource) -> Detection {
let raw = source.locale();
let lang = raw.as_deref().map_or(Lang::En, Lang::from_locale);
Detection { lang, raw }
match source.locale() {
Some(raw) => Detection {
lang: Lang::from_locale(&raw),
raw: Some(raw),
origin: source.label(),
},
None => Detection {
lang: Lang::En,
raw: None,
origin: ORIGIN_DEFAULT,
},
}
}
/// 用进程环境变量探测语言(启动日志用)。
/// 用进程环境变量探测语言。
#[must_use]
pub fn detect_env() -> Detection {
detect(&EnvLocale)
}
/// 全量优先级:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。
///
/// - `setting`:配置里 `ui.language` 的原文(`zh_cn` / `en_us`)。给了但不认识 → **跳过**
/// (当作「没配」继续往下走,而不是把它猜成某种语言);
/// - `platform`:平台真值源(Windows 上是 `app-iced::platform::locale` 读注册表;其它平台没有);
/// - `env`:`LC_ALL > LC_MESSAGES > LANG`([`EnvLocale`]);
/// - 都不给 → [`Lang::En`],`origin = default`。
///
/// 这正是 D28 要的顺序:**用户手选的**压过系统,系统压过环境变量,都没有就英文。
#[must_use]
pub fn resolve(
setting: Option<&str>,
platform: &dyn LocaleSource,
env: &dyn LocaleSource,
) -> Detection {
if let Some(value) = setting {
if let Some(lang) = Lang::from_setting(value) {
return Detection {
lang,
raw: Some(value.to_owned()),
origin: ORIGIN_SETTING,
};
}
}
let from_platform = detect(platform);
if from_platform.raw.is_some() {
return from_platform;
}
detect(env)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -214,22 +283,82 @@ mod tests {
#[test]
fn detect_falls_back_to_english_without_a_locale() {
// 抓得住:取不到 locale 时**不 panic、不猜中文**(D28 的「纯英文环境不用管」)。
// 抓不住:Windows 上「取不到」是否等于用户真的说英文——这正是本 crate 的已知缺口。
// 抓不住:Windows 上「环境变量取不到」是否等于用户真的说英文——那是平台源的活
// (`app-iced::platform::locale`),见下面 resolve 的优先级测试。
let detection = detect(&Fixed(None));
assert_eq!(detection.lang, Lang::En);
assert_eq!(detection.raw, None);
assert_eq!(detection.describe(), "lang=en locale=<未设置>");
assert_eq!(detection.origin, ORIGIN_DEFAULT);
assert_eq!(
detection.describe(),
"lang=en locale=<未设置> source=default"
);
assert_eq!(Lang::default(), Lang::En);
}
#[test]
fn detect_uses_the_injected_source_verbatim() {
// 抓得住:探测结论确实来自注入源(不会被本机环境变量污染)。
// 抓得住:探测结论确实来自注入源(不会被本机环境变量污染),且 origin 取自源自报的名字。
// 抓不住:EnvLocale 到底读了哪几个变量(那条路径只能靠人工/集成测试看日志)。
let detection = detect(&Fixed(Some("zh_CN.UTF-8")));
assert_eq!(detection.lang, Lang::ZhCn);
assert_eq!(detection.raw.as_deref(), Some("zh_CN.UTF-8"));
assert_eq!(detection.describe(), "lang=zh-CN locale=zh_CN.UTF-8");
assert_eq!(detection.origin, "injected");
assert_eq!(
detection.describe(),
"lang=zh-CN locale=zh_CN.UTF-8 source=injected"
);
assert_eq!(EnvLocale.label(), "env");
}
#[test]
fn resolve_priority_is_setting_then_platform_then_env_then_english() {
// 抓得住:四级优先级的顺序本身(用户手选压过系统,系统压过环境变量,都没有才英文);
// 以及「配置值不认识时不当成任何一种语言,继续往下走」。
// 抓不住:这些源在真机上到底读到什么(平台源的真机行为在 app-iced 侧测)。
struct Named(&'static str, Option<&'static str>);
impl LocaleSource for Named {
fn locale(&self) -> Option<String> {
self.1.map(str::to_owned)
}
fn label(&self) -> &'static str {
self.0
}
}
let registry_zh = Named("registry", Some("zh-CN"));
let env_en = Named("env", Some("en_US.UTF-8"));
let nothing = Named("registry", None);
// ① 配置显式值压过一切
let detection = resolve(Some("en_us"), &registry_zh, &env_en);
assert_eq!(
(detection.lang, detection.origin),
(Lang::En, ORIGIN_SETTING)
);
// ② 没配置(或配置不认识)→ 平台源
for setting in [None, Some("zh-CN"), Some("")] {
let detection = resolve(setting, &registry_zh, &env_en);
assert_eq!(
(detection.lang, detection.raw.as_deref(), detection.origin),
(Lang::ZhCn, Some("zh-CN"), "registry"),
"setting={setting:?}"
);
}
// ③ 平台源没有 → 环境变量
let detection = resolve(None, &nothing, &env_en);
assert_eq!(
(detection.lang, detection.origin),
(Lang::En, "env"),
"环境变量说是英文,但来源必须是 env"
);
// ④ 全没有 → 英文 + default
let detection = resolve(None, &nothing, &Named("env", None));
assert_eq!(
(detection.lang, detection.origin),
(Lang::En, ORIGIN_DEFAULT)
);
}
#[test]
+4 -1
View File
@@ -6,11 +6,14 @@
//!
//! | 模块 | 职责 | 不负责 |
//! |---|---|---|
//! | [`lang`] | `Lang`(en / zh-CN)+ 系统 locale 探测(locale 源可注入) | 读配置(`ui.language` 由壳层读) |
//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 |
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
//!
//! 语言从哪来的优先级写在 [`lang::resolve`]:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。
//! 平台源(Windows 上 `reg.exe` 读注册表,零 FFI)在 UI 壳层实现并注入——本 crate 不认平台。
//!
//! ## 三条硬规矩(细则见 [`bridge`])
//!
//! 1. **一句话 = 一个 key**。UI 侧不得把片段拼成一句话,尤其不得拼中文: