复审 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 已另记)。
388 lines
15 KiB
Rust
388 lines
15 KiB
Rust
//! 语言与系统 locale 探测(D28 前半)。
|
||
//!
|
||
//! ## 判据只有两条
|
||
//!
|
||
//! 1. **中文环境**(`zh*` 任意变体)→ [`Lang::ZhCn`];
|
||
//! 2. **其它一切**(`en*`、`ja*`、`fr*`、`C`、`POSIX`、变量缺失、解析不了)→ [`Lang::En`]。
|
||
//!
|
||
//! 「其它一切 → 英文」不是偷懒:D28 的原话是「纯英文环境不用管」,而把 ja/fr/de 猜成中文才是真错
|
||
//! (会连带触发中文字体下载)。宁可给一个英文界面,也不给一个语种错误的中文界面。
|
||
//!
|
||
//! ## 为什么 locale 源要能注入
|
||
//!
|
||
//! [`detect`] 的形状是 `detect(&dyn LocaleSource)`,[`EnvLocale`] 只是其中一个实现。这样
|
||
//! **单测不依赖跑测试那台机器的 locale**(真值表测试在 [`lang`] 自己的 `tests` 里跑固定输入),
|
||
//! 后续 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`);[`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;
|
||
|
||
use crate::catalog::Catalog;
|
||
|
||
/// 界面语言。目前只有中英两套(D28 的「至少 zh-CN / en-US」)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum Lang {
|
||
/// 英文(默认;未知 locale 也落在这里)。
|
||
En,
|
||
/// 简体中文。
|
||
ZhCn,
|
||
}
|
||
|
||
impl Lang {
|
||
/// 全部语言(顺序即设置页下拉框的顺序)。
|
||
pub const ALL: &'static [Lang] = &[Lang::En, Lang::ZhCn];
|
||
|
||
/// BCP-47 标签(日志 / 字体请求里用;`zh-CN` 是本 crate 唯一的中文标识)。
|
||
#[must_use]
|
||
pub const fn tag(self) -> &'static str {
|
||
match self {
|
||
Lang::En => "en",
|
||
Lang::ZhCn => "zh-CN",
|
||
}
|
||
}
|
||
|
||
/// 配置值(`core::setting::keys::LANGUAGES` 的同一份词表)。
|
||
#[must_use]
|
||
pub const fn setting_value(self) -> &'static str {
|
||
match self {
|
||
Lang::En => "en_us",
|
||
Lang::ZhCn => "zh_cn",
|
||
}
|
||
}
|
||
|
||
/// 解析**配置值**(严格):只认 [`Lang::setting_value`] 那两个字符串。
|
||
///
|
||
/// 与宽松的 [`Lang::from_locale`] 分工明确:配置是我们自己写进去的,写错就该暴露(`None` 让设置层
|
||
/// 报错),而不是悄悄换成别的语言;系统 locale 是别人给的,只能尽量理解。
|
||
#[must_use]
|
||
pub fn from_setting(value: &str) -> Option<Lang> {
|
||
Lang::ALL
|
||
.iter()
|
||
.copied()
|
||
.find(|lang| lang.setting_value() == value)
|
||
}
|
||
|
||
/// 解析 **locale / 语言标签**(宽松):`zh`、`zh_CN.UTF-8`、`zh-Hans-CN`、`ZH_cn` 都是中文。
|
||
///
|
||
/// 只看**主语言子标签**:`zh-TW` / `zh-Hant` 也归 [`Lang::ZhCn`]——仓内只有一套中文目录,
|
||
/// 繁体用户看简体比看英文更有用;真要繁体得加第三套目录(见 `bridge` 的上游约定)。
|
||
#[must_use]
|
||
pub fn from_locale(tag: &str) -> Lang {
|
||
let trimmed = tag.trim();
|
||
// `zh_CN.UTF-8@euro`:codeset 与修饰符与语种无关,先切掉。
|
||
let without_codeset = trimmed.split('.').next().unwrap_or("");
|
||
let without_modifier = without_codeset.split('@').next().unwrap_or("");
|
||
let primary = without_modifier
|
||
.split(['-', '_'])
|
||
.next()
|
||
.unwrap_or("")
|
||
.to_ascii_lowercase();
|
||
|
||
if primary == "zh" {
|
||
Lang::ZhCn
|
||
} else {
|
||
Lang::En
|
||
}
|
||
}
|
||
|
||
/// 本语言的消息目录。
|
||
#[must_use]
|
||
pub const fn catalog(self) -> Catalog {
|
||
Catalog::of(self)
|
||
}
|
||
}
|
||
|
||
/// 没有任何 locale 信息时的落点(D28:纯英文环境不用管)。
|
||
impl Default for Lang {
|
||
fn default() -> Self {
|
||
Lang::En
|
||
}
|
||
}
|
||
|
||
/// locale 的来源。实现方负责回答「这台机器说哪种语言」。
|
||
pub trait LocaleSource {
|
||
/// 原始 locale 标签(`None` = 取不到,不是「英文」的断言)。
|
||
fn locale(&self) -> Option<String>;
|
||
|
||
/// 这个源在启动日志里的名字(`registry` / `env` / …)。默认 `injected`(测试用)。
|
||
fn label(&self) -> &'static str {
|
||
"injected"
|
||
}
|
||
}
|
||
|
||
/// [`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;
|
||
|
||
impl LocaleSource for EnvLocale {
|
||
fn locale(&self) -> Option<String> {
|
||
// POSIX 优先级:LC_ALL > LC_MESSAGES > LANG。空串按「没设置」处理。
|
||
["LC_ALL", "LC_MESSAGES", "LANG"]
|
||
.iter()
|
||
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
|
||
}
|
||
|
||
fn label(&self) -> &'static str {
|
||
ORIGIN_ENV
|
||
}
|
||
}
|
||
|
||
/// 探测结论(`raw` 与 `origin` 一起带出来,是为了日志能解释「为什么是英文」)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Detection {
|
||
/// 结论。
|
||
pub lang: Lang,
|
||
/// 原始 locale 标签(`None` = 一个源都没取到)。
|
||
pub raw: Option<String>,
|
||
/// 结论的来源(`setting` / 平台源自己的 [`LocaleSource::label`] / `env` / `default`)。
|
||
pub origin: &'static str,
|
||
}
|
||
|
||
impl Detection {
|
||
/// 一行日志(`lang=zh-CN locale=zh-CN source=registry` / `lang=en locale=<未设置> source=default`)。
|
||
#[must_use]
|
||
pub fn describe(&self) -> String {
|
||
format!(
|
||
"lang={} locale={} source={}",
|
||
self.lang.tag(),
|
||
self.raw.as_deref().unwrap_or("<未设置>"),
|
||
self.origin
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 用给定来源探测语言。
|
||
#[must_use]
|
||
pub fn detect(source: &dyn LocaleSource) -> Detection {
|
||
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::*;
|
||
|
||
struct Fixed(Option<&'static str>);
|
||
|
||
impl LocaleSource for Fixed {
|
||
fn locale(&self) -> Option<String> {
|
||
self.0.map(str::to_owned)
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn locale_truth_table() {
|
||
// 抓得住:主语言子标签的解析(分隔符 / 大小写 / codeset / 修饰符 / 变体)。
|
||
// 抓不住:这些字符串是否真的是这台机器的 locale——真值来自 LocaleSource 实现。
|
||
let zh = [
|
||
"zh",
|
||
"zh_CN",
|
||
"zh_CN.UTF-8",
|
||
"zh-CN",
|
||
"zh-Hans-CN",
|
||
"zh_hans_cn",
|
||
"ZH_cn",
|
||
" zh_TW ",
|
||
"zh_CN.GB18030@euro",
|
||
];
|
||
for tag in zh {
|
||
assert_eq!(Lang::from_locale(tag), Lang::ZhCn, "{tag}");
|
||
}
|
||
|
||
let en = [
|
||
"",
|
||
"C",
|
||
"POSIX",
|
||
"en",
|
||
"en_US.UTF-8",
|
||
"en-GB",
|
||
"ja_JP.UTF-8",
|
||
"fr_FR@euro",
|
||
"de",
|
||
// 不是中文但含 zh 之外的字:必须落英文,不许猜成中文
|
||
"zhuang",
|
||
];
|
||
for tag in en {
|
||
assert_eq!(Lang::from_locale(tag), Lang::En, "{tag}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn detect_falls_back_to_english_without_a_locale() {
|
||
// 抓得住:取不到 locale 时**不 panic、不猜中文**(D28 的「纯英文环境不用管」)。
|
||
// 抓不住: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.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.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"), ®istry_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, ®istry_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, ¬hing, &env_en);
|
||
assert_eq!(
|
||
(detection.lang, detection.origin),
|
||
(Lang::En, "env"),
|
||
"环境变量说是英文,但来源必须是 env"
|
||
);
|
||
// ④ 全没有 → 英文 + default
|
||
let detection = resolve(None, ¬hing, &Named("env", None));
|
||
assert_eq!(
|
||
(detection.lang, detection.origin),
|
||
(Lang::En, ORIGIN_DEFAULT)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn setting_values_match_the_config_vocabulary() {
|
||
// 抓得住:本 crate 的配置词表被改动(改名会让已落盘的 ui.language 读不出来)。
|
||
// 抓不住:`core::setting::keys::LANGUAGES` 那边先改了——本 crate 不依赖 core(零依赖纪律),
|
||
// 所以这条只能靠接线 mission 写一条跨 crate 的 pin 测试,或让 core 反向引用这份词表。
|
||
let values = Lang::ALL
|
||
.iter()
|
||
.map(|lang| lang.setting_value())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(values, vec!["en_us", "zh_cn"]);
|
||
assert_eq!(Lang::from_setting("zh_cn"), Some(Lang::ZhCn));
|
||
assert_eq!(Lang::from_setting("en_us"), Some(Lang::En));
|
||
}
|
||
|
||
#[test]
|
||
fn setting_parsing_is_strict_where_locale_parsing_is_lenient() {
|
||
// 抓得住:宽松解析被误用到配置值上(`zh-CN` 是合法 locale 但不是合法配置值)。
|
||
// 抓不住:有人把非法值直接塞进配置(那是设置层的 schema 校验与该键的 enumeration 约束)。
|
||
for bogus in ["zh-CN", "ZH_CN", "zh", "", "en"] {
|
||
assert_eq!(Lang::from_setting(bogus), None, "{bogus}");
|
||
}
|
||
assert_eq!(Lang::from_locale("zh-CN"), Lang::ZhCn);
|
||
}
|
||
}
|