Files
Unlucky-Launcher/crates/i18n/src/bridge.rs
T
AskaEth e77a5df6e8 feat(i18n): 建国际化地基——语言探测 / 中英消息目录 / 中文字体策略(D28)
M92 只建地基、不接页面(页面接线是后续 mission,避免「先硬编码再全量重写」)。
新建的 crates/i18n 是**零依赖** crate:`cargo tree -p unlucky-i18n --edges normal`
只打印它自己,所以 iced 版、将来的别的壳、presenter 都能依赖它而不锁死渲染框架。

- lang:`Lang::{En, ZhCn}` + 可注入的 `LocaleSource`(`EnvLocale` 按 POSIX 优先级读
  LC_ALL > LC_MESSAGES > LANG)。未知语种一律英文——把 ja/fr 猜成中文会连带触发中文字体
  下载,比英文界面更糟。配置值(`ui.language` 的 zh_cn/en_us)走严格解析,locale 走宽松
  解析,两条路刻意分开。**已知缺口**:Windows 上这三个变量通常都不存在,
  `GetUserDefaultLocaleName` 属于 platform 的 FFI(后续 mission)。
- catalog:`Key` 枚举(拼错的 key = 编译错误,附 compile_fail 文档测试守着)
  + `catalog!` 宏「一行 = 一个 key 的全部语言」⇒ 内置两套目录缺译在结构上不可能发生。
  缺译诊断(`Catalog::diagnose`)留给注入目录与回归守门:`get` 严格返回 None、
  `text` 返回 ⟪missing⟫、`render` 返回 ⟪missing:KeyName⟫——**不回落**到另一种语言。
  带变量的句子走 `{name}` 占位符,缺参时占位符原样留在文案里(可见,不静默变空)。
- font:`decide(lang, installed)` 是纯函数。英文环境恒 `NotNeeded`(D28 的零字体/零网络);
  中文环境按候选族名单命中系统字体,扫不到就发 `NeedsDownload(Noto Sans SC)` 信号,
  **本 crate 不联网**;`Fetcher` 是注入式接口,网络实现留给后续 mission。
  URL/哈希显式留白(`Source::PendingPin`)——不编造 CDN 路径,猜错的地址会被后面所有人当事实用。
- bridge:文案边界规则全文(R1 不许拼句 / R2 core 中文串不进 UI / R3 未知串不许画原文)
  + `classify()` 示例契约:过渡表把已知漏到界面的中文串映射到 key,品牌专名显式放行,
  其余一律 `Untranslated`(UI 只许显示兜底 key)。
- app-iced/fonts:新增 `fonts/cjk.rs` 适配层,把系统字面表喂给 `decide`;读不到字体库时给
  `Unprobeable` 而不是「需下载」(否则会误触发一次 CDN 下载)。结论并入同一行启动日志:
  `[font] … | lang=zh-CN locale=zh_CN.UTF-8 cjk=system:"Microsoft YaHei"`。

测试:26 条(i18n 22 + app-iced fonts/cjk 4),每条断言都写了「抓得住什么 / 抓不住什么」。
2026-11-02 16:13:22 +08:00

