复审 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)
190 lines
7.9 KiB
Rust
190 lines
7.9 KiB
Rust
//! 中文环境的 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"));
|
||
}
|
||
}
|