Files
Unlucky-Launcher/crates/app-iced/src/fonts/cjk.rs
T
AskaEth 875b792206 fix(m168): 复审 r1 五项 —— 修红的 integration target、独立失败域、整体时限
复审 r1(p1-1/fix-then-merge)五项全改:

1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
   scope 漏掉了覆盖 schema 版本号的目录式 target):
   - `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
     `ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
   - `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
     1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
     这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
     守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
   换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
   (Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
   并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
   `TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
   `min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
   `a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
   顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
   现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
   那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
   「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
   `Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
   `Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。

次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。

验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
  `setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
2026-11-08 14:38:23 +08:00

190 lines
7.9 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.
//! 中文环境的 CJK 字体探测(D28 后半):把 iced 系统字体库里的族名喂给 `unlucky_i18n::font::decide`。
//!
//! ## 分工
//!
//! - **判据**(要不要中文字体、哪些族算候选、缺了要不要下载)全在 `unlucky_i18n::font`,是纯函数,
//! 零 UI 依赖、脱机可测;
//! - **语言**从 [`unlucky_i18n::lang::resolve`] 拿:`ui.language` 显式值 > [`SystemLocale`]
//! (Windows 读注册表,零 FFI)> 环境变量 > 英文;
//! - **本模块只做适配**:把 [`super::Face`] 列表变成一个「某族装没装」的查询,并处理
//! **读不到字体库**这种情况。
//!
//! ## 为什么「读不到」不等于「需下载」
//!
//! `system_faces()` 拿不到字体库时返回 `None`。把它当成「没有中文字体」会直接触发一次 CDN 下载
//! (D28 的自动下载路径),而真相是**我们不知道**。所以这一路走 [`Strategy::Unprobeable`]:
//! 日志里留痕,不下载、不弹提示。
//!
//! ## 诚实边界(抓得住什么 / 抓不住什么)
//!
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
//!
//! ## `ui.language` 的接线(M106)
//!
//! 启动编排(`app/boot.rs`)那一拍只按系统 locale 判(`fonts::resolve` → `probe_with(None, ..)`);真正的
//! `ui.language` 在 `App` 构造/切语言时读到,随后走 `fonts::cjk_for(setting)` 复算一次并打日志。
//! 所以「英文系统上手动切中文」也会得到 `needs-download` 而不是 `not-needed`——白块那条路被堵住了。
use unlucky_i18n::font::{self, Strategy};
use unlucky_i18n::lang::{self, Detection, EnvLocale};
use super::Face;
use crate::platform::locale::SystemLocale;
/// 一次中文探测的结论(启动日志的第二段)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CjkReport {
/// 语言探测(含原始 locale 与来源,便于解释「为什么是英文」)。
pub detection: Detection,
/// 字体策略结论。
pub strategy: Strategy,
}
impl CjkReport {
/// 一行日志片段(`lang=zh-CN locale=zh-CN source=registry cjk=system:"Microsoft YaHei"`)。
pub fn describe(&self) -> String {
format!("{} {}", self.detection.describe(), self.strategy.describe())
}
}
/// 用给定语言 + 字体库内容做判定。
///
/// `faces` 为 `None` = 字体库读不出来(→ [`Strategy::Unprobeable`],**不是**「需下载」)。
pub fn report(detection: Detection, faces: Option<&[Face]>) -> CjkReport {
let strategy = faces.map_or(Strategy::Unprobeable, |faces| {
let is_installed = |family: &str| faces.iter().any(|face| face.family == family);
font::decide(detection.lang, &is_installed)
});
CjkReport {
detection,
strategy,
}
}
/// 探测本机:优先级见 [`unlucky_i18n::lang::resolve`]。
///
/// `setting` 是配置里 `ui.language` 的原文(`zh_cn` / `en_us`);`None` = **配置里没写过这个键**
/// (= 跟随系统,不是「没读」)。注意 `None` 与 `Some("不认识的值")` 效果相同:都不当显式选择,
/// 继续往下走。
pub fn probe_with(setting: Option<&str>, faces: Option<&[Face]>) -> CjkReport {
report(lang::resolve(setting, &SystemLocale, &EnvLocale), faces)
}
#[cfg(test)]
mod tests {
use super::*;
fn faces(families: &[&str]) -> Vec<Face> {
families
.iter()
.map(|family| Face {
family: (*family).to_owned(),
weight: 400,
})
.collect()
}
fn zh() -> Detection {
Detection {
lang: unlucky_i18n::Lang::ZhCn,
raw: Some("zh-CN".to_owned()),
origin: "injected",
}
}
fn en() -> Detection {
Detection {
lang: unlucky_i18n::Lang::En,
raw: Some("en-US".to_owned()),
origin: "injected",
}
}
#[test]
fn chinese_host_with_a_cjk_font_uses_the_system_one() {
// 抓得住:适配层把字体库查反/查漏(例如只按第一个族判断、或把 `Segoe UI` 当候选)。
// 抓不住:这台机器实测有没有这个族(测试用的是注入的假字面表)。
let installed = faces(&["Segoe UI", "Microsoft YaHei", "Cascadia Mono"]);
let report = report(zh(), Some(&installed));
assert_eq!(
report.strategy,
Strategy::System {
family: "Microsoft YaHei"
}
);
assert_eq!(
report.describe(),
"lang=zh-CN locale=zh-CN source=injected cjk=system:\"Microsoft YaHei\""
);
}
#[test]
fn chinese_host_without_cjk_fonts_gets_the_download_signal() {
// 抓得住:D28 的「中文环境扫不到 → 要下载」这条路(信号必须能被日志看出来)。
// 抓不住:信号之后的下载与提示(本 mission 不联网,页面也没接)。
let latin_only = faces(&["Segoe UI", "Roboto"]);
let report = report(zh(), Some(&latin_only));
assert_eq!(
report.strategy,
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC)
);
assert!(report
.describe()
.contains("cjk=needs-download(Noto Sans SC)"));
}
#[test]
fn english_host_never_asks_for_a_chinese_font() {
// 抓得住:英文环境的「零字体负担」(D28)——系统里有没有中文字体都不影响结论。
// 抓不住:程序真的没去读那些字体文件(那是 iced/cosmic-text 的加载行为)。
assert_eq!(report(en(), Some(&[])).strategy, Strategy::NotNeeded);
assert_eq!(
report(en(), Some(&faces(&["Microsoft YaHei"]))).strategy,
Strategy::NotNeeded
);
}
#[test]
fn unreadable_font_database_is_unknown_not_missing() {
// 抓得住:把「读不到字体库」错判成「缺字体」——那会在探测失败时触发一次多余的 CDN 下载。
// 抓不住:真的读不到字体库时的运行行为(那条路径要 poisoned lock,测试造不出来)。
let report = report(zh(), None);
assert_eq!(report.strategy, Strategy::Unprobeable);
assert!(
report.describe().ends_with("cjk=unprobeable"),
"{}",
report.describe()
);
}
#[test]
fn explicit_setting_overrides_the_platform_source() {
// 抓得住:`ui.language` 显式值被平台源盖掉(用户手选中文/英文必须赢过系统语言);
// 也让「平台源真的接在链路上」有个不依赖本机语言的证据。
// 抓不住:`ui.language` 在真机上读到什么(那一级由 `App` 读配置后经 `fonts::cjk_for` 喂进来)。
let installed = faces(&["Microsoft YaHei"]);
let forced_english = probe_with(Some("en_us"), Some(&installed));
assert_eq!(forced_english.detection.lang, unlucky_i18n::Lang::En);
assert_eq!(
forced_english.detection.origin,
unlucky_i18n::lang::ORIGIN_SETTING
);
assert_eq!(forced_english.strategy, Strategy::NotNeeded);
let forced_chinese = probe_with(Some("zh_cn"), Some(&installed));
assert_eq!(
forced_chinese.strategy,
Strategy::System {
family: "Microsoft YaHei"
}
);
assert!(forced_chinese.describe().contains("source=setting"));
}
}