185 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.
//! 文案边界(示例契约):core / presenter 的**状态**怎么进 UI。
//!
//! ## 现状(为什么需要这份契约)
//!
//! UI 文案硬编码英文,而 `core` / `presenter` 产出的状态串是中文,于是界面上已经出现
//! `Launch failed · exit0 静默失败` 这种**一句话里中英混排**的东西,加载器徽标也在 `原版` / `Fabric`
//! 之间混用。根因不是「译文不够」,是**边界没划**:谁产出文案、谁负责翻译,没有规则。
//!
//! ## 三条规则
//!
//! **R1 · UI 侧不产生文案,只翻译 key。**
//! UI 不许把片段拼成句子(`"Launch failed · " + reason`),也不许写中文字面量。
//! 一句话 = 一个 key([`crate::catalog`]),带变量的用 `{name}` 交给
//! [`crate::catalog::Catalog::render`]。拼接一旦发生,任何一套语言都拼不对(语序、标点、
//! 量词都不一样),而且混排会立刻出现在截图里。
//!
//! **R2 · core 的中文串不进 UI。**
//! core / presenter 的中文(`原版`、`静默失败(exit 0 但游戏没起来)`、
//! `缺少库文件 foo.jar(…)`)是**日志 / CLI / 诊断报告**的文案,不是界面文案。
//! 界面只能通过两条合法通道拿到「要显示什么」:
//! 1. **结构化出口**(首选):枚举 / DTO 字段 → 映射到 [`crate::Key`]。
//! 例:`presenter::dto::LoaderKind::as_str()` 的稳定 id(`vanilla` / `fabric`)→
//! [`crate::Key::LoaderVanilla`];`LogOutcome` 三态 → `LaunchOutcome*` 三 key。
//! 好处是**新增一个枚举变体时编译器会逼你补映射**(`match` 非穷尽即报错)。
//! 2. **过渡表**([`classify`]):只覆盖「已知会漏到界面的少数几个串」,逐条硬编码。
//! 它是**债**,不是终点——新出现的未知串一律走第三条。
//!
//! **R3 · 认不出的串,不许画原文。**
//! [`CoreText::Untranslated`] 就是那种情况:UI 只许显示 [`crate::Key::CoreTextUntranslated`]
//! 这个**故意难看**的兜底(`[system text not translated]`),并把原文写进日志。
//! 这条比「先画上去以后再翻」严格,是因为后者永远不会被回头修——难看才会被修。
//! 同时它是一件**上游缺陷的证据**:说明 core 欠一个结构化出口,应当报 finding 而不是在 UI 贴创可贴。
//!
//! ## 品牌专名(唯一的豁免)
//!
//! `Fabric` / `Forge` / `NeoForge` / `Quilt` / `OptiFine` / `LiteLoader` 是专有名词,**不翻译**
//! (翻成「锻造」就没人认识了)。它们可以原样显示,但必须经 [`CoreText::Brand`] 判定——
//! 也就是说「原样显示」是一个**显式决定**,不是「反正没译文就画原文」的默认行为。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:本表里列到的串(映射正确、未知串不被放行);
//! - **抓不住**:core 明天新加一句中文——本 crate 不依赖 core(零依赖纪律),没有跨 crate 的一致性检查。
//! 那条只能靠「core 侧只给枚举」的纪律 + 后续在 `crates/presenter` 里加一条守门测试
//! (例如:`*_view` 的字符串字段不许含 CJK 字符)。
use crate::catalog::Key;
/// 一段来自 `core` / `presenter` 的显示文本该怎么处理。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoreText {
/// 品牌专名:原样显示(不翻译,见模块文档的豁免)。
Brand(&'static str),
/// 有对应 key:按当前语言取词。
Key(Key),
/// 不认识:只许显示 [`Key::CoreTextUntranslated`],原文进日志。
Untranslated,
}
/// 已知会漏到界面的中文状态串 → key。
///
/// 每一条都得在 `crates/presenter` / `crates/core` 里找得到出处(注释里给位置),
/// 不许凭想象加——凭空加的条目会让 [`classify`] 假装覆盖了实际没覆盖的东西。
const LEGACY_STATUS: &[(&str, Key)] = &[
// `presenter::dto::instances::LoaderKind::label()` / `host::launch::loader_label()`
("原版", Key::LoaderVanilla),
// `presenter::host::job.rs`:`LogOutcome::SilentFail` 的展示文字
("静默失败(exit 0 但游戏没起来)", Key::LaunchOutcomeSilent),
// `presenter::mock_data.rs` 里的演示数据
("exit0 静默失败", Key::LaunchOutcomeSilent),
];
/// 品牌专名(原样显示;**不得**翻译或拼进句子,UI 拼装仍然禁止)。
const BRANDS: &[&str] = &[
"Fabric",
"Forge",
"NeoForge",
"Quilt",
"OptiFine",
"LiteLoader",
];
/// 契约总入口:core / presenter 的显示文本 → 该画什么。
///
/// 这是**示例契约**:形状(三分类 + 未知不放行)比表里的具体条目重要,接线 mission 按这个形状
/// 把每条 UI 路径改过来,条目随上游结构化出口的落地逐条删掉。
#[must_use]
pub fn classify(text: &str) -> CoreText {
let trimmed = text.trim();
if trimmed.is_empty() {
return CoreText::Untranslated;
}
if let Some((_, key)) = LEGACY_STATUS.iter().find(|(legacy, _)| *legacy == trimmed) {
return CoreText::Key(*key);
}
if let Some(brand) = BRANDS.iter().find(|brand| **brand == trimmed) {
return CoreText::Brand(brand);
}
// `Fabric 0.16.5` 这类「品牌 + 版本号」也是合法显示:品牌之后只跟一个版本号时放行。
// 注意这里是**保守**放行:只有「一个词 + 空格 + 版本号」的形状才算,含中文的串绝不会进来。
if let Some((head, tail)) = trimmed.split_once(' ') {
if let Some(brand) = BRANDS.iter().find(|brand| **brand == head) {
if !tail.is_empty() && !tail.contains(char::is_whitespace) && !tail.chars().any(is_cjk)
{
return CoreText::Brand(brand);
}
}
}
CoreText::Untranslated
}
/// 是否 CJK 字符(`classify` 的保守放行判据用)。
fn is_cjk(ch: char) -> bool {
// 0x2E80..=0x9FFF 已含 CJK 部首 / 假名 / 统一表意文字与 0x3000 段的中文标点,不再单列。
matches!(ch as u32, 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_chinese_status_strings_map_to_keys() {
// 抓得住:过渡表条目的映射写错(把「原版」映到别的 key),以及「同一段意思两个 key」的漂移。
// 抓不住:core 那边改了这个中文串的措辞——那时这里会落到 Untranslated(**故意的**,
// 让漏译可见,而不是继续拿旧文案糊过去)。
assert_eq!(classify("原版"), CoreText::Key(Key::LoaderVanilla));
assert_eq!(
classify("静默失败(exit 0 但游戏没起来)"),
CoreText::Key(Key::LaunchOutcomeSilent)
);
assert_eq!(
classify("exit0 静默失败"),
CoreText::Key(Key::LaunchOutcomeSilent)
);
assert_eq!(
classify(" 原版 "),
CoreText::Key(Key::LoaderVanilla),
"前后空白要吃掉"
);
}
#[test]
fn brands_pass_through_and_are_never_translated() {
// 抓得住:品牌名被当成待翻译文案(`Fabric` → 某个 key 的译文就没人认识它了),
// 以及「品牌 + 版本号」这种合法徽标被判成未翻译。
// 抓不住:品牌名与版本号之间该用什么分隔(M3 令牌 / 排版层的事,不是本契约的事)。
assert_eq!(classify("Fabric"), CoreText::Brand("Fabric"));
assert_eq!(classify("Fabric 0.16.5"), CoreText::Brand("Fabric"));
assert_eq!(classify("NeoForge 21.1.72"), CoreText::Brand("NeoForge"));
assert_eq!(classify("Fabric 中文"), CoreText::Untranslated);
assert_eq!(classify("Fabric 0.16.5 beta"), CoreText::Untranslated);
assert_eq!(classify("FabricX"), CoreText::Untranslated);
}
#[test]
fn unknown_core_prose_is_refused_instead_of_printed_raw() {
// 抓得住:R3——未知中文(core 的诊断prose、`describe_warning` 的拼句)被放行到界面,
// 那正是 `Launch failed · exit0 静默失败` 这类混排的来源。
// 抓不住:UI 拿到 Untranslated 之后有没有真的只显示兜底(页面侧测试 + 截图验收)。
let leaks = [
"缺少库文件 lwjgl.jar(C:\\Users\\x\\.minecraft\\libs\\lwjgl.jar)",
"版本 JSON 没有 mainClass",
"退出码为 0 但疑似静默失败(3 条错误行):游戏可能刚起来就被 mod 异常终止",
"",
" ",
];
for leak in leaks {
assert_eq!(classify(leak), CoreText::Untranslated, "{leak}");
}
}
#[test]
fn classify_never_returns_a_raw_unknown_string() {
// 抓得住:将来给 `CoreText` 加了「原样字符串」变体(那等于把 R3 废掉)。
// 抓不住:枚举里已有变体被误用——那要靠调用点 review。
let odd = "some untranslated english";
assert_eq!(classify(odd), CoreText::Untranslated);
}
}