Files
Unlucky-Launcher/crates/app-iced/src/renderer/acceptance/locale.rs
T
AskaEth 8153e54f87 fix(acceptance): M98 r1 —— [window] 绑定真 settings、语言维度补断言、boot.rs 拆分
复审 r1(reviewer-accscope-code,p2-2items / fix-then-merge)的两条阻塞 + 四条顺手的 p3。

## p2-1 `[window]` 的三个窗口属性曾是格式串常量(U1:常量冒充观测结果)

第一版把 `resizable=true decorations=false transparent=false` 写在格式化串里,与交给 iced 的
`window::Settings` 没有任何绑定;定向变异把 `settings.decorations` 改成 `true`,**371 个测试全绿**,
而验收包里 `[caps]` 与 `[window]` 会互相矛盾。

- 新增 `acceptance::window::WindowFacts` + `from_settings()`:字段只能从**真实 settings** 抽出来,
  没有手抄入口;`window_text()` 逐字段打印。
- 窗口设置收成单一产地 `app/boot/diagnostics::window_settings()`,`launch()` 与 `[window]`/`[caps]`
  两行日志共用同一个 `&window::Settings`;`pages/harness.rs` 同样从自己那份 settings 取。
- 守门测试 `window_settings_match_what_the_acceptance_logs_claim`,含「from_settings 必须逐字段
  搬运」的反向断言。变异实证:`window_settings` 的 `decorations → true` ⇒ 红并点名;
  `from_settings` 里写死 `decorations: false` ⇒ 红(第一次写测试时这条漏抓,补强后实测红)。

## p2-2 语言维度空转、且没有任何结论断言

`lang::resolve` 是 setting → 平台源 → env,macOS 的 AppleLanguages 几乎恒有值 ⇒ `LANG=zh_CN` /
`LANG=en_US` 两档同值,而原脚本只断言「`[i18n]` 这行存在」——整条链退化成
`source=default lang=en` 时核心集一条都不红。

- 脚本加两条结论断言:`^\[i18n\].*platform=applelanguages:[A-Za-z-]+` 与 ` source=applelanguages`
  (退化日志上实测红、健康日志上实测绿)。
- `REPORT.md` 语言行下注明:env 优先级低于平台源,正常 Mac 上两档同值,这一维验的是「平台源
  真读到了 + 中文字体回退」,不是中英对照。

## p3(复审 + 第二路自跑复核同源的三条)

- `[perf] boot probe=0ms` → `probe=skipped`(没计时就不报数;降级重开那一轮改为**真计时**)。
  变异实证:把 skipped 写成 0ms ⇒ `perf_lines_carry_measured_millis` 红。
- `[env] gpu=` 排序改成 `DiscreteGpu > IntegratedGpu > 其它`:本机原来报核显,而 iced 实际选的是
  独显(现在报 `AMD Radeon RX 6600 XT [DiscreteGpu]`,与 `Selected:` 一致)。
- `[i18n] setting=<未设置>` → `<未接线>`(配置侧根本没读取点,写「未设置」等于把「我们没读」
  说成「用户没设」)。
- `[backend] final=` 失败行:`refuse` / `none` 两条路是 `final=none`(什么都没跑起来),只有
  `respawn` 才是兜底名;`pages/harness.rs` 不再写死 `via="refuse"`,改用共用的 `failure_via()`。
  实跑负例:`[backend] final=none result=failed primary=wgpu fallback=tiny-skia via=refuse`。
- 脚本 `[tick]` 那处唯一未锚定的 `field()`:加 `^` 锚定、正则改 `clock=[a-z0-9-]+`(真值是
  `idle-1hz`,旧 `[a-z]+` 把它截成 `idle`,靠前缀巧合通过);断言也改成 `clock=idle-1hz`。
- 脚本 `[backend] final` 取值改 `field_backend()`:优先 `result=started`(respawn 场景一条日志里
  有父 failed + 子 started 两条,旧 `head -1` 会把「这次没跑起来」写进报告)。
- 脚本 `exec 3>&1` 全程留痕(终端提示也进 `logs/00-run.log`)、跑前清空 `$OUT/shots/`、
  python3 缺失时在 REPORT 里写明「几何列未采」而不是留空。
- `renderer/mod.rs` 补一段依据:为什么「单项 ICED_BACKEND ⇒ 没有静默回退」成立(`iced_tiny_skia`
  的 `with_backend` 只接受 `tiny-skia`),以及它依赖 iced 实现细节的两层缓解。

## 结构

`app/boot.rs` 400 行零余量 → 拆成 `boot.rs`(248) + `boot/fallback.rs`(117,兜底/重开/退出码) +
`boot/diagnostics.rs`(110,窗口设置与四段报告);`run()` 回到「探测 → 字体 → 打印 → 起 iced」。
`acceptance` 再把 `[window]` 拆成 `window.rs`(142)。

