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:
1 parent
e77a5df6e8
commit
ce2c42032b
8 files changed
+513
-39
No files matched your search
@@ -0,0 +1,301 @@
|
||||
//! 系统语言的**真值源**(D28):Windows 走 `reg.exe` 读注册表——**零 `unsafe`、零 FFI、零新依赖**。
|
||||
//!
|
||||
//! ## 为什么是子进程而不是 FFI
|
||||
//!
|
||||
//! 先例是 M73 的壁纸探测(`crates/core/src/platform/wallpaper/windows.rs`):同样是「Windows 上
|
||||
//! 只有注册表知道答案」的问题,它用 `reg.exe query` 解决,`unsafe` 一个没加。本 crate 的 `unsafe`
|
||||
//! 只允许出现在 [`crate::platform::win32`](D36 的纪律),所以这里照抄那条路。
|
||||
//!
|
||||
//! 为什么不 `GetUserDefaultLocaleName`:它要 FFI,而它对**本用途**并不比注册表准——Windows 的
|
||||
//! 「区域格式」与「显示语言」本来就是两个设置,两者都有各自的说法(见下面的诚实边界)。
|
||||
//!
|
||||
//! ## 读什么、按什么顺序
|
||||
//!
|
||||
//! 1. `HKCU\Control Panel\International` 的 `LocaleName`——Win7+ 都有,值就是 `zh-CN` 这种标签,
|
||||
//! [`unlucky_i18n::Lang::from_locale`] 直接吃得下;
|
||||
//! 2. 读不到 `LocaleName` 时退到同一个键的 `Locale`(8 位十六进制 LCID,如 `00000804`),
|
||||
//! 用一张**很短的**表翻成标签;表里没有的返回 `None`(宁可英文,也不猜错语种)。
|
||||
//!
|
||||
//! 优先级链在 [`unlucky_i18n::lang::resolve`]:**`ui.language` 显式值 > 本模块 > 环境变量 > 英文**。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:`reg query` 输出的解析(含 CRLF、空值、值名大小写)、`LocaleName` 缺失时退 LCID
|
||||
//! 的分支、命令失败/缺失时的 `None`;
|
||||
//! - **抓不住**:①`LocaleName` 是**区域格式**,用户完全可能在设置里把「显示语言」改成英文而区域
|
||||
//! 仍是 `zh-CN`(或反过来)——真要读显示语言得 `GetUserDefaultUILanguage`(FFI)或多语言
|
||||
//! 用户界面注册表(`...\Control Panel\Desktop` 的 `PreferredUILanguages`,本 mission 未做);
|
||||
//! ②策略禁用 `reg.exe` / Wine 环境 → `None` → 落到环境变量 → 英文;
|
||||
//! ③代码页(`chcp`)与这里无关,注册表值不受它影响。
|
||||
|
||||
use unlucky_i18n::lang::LocaleSource;
|
||||
|
||||
/// 语言相关的注册表键。
|
||||
const INTERNATIONAL_KEY: &str = r"HKCU\Control Panel\International";
|
||||
|
||||
/// 首选值名:值是 BCP-47 风格标签(`zh-CN`)。
|
||||
const LOCALE_NAME_VALUE: &str = "LocaleName";
|
||||
|
||||
/// 兜底值名:值是 8 位十六进制 LCID(`00000804`)。
|
||||
const LOCALE_VALUE: &str = "Locale";
|
||||
|
||||
/// 常见 LCID → 语言的短表(**故意短**:不认识的返回 `None`,让链路落到英文)。
|
||||
///
|
||||
/// 覆盖面:中文四地 + 英语三地 + 几个大语种。这张表只在 `LocaleName` 缺失时用到,
|
||||
/// 而 `LocaleName` 从 Win7 起就有,所以它实际上是「老系统 / 被裁剪的系统」专用。
|
||||
const LCID_TAGS: &[(&str, &str)] = &[
|
||||
("00000804", "zh-CN"),
|
||||
("00000404", "zh-TW"),
|
||||
("00000c04", "zh-HK"),
|
||||
("00001004", "zh-SG"),
|
||||
("00000409", "en-US"),
|
||||
("00000809", "en-GB"),
|
||||
("00000c09", "en-AU"),
|
||||
("00000411", "ja-JP"),
|
||||
("00000412", "ko-KR"),
|
||||
("00000407", "de-DE"),
|
||||
("0000040c", "fr-FR"),
|
||||
("00000410", "it-IT"),
|
||||
("00000c0a", "es-ES"),
|
||||
("00000416", "pt-BR"),
|
||||
("00000419", "ru-RU"),
|
||||
("00000415", "pl-PL"),
|
||||
("0000041f", "tr-TR"),
|
||||
("00000406", "da-DK"),
|
||||
("0000040b", "fi-FI"),
|
||||
("00000413", "nl-NL"),
|
||||
];
|
||||
|
||||
/// 跑外部命令的接口(注入点:单测不 spawn 进程)。
|
||||
pub trait RegIo {
|
||||
/// 跑一条命令取 stdout;命令缺失 / 非零退出 / 空输出都返回 `None`。
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
|
||||
}
|
||||
|
||||
/// 真实实现:`reg.exe` 子进程(非 Windows 上恒 `None`)。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HostReg;
|
||||
|
||||
impl RegIo for HostReg {
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
if !cfg!(windows) {
|
||||
// 其它平台的「系统语言」真值源不是注册表;`resolve` 会继续往环境变量走。
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
// `Command::output` 对非零退出同样返回 `Ok`,退出码要单独判(`reg.exe` 查不到值就是非零)。
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统语言源(实现 [`LocaleSource`],日志里自称 `registry`)。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SystemLocale;
|
||||
|
||||
impl LocaleSource for SystemLocale {
|
||||
fn locale(&self) -> Option<String> {
|
||||
detect_with(&HostReg)
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"registry"
|
||||
}
|
||||
}
|
||||
|
||||
/// 用给定 IO 读一次系统语言(`LocaleName` 优先,退 `Locale` 的 LCID)。
|
||||
pub fn detect_with(io: &dyn RegIo) -> Option<String> {
|
||||
let by_name = io
|
||||
.run(
|
||||
"reg.exe",
|
||||
&["query", INTERNATIONAL_KEY, "/v", LOCALE_NAME_VALUE],
|
||||
)
|
||||
.and_then(|text| parse_reg_sz(&text, LOCALE_NAME_VALUE))
|
||||
.filter(|value| looks_like_tag(value));
|
||||
if by_name.is_some() {
|
||||
return by_name;
|
||||
}
|
||||
|
||||
io.run("reg.exe", &["query", INTERNATIONAL_KEY, "/v", LOCALE_VALUE])
|
||||
.and_then(|text| parse_reg_sz(&text, LOCALE_VALUE))
|
||||
.and_then(|lcid| lcid_tag(&lcid))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// 取 `reg query <键> /v <值名>` 输出里某个 `REG_SZ` 值的原文。
|
||||
///
|
||||
/// 真机输出形如(本机 Windows 11 24H2 实测,CRLF):
|
||||
///
|
||||
/// ```text
|
||||
/// HKEY_CURRENT_USER\Control Panel\International
|
||||
/// LocaleName REG_SZ zh-CN
|
||||
/// ```
|
||||
///
|
||||
/// 值名大小写不敏感(`reg.exe` 原样回显查询时给的名字);值可能含空格,所以「值名 + 类型」
|
||||
/// 之后整段取原文,不 `split_whitespace` 再拼(会吃掉连续空格)。
|
||||
pub fn parse_reg_sz(text: &str, value_name: &str) -> Option<String> {
|
||||
text.lines().find_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let name_end = trimmed.find(char::is_whitespace)?;
|
||||
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
|
||||
return None;
|
||||
}
|
||||
let after_name = trimmed[name_end..].trim_start();
|
||||
let kind_end = after_name.find(char::is_whitespace)?;
|
||||
let value = after_name[kind_end..].trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// LCID(十六进制原文,如 `00000804`)→ 标签(大小写不敏感)。
|
||||
pub fn lcid_tag(lcid: &str) -> Option<&'static str> {
|
||||
LCID_TAGS
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(lcid.trim()))
|
||||
.map(|(_, tag)| *tag)
|
||||
}
|
||||
|
||||
/// 像不像一个语言标签(`zh-CN` / `en` / `zh-Hans-CN`)。
|
||||
///
|
||||
/// 只拦明显不是标签的东西(空值、带空格、带路径分隔符)——`reg.exe` 在某些策略下会回
|
||||
/// `REG_SZ` 空串或一长串本地化文本,那些不该被当成语言喂给 `from_locale`。
|
||||
fn looks_like_tag(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 35
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 本机 Windows 11 24H2 实测抓下来的两段原文(含 `\r\n`)。
|
||||
const REAL_LOCALE_NAME: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n LocaleName REG_SZ zh-CN\r\n\r\n";
|
||||
const REAL_LOCALE: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n Locale REG_SZ 00000804\r\n\r\n";
|
||||
|
||||
struct FakeReg {
|
||||
name: Option<&'static str>,
|
||||
locale: Option<&'static str>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl FakeReg {
|
||||
fn both(name: Option<&'static str>, locale: Option<&'static str>) -> Self {
|
||||
FakeReg {
|
||||
name,
|
||||
locale,
|
||||
fail: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegIo for FakeReg {
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
assert_eq!(program, "reg.exe", "程序名写错就不是注册表读了");
|
||||
assert_eq!(args[0], "query");
|
||||
assert_eq!(args[1], INTERNATIONAL_KEY);
|
||||
assert_eq!(args[2], "/v");
|
||||
if self.fail {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = match args[3] {
|
||||
LOCALE_NAME_VALUE => self.name?,
|
||||
LOCALE_VALUE => self.locale?,
|
||||
other => panic!("没料到的值名:{other}"),
|
||||
};
|
||||
Some(format!(
|
||||
"\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n {} REG_SZ {raw}\r\n\r\n",
|
||||
args[3]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_registry_output_parses_to_the_locale_tag() {
|
||||
// 抓得住:解析器与真机 `reg query` 输出格式之间的契约(CRLF、前导空白、值名与类型之间的
|
||||
// 多个空格)。这两段夹具是真机抓的原文,不是我编的。
|
||||
// 抓不住:这台机器的值本身会变(用户改区域设置)——测试只钉格式,不钉具体语言。
|
||||
assert_eq!(
|
||||
parse_reg_sz(REAL_LOCALE_NAME, LOCALE_NAME_VALUE).as_deref(),
|
||||
Some("zh-CN")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_reg_sz(REAL_LOCALE, LOCALE_VALUE).as_deref(),
|
||||
Some("00000804")
|
||||
);
|
||||
// 值名大小写不敏感(reg.exe 会原样回显查询里给的名字)
|
||||
assert_eq!(
|
||||
parse_reg_sz(REAL_LOCALE_NAME, "localename").as_deref(),
|
||||
Some("zh-CN")
|
||||
);
|
||||
// 空值 = 没设(不是空串语言)
|
||||
let empty = " LocaleName REG_SZ \r\n";
|
||||
assert_eq!(parse_reg_sz(empty, LOCALE_NAME_VALUE), None);
|
||||
// 别的值名不该被误取
|
||||
assert_eq!(parse_reg_sz(REAL_LOCALE_NAME, "Locale"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcid_fallback_covers_chinese_and_english() {
|
||||
// 抓得住:LCID 兜底表的关键格子(中文四地 + 英语三地)被改动或删掉;
|
||||
// 以及「不认识就 None」这条(不许猜成某个语言)。
|
||||
// 抓不住:这 20 条之外的语言——没有 `LocaleName` 的老系统上它们会落到英文。
|
||||
assert_eq!(lcid_tag("00000804"), Some("zh-CN"));
|
||||
assert_eq!(lcid_tag("00000804\r"), Some("zh-CN"), "要去空白");
|
||||
assert_eq!(lcid_tag("00000404"), Some("zh-TW"));
|
||||
assert_eq!(lcid_tag("00000C04"), Some("zh-HK"), "大小写不敏感");
|
||||
assert_eq!(lcid_tag("00001004"), Some("zh-SG"));
|
||||
assert_eq!(lcid_tag("00000409"), Some("en-US"));
|
||||
assert_eq!(lcid_tag("00000411"), Some("ja-JP"));
|
||||
assert_eq!(lcid_tag("00000437"), None, "表外语言必须 None");
|
||||
assert_eq!(lcid_tag(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_prefers_locale_name_and_falls_back_to_lcid() {
|
||||
// 抓得住:两条取值路径的顺序(有 LocaleName 就不该再看 LCID),以及命令失败时的 None。
|
||||
// 抓不住:真机注册表里到底有什么(下面的 windows_live 测试才碰真机)。
|
||||
assert_eq!(
|
||||
detect_with(&FakeReg::both(Some("zh-CN"), Some("00000409"))).as_deref(),
|
||||
Some("zh-CN"),
|
||||
"LocaleName 优先"
|
||||
);
|
||||
assert_eq!(
|
||||
detect_with(&FakeReg::both(None, Some("00000804"))).as_deref(),
|
||||
Some("zh-CN"),
|
||||
"没有 LocaleName 才退 LCID"
|
||||
);
|
||||
assert_eq!(detect_with(&FakeReg::both(None, None)), None);
|
||||
assert_eq!(
|
||||
detect_with(&FakeReg {
|
||||
name: Some("zh-CN"),
|
||||
locale: Some("00000804"),
|
||||
fail: true,
|
||||
}),
|
||||
None,
|
||||
"命令失败就是 None,不许猜"
|
||||
);
|
||||
// 值名对但值不像标签(策略回了一串本地化文本)→ 不许当成语言
|
||||
assert_eq!(detect_with(&FakeReg::both(Some("中文(简体)"), None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_live_registry_query_returns_a_tag() {
|
||||
// 抓得住:`reg.exe` 这条路在本机真的通(spawn 成功、退出码为 0、输出能被解析)。
|
||||
// 抓不住:它读到的「区域格式」是否等于用户想用的**界面语言**(见模块文档的诚实边界)。
|
||||
let tag = SystemLocale.locale().expect("本机应当读得到 LocaleName");
|
||||
assert!(!tag.is_empty());
|
||||
assert!(looks_like_tag(&tag), "解析出来的不是语言标签:{tag:?}");
|
||||
assert_eq!(SystemLocale.label(), "registry");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
||||
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
||||
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。
|
||||
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
|
||||
//!
|
||||
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
|
||||
//! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示
|
||||
@@ -13,6 +14,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
use crate::Backend;
|
||||
|
||||
pub mod locale;
|
||||
pub mod win32;
|
||||
|
||||
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
|
||||
|
||||
Reference in new issue
Block a user