门禁:`cargo fmt --check` / `clippy --all-targets -- -D warnings` / `test`(373 lib + 6 integration)全绿。
2026-11-03 12:59:57 +08:00

247 lines
9.1 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.
//! `[env]` 与 `[i18n]`:**本机是什么**(系统 / arch / 核数 / GPU / 时区)与**为什么是这个语言**。
//!
//! ## 语言链为什么在这里而不是 `platform/`
//!
//! `platform::locale::SystemLocale` 是 **Windows 注册表**那一路(`reg.exe`,零 FFI)。macOS 的
//! 真值源是 `defaults read -g AppleLanguages`,而 M98 的 scope 没有 `platform/**`,所以
//! [`AppleLanguages`] 先落在这里。**它该搬去 `platform/locale.rs`**——两处并存会漂(见交接报告)。
use unlucky_i18n::lang::{self, Detection, LocaleSource};
use crate::Probe;
// ---------------------------------------------------------------- [env]
/// `[env]`:系统 / arch / 核数 / 语言 / 时区 / 缩放请求 / GPU。
///
/// `gpu` 与 `hardware` **来自真实适配器枚举**([`Probe`]),不是请求值;枚举不到就写
/// `<无适配器>` 并把 `hardware=false` 一起打出来——「没有适配器」与「没枚举」是两件事。
///
/// `display=` 恒为 `needs-window`:显示器尺寸只有拿到窗口句柄才问得到,而这一行在开窗之前就打了。
/// 真值由验收脚本用 `system_profiler SPDisplaysDataType` 采进 `logs/00-env.txt`——**写个猜的值
/// 比留白更坏**(那会让「Monitor 4K / 无外接」这种结论变成猜测)。
#[must_use]
pub fn env_line(probe: &Probe, language: &str, scale: Option<f32>, tz: Option<&str>) -> String {
format!(
"[env] os={} arch={} cores={} language={language} tz={} scale_requested={} display=needs-window \
adapters={} hardware={} gpu=\"{}\"",
std::env::consts::OS,
std::env::consts::ARCH,
std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get),
tz.unwrap_or("<未设置>"),
scale.map_or_else(|| "system".to_owned(), |value| value.to_string()),
probe.adapters.len(),
probe.hardware,
gpu_summary(probe),
)
}
/// 枚举结果里**最该被当成 GPU** 的那个适配器的摘要:`DiscreteGpu > IntegratedGpu > 其它`。
///
/// 为什么不是「第一个非 Cpu/Other」(M98 r1 p3-2):本机枚举顺序是核显在前,于是 `[env] gpu=`
/// 报的是 `AMD Radeon(TM) Graphics [IntegratedGpu]`,而 iced 实际选的是
/// `AMD Radeon RX 6600 XT [DiscreteGpu]`。一行「机器有什么显卡」的日志报错型号,比不报更坏。
/// 判据仍然只管**型号名称**:真正的「iced 选了谁」只有 `[gpu] …Selected: AdapterInfo` 那行有。
#[must_use]
pub fn gpu_summary(probe: &Probe) -> String {
probe
.adapters
.iter()
.max_by_key(|adapter| gpu_rank(adapter.device_type))
.map_or_else(
|| "<无适配器>".to_owned(),
|adapter| format!("{} [{}]", adapter.name, adapter.device_type),
)
}
/// 适配器的「该不该被当成这台机器的显卡」排序权重(越大越该)。
fn gpu_rank(device_type: &str) -> u8 {
match device_type {
"DiscreteGpu" => 3,
"IntegratedGpu" => 2,
"VirtualGpu" => 1,
"Cpu" | "Other" => 0,
_ => 1,
}
}
/// 时区(`TZ` 环境变量)。`std` 拿不到系统时区,所以这里**只报环境变量**,没有就写未设置。
///
/// macOS 上的真值时区由脚本用 `date +%Z%z` 采(那份是观测值,这个只是进程视角)。
#[must_use]
pub fn tz() -> Option<String> {
std::env::var("TZ")
.ok()
.filter(|value| !value.trim().is_empty())
}
// ---------------------------------------------------------------- [i18n]
/// 语言决策链的四级(顺序即优先级:显式值 > 平台源 > 环境变量 > 英文)。
///
/// 每一级的**原始值**都留着,日志才能解释「为什么是英文」——只打结论的话,macOS 上「英文」
/// 到底是「用户就是英文」还是「一个源都没取到」根本分不出来。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chain {
/// `ui.language` 的显式值(当前没有读取点,恒 `None`)。
pub setting: Option<String>,
/// (源自称的名字, 原始 locale)。
pub platform: (&'static str, Option<String>),
/// (源自称的名字, 原始 locale)。
pub env: (&'static str, Option<String>),
/// 结论(由 [`lang::resolve`] 算,不在本模块重写一遍优先级)。
pub detection: Detection,
}
impl Chain {
/// 用三个源探测一次。**每个源只读一次**(`defaults` / `reg.exe` 都是子进程)。
#[must_use]
pub fn read(
setting: Option<&str>,
platform: &dyn LocaleSource,
env: &dyn LocaleSource,
) -> Self {
let platform = Captured {
label: platform.label(),
raw: platform.locale(),
};
let env = Captured {
label: env.label(),
raw: env.locale(),
};
// 优先级仍由 `resolve` 单点决定:这里把**已读到的值**包成同标签的源喂回去,
// 不复制一遍 if/else(复制就会漂)。
let detection = lang::resolve(setting, &platform, &env);
Self {
setting: setting.map(str::to_owned),
platform: (platform.label, platform.raw),
env: (env.label, env.raw),
detection,
}
}
/// 本机(平台源 + 环境变量;配置未接线)。
#[must_use]
pub fn host() -> Self {
Self::read(None, platform_locale_source(), &lang::EnvLocale)
}
/// 结论语言标签(`zh-CN` / `en`)。
#[must_use]
pub fn language(&self) -> &'static str {
self.detection.lang.tag()
}
/// `[i18n]` 一行:四级链 + 结论。
///
/// `ui_text=pinned-en` 是**今天的事实**:`pages/mod.rs::render` 里下载页固定传 `Lang::En`、
/// 日志页传 `model.lang`(默认也是 `En`),也就是**文案还没接这条结论**。不写这一格的话,
/// 日志会让人以为「语言已经是中文了」——那是新的假绿。
#[must_use]
pub fn line(&self) -> String {
format!(
"[i18n] setting={} platform={}:{} env={}:{} -> {} ui_text=pinned-en",
// `<未接线>` 而不是 `<未设置>`:配置侧的读取点**还不存在**(M98 r1 p3-3)。
// 写「未设置」会让读的人以为用户没配过;真实情况是我们没读。
self.setting.as_deref().unwrap_or("<未接线>"),
self.platform.0,
show(self.platform.1.as_deref()),
self.env.0,
show(self.env.1.as_deref()),
self.detection.describe()
)
}
}
fn show(value: Option<&str>) -> String {
value.map_or_else(|| "<未设置>".to_owned(), |raw| raw.to_owned())
}
/// 把值钉住的源(为了「读一次、按真优先级算」)。
struct Captured {
label: &'static str,
raw: Option<String>,
}
impl LocaleSource for Captured {
fn locale(&self) -> Option<String> {
self.raw.clone()
}
fn label(&self) -> &'static str {
self.label
}
}
/// 本平台的「系统语言」真值源。
///
/// - Windows:`reg.exe` 读注册表([`crate::platform::locale::SystemLocale`]);
/// - macOS:[`AppleLanguages`](`defaults read -g AppleLanguages`);
/// - 其它:没有平台源(`resolve` 会继续往环境变量走)。
///
/// 为什么 macOS 不复用 `SystemLocale`:它 `cfg!(windows)` 之外恒返回 `None`,在 Mac 上等于
/// 「没有平台源」——于是语言只剩环境变量一级,而 GUI 程序从 Finder 启动时 `LANG` 通常是空的。
#[must_use]
pub fn platform_locale_source() -> &'static dyn LocaleSource {
#[cfg(target_os = "macos")]
{
&AppleLanguages
}
#[cfg(not(target_os = "macos"))]
{
&crate::platform::locale::SystemLocale
}
}
/// macOS 的系统语言源:`defaults read -g AppleLanguages` 的第一项。
#[derive(Debug, Clone, Copy, Default)]
pub struct AppleLanguages;
impl LocaleSource for AppleLanguages {
fn locale(&self) -> Option<String> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("defaults")
.args(["read", "-g", "AppleLanguages"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_apple_languages(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
fn label(&self) -> &'static str {
"applelanguages"
}
}
/// 解析 `defaults` 的输出,取**第一条**语言(真机格式见测试夹具)。
///
/// ```text
/// (
/// "zh-Hans-CN",
/// "en-US"
/// )
/// ```
///
/// 首项是用户的**首选语言**;后面几项是「语言与地区」里的备选,与本 crate「只看主语言子标签」
/// 的口径无关。
#[must_use]
pub fn parse_apple_languages(text: &str) -> Option<String> {
let start = text.find('"')?;
let rest = &text[start + 1..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_owned())
}
#[cfg(test)]
#[path = "locale_tests.rs"]
mod tests;