复审 r3(p2-4items / fix-then-merge)四项 + 顺手五条:
1. **P2-1 `catch_unwind` 在发布档是死代码,三处文档说了不成立的话**:
`Cargo.toml` 的 `[profile.release]` 是 `panic = "abort"`(只有 `fast` 是 `unwind`),发布走
`--release`。分两半写清楚:①**闸前移仍安全**——release 档 panic 是进程退出,不存在永久停在
`Downloading`;`dev`/`test`/`fast` 档 `catch_unwind` 真会写 `Failed`;②但「下载失败不崩」在
panic 这一支上对发布版**是反的**:注册/`load_font` panic ⇒ 启动器整体 abort,不是降级。
`install.rs::plan_with_offline` 的文档(新增两档对照表)、`worker.rs` 模块文档与 `run` 注释全部
按这个说法重写,不再用 `catch_unwind` 给「不崩」背书。
2. **P2-2 「启动不发起下载」这条修复没有守卫**:拆出纯函数
`fonts::resolve_with_faces(setting, faces)`(`resolve_with` 只是 `system_faces()` 的包装),
新增 `fonts::tests::resolving_fonts_never_starts_a_download`:给一张只有拉丁族的脸表
(一定判 `NeedsDownload`),断言调用后 `install::status()` **不是** `Downloading`。
变异(实跑):把 `install::after_probe(cjk_report.strategy)` 加回 `resolve_with_faces` ⇒
`求值字体**不许**发起下载(状态从 Unknown 变成了 Downloading { bytes: 17772300 })`,红。
3. **P2-3 时限算术有洞 + `is_last` 没守卫**:改 `ATTEMPT_DEADLINE=60 s` / `TOTAL_DEADLINE=420 s`
(原来 120/360 时,第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 72.3 KiB/s,r1 实测
67 KiB/s 需要 259 s,仍然必失败)。文档按新数值重算成一张表:第一个候选瞬间失败 ⇒ 420 s ⇒
42.3 KiB/s;爬满 60 s 被切 ⇒ 360 s ⇒ 49.4 KiB/s,两者都低于实测最慢的 67 KiB/s。
守卫:把同解的旧用例换成 **单候选 + attempt 300 ms / total 5 s**——有 `is_last` 时跑到总时限
(≈5 s),没有时 300 ms 就切,两种实现可分辨。变异(实跑):删掉 `is_last` ⇒
`the_last_candidate_gets_the_whole_remaining_budget` 红在
`最后一个候选该拿整个剩余预算(≈5 s)…452.6035ms`。
4. **P2-4(b)(c)**:①`install::tests` 里三处判据用例改用 `plan_with_offline(..., false)`,把进程级
`OFFLINE` 从判据里摘出去(否则同进程里 `headless(screenshot_options())` 会把它置 true 且从不
还原 ⇒ 结果依赖执行顺序);②`fonts.rs` 注释改成真实机制——出图 harness 不经
`App::with_presenter`、压根没有 `set_offline`,它零流量靠的是「`resolve_with` 不触发」。
顺手:①`sha256_mismatch…` 的 doc 改成「两层校验互备,本用例抓的是两层同时拆」(原来说
「拆任意一层就红」是假的);②`font.rs` 文档「把 90 s 压成 300 ms」→ 60 s/420 s;③`install.rs` 与
`catalog/font_install.rs` 的「≈17 MB」→ 16.9;④`worker::start` 的裸 `thread::spawn` 换成
`Builder::spawn`,起不来就落 `Failed`(发布档 panic = abort,不能让它 panic);线程体抽成 `run()`。
验证(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-168`,取退出码、不截断):
- `cargo test -p unlucky-app-iced --lib` → **688 passed / 0 failed / 2 ignored / exit 0**
- `cargo test -p unlucky-core --all-targets` → **16 个 target / 1189 passed / 0 failed / exit 0**
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed / exit 0
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → exit 0
- `cargo fmt --all -- --check` 干净;全仓无文件 >400;本轮改动文件最大 378(`app-iced/src/fonts.rs`)
279 lines
13 KiB
Rust
279 lines
13 KiB
Rust
//! 中文字体策略(D28 后半):**只做判据与「该下载什么」,不联网**。
|
||
//!
|
||
//! ## 三句话
|
||
//!
|
||
//! 1. **英文环境不要字体**:`Lang::En` → [`Strategy::NotNeeded`],与系统里装了什么无关。
|
||
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
|
||
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
|
||
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
|
||
//! 真正下载由注入的 [`Fetcher`] 实现(M168 起的真实现是
|
||
//! `crates/app-iced/src/fonts/cdn.rs`,底层走 `unlucky_core::download::font`)。本 crate 保持
|
||
//! **零依赖**:它只携带「下什么」([`Source::Pinned`]:URL 候选 + sha256 + 字节数),不碰网络。
|
||
//!
|
||
//! ## 谁在什么时刻问这里
|
||
//!
|
||
//! | 调用点 | 时机 | 后果 |
|
||
//! |---|---|---|
|
||
//! | `crates/app-iced/src/fonts.rs::resolve` | 启动(按系统 locale) | 打一行 `cjk=…` 日志 |
|
||
//! | `crates/app-iced/src/fonts.rs::cjk_for` | 设置页切语言 | 复算 + 需要时**触发下载** |
|
||
//!
|
||
//! 两条路都**只**在 [`Strategy::NeedsDownload`] 时才碰 [`Fetcher`]([`ensure`] 的判据),
|
||
//! 英文环境与「系统已有字体」两条路一次网络调用都没有(守卫见本文件的单测与
|
||
//! `crates/app-iced/src/fonts/install/tests.rs`)。
|
||
//!
|
||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||
//!
|
||
//! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定;
|
||
//! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集,
|
||
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 crate 不引依赖);
|
||
//! ②下载下来的字节能不能在**这台机器**的渲染器上正常出字(注册与回退在 app-iced;macOS/Linux
|
||
//! 没有真机验证,见 M168 报告)。
|
||
|
||
use crate::lang::Lang;
|
||
|
||
/// 已知的系统 CJK 族(Windows / macOS / Linux,按「观感优先」排序,命中第一个就用)。
|
||
///
|
||
/// 只收**简体**优先的常见族;`Microsoft JhengHei` 这类繁体族留在名单里垫底——
|
||
/// 仓内只有一套中文目录(简体),繁体用户看简体的观感仍好过看英文。
|
||
pub const CJK_FAMILIES: &[&str] = &[
|
||
// Windows 10/11
|
||
"Microsoft YaHei UI",
|
||
"Microsoft YaHei",
|
||
"DengXian",
|
||
"SimHei",
|
||
"SimSun",
|
||
"Microsoft JhengHei",
|
||
// macOS
|
||
"PingFang SC",
|
||
"Hiragino Sans GB",
|
||
"Heiti SC",
|
||
"Songti SC",
|
||
// Linux
|
||
"Noto Sans CJK SC",
|
||
"Noto Sans SC",
|
||
"Source Han Sans SC",
|
||
"Source Han Sans CN",
|
||
"Sarasa Gothic SC",
|
||
"WenQuanYi Micro Hei",
|
||
"Droid Sans Fallback",
|
||
];
|
||
|
||
/// 中文字体必须覆盖的字重(M-决定日志 ③:Android 16 的可变思源黑体缺 100–400,
|
||
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
|
||
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
|
||
|
||
/// 可下载字体的来源:**已钉死**的 URL 候选 + `sha256` + 字节数。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Source {
|
||
/// 钉死的一套下载参数(M168)。
|
||
///
|
||
/// 为什么是「常量 + 多重校验」而不是「让下载器自己挑」:CDN 上的字节会变(上游改字体、
|
||
/// 镜像换了实现),而**字体是注册进渲染器的可执行内容**。所以 URL 钉到 **git commit**
|
||
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
|
||
/// `unlucky_core::download::font`)。
|
||
///
|
||
/// `urls` 是**候选**:首选挂了才试下一个。两个候选**必须是不同的失败域**(Cloudflare 侧 +
|
||
/// Fastly 侧,见 [`NOTO_SANS_SC`] 的实测表);同一家 CDN 的两个域名不算冗余。
|
||
///
|
||
/// 每次尝试与整条取回都有整体时限(`unlucky_core::download::font` 的 `ATTEMPT_DEADLINE` /
|
||
/// `TOTAL_DEADLINE`):慢而不死的连接(IPv6 爬行实测 67 KB/s)会在时限处被打断、转下一个
|
||
/// 候选,而不是让用户在设置页干等四分钟。超时归「网络」档,界面文案是「网络不通或太慢」。
|
||
Pinned {
|
||
/// URL 候选(按顺序尝试)。
|
||
urls: &'static [&'static str],
|
||
/// 期望 `sha256`(小写十六进制,64 位)。
|
||
sha256: &'static str,
|
||
/// 期望字节数(进度预算 + 大小上限判据)。
|
||
bytes: u64,
|
||
},
|
||
}
|
||
|
||
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct CjkFont {
|
||
/// 族名(下载并注册后,`Font` 用它取字)。
|
||
pub family: &'static str,
|
||
/// 许可证(必须是 OFL / Apache 这类可再分发的;D28 明令不得内置 Segoe)。
|
||
pub license: &'static str,
|
||
/// 这套字体可用的字重。
|
||
pub weights: &'static [u16],
|
||
/// 下载源(URL 候选 + sha256 + 字节数,见 [`Source::Pinned`])。
|
||
pub source: Source,
|
||
}
|
||
|
||
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)**可变字体**(wght 100–900,整个简体字符集)。
|
||
///
|
||
/// 钉死的东西(M168 实测抓的,不是猜的):
|
||
///
|
||
/// | 项 | 值 |
|
||
/// |---|---|
|
||
/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` |
|
||
/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) |
|
||
/// | 字节数 | 17 772 300(16.95 MiB) |
|
||
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
|
||
///
|
||
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
|
||
/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。
|
||
///
|
||
/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB,
|
||
/// 而可变字体一份 16.9 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见
|
||
/// `crates/core/src/download/font.rs` 模块文档与 M168 报告里的「抓不住」一段)。
|
||
pub const NOTO_SANS_SC: CjkFont = CjkFont {
|
||
family: "Noto Sans SC",
|
||
license: "OFL-1.1",
|
||
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||
source: Source::Pinned {
|
||
// 两个候选**必须是不同的失败域**(M168 复审 P2-1):`cdn.jsdelivr.net` 与
|
||
// `fastly.jsdelivr.net` 都 CNAME 到 `jsdelivr.map.fastly.net`(同一批 Fastly 边缘),
|
||
// 「一个坏了换另一个」等于没有冗余——上一版就是这样,被复审当场打掉。
|
||
//
|
||
// 实测(两份独立的国内网络取证,同一份 17 772 300 B 内容,sha256 都是 a3041811…):
|
||
//
|
||
// | 域名 | 解析 | 本机(本 worktree 开发机) | 复审机(无代理) |
|
||
// |---|---|---|---|
|
||
// | gcore.jsdelivr.net | Cloudflare 104.16.x | 13.3 s 完整 | 16.6 s 完整 |
|
||
// | testingcf.jsdelivr.net | Cloudflare 104.16.x | 11.8 s 完整 | 14.8 s 完整 |
|
||
// | cdn.jsdelivr.net | Fastly 151.101.x | 11.9 s 完整(另两次 5.7 s / 14.9 s)| 1.1 s,但**走 IPv6 时 3 次都掉到 ≈67 KB/s**(60 s 只到 4.0 MB)|
|
||
// | fastly.jsdelivr.net | 同上(Fastly)| — | 20 s 只到 2.5 MB(比它「要备的那条」还慢)|
|
||
//
|
||
// 排序依据(不是「听说更稳」):**首选放 Cloudflare 那一侧**——Fastly 侧的 IPv6 边缘
|
||
// 在复审环境里会掉进 60~77 KB/s 的慢路径(那条路上 17.7 MB 要 ≈4 分钟),而 Cloudflare
|
||
// 侧两份取证都完整下完、且是独立的失败域。二选留 Fastly 侧:本机它反而最快(5.7~11.9 s),
|
||
// 保留它是为了「Cloudflare 一侧整体不可用时还有另一家 CDN」。
|
||
// 先后只影响速度,不影响正确性:sha256 只认一份,镜像给了别的内容会被当场拒。
|
||
urls: &[
|
||
"https://gcore.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||
"https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||
],
|
||
sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||
bytes: 17_772_300,
|
||
},
|
||
};
|
||
|
||
/// 字体策略结论。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Strategy {
|
||
/// 不需要中文字体(英文环境)。
|
||
NotNeeded,
|
||
/// 系统里已有可用族,直接用。
|
||
System {
|
||
/// 命中的族名。
|
||
family: &'static str,
|
||
},
|
||
/// 需要下载(本模块只发信号,不联网)。
|
||
NeedsDownload(&'static CjkFont),
|
||
/// 探测不出(拿不到系统字体库)——**不等于**「需下载」:不许凭猜测去下载。
|
||
Unprobeable,
|
||
}
|
||
|
||
impl Strategy {
|
||
/// 一行日志片段(`cjk=system:"Microsoft YaHei"` / `cjk=needs-download(Noto Sans SC)` …)。
|
||
#[must_use]
|
||
pub fn describe(&self) -> String {
|
||
match self {
|
||
Strategy::NotNeeded => "cjk=not-needed".to_owned(),
|
||
Strategy::System { family } => format!("cjk=system:\"{family}\""),
|
||
Strategy::NeedsDownload(font) => format!("cjk=needs-download({})", font.family),
|
||
Strategy::Unprobeable => "cjk=unprobeable".to_owned(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 判据(纯函数):语言 + 「某族装没装」→ 策略。
|
||
///
|
||
/// `installed` 由调用方提供(iced 版查系统字体库,单测给假闭包)。
|
||
#[must_use]
|
||
pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
|
||
if lang != Lang::ZhCn {
|
||
// D28:纯英文环境不加载任何中文字体,也不下载。
|
||
return Strategy::NotNeeded;
|
||
}
|
||
|
||
CJK_FAMILIES
|
||
.iter()
|
||
.copied()
|
||
.find(|family| installed(family))
|
||
.map_or(Strategy::NeedsDownload(&NOTO_SANS_SC), |family| {
|
||
Strategy::System { family }
|
||
})
|
||
}
|
||
|
||
/// 字体下载器接口(**注入式**:i18n 不碰网络,真实现由 app 层提供)。
|
||
pub trait Fetcher {
|
||
/// 取一套字体(返回原始字节,装进字体系统是调用方的事)。
|
||
///
|
||
/// 契约:**同一个进程里第二次调用应当零网络**(缓存命中),且返回的字节已通过校验
|
||
/// (实现方的责任,见 `unlucky_core::download::font`)。
|
||
///
|
||
/// # Errors
|
||
/// 网络失败 / 校验失败 / 磁盘失败三类人话错误。
|
||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError>;
|
||
}
|
||
|
||
/// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。
|
||
///
|
||
/// 三类对应三句不同的话:网络类「过会儿再试」、校验类「这个源给的东西不对」、磁盘类
|
||
/// 「腾点空间」。UI 文案按 [`FetchError::class`] 选词,**不要**按错误串猜。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum FetchError {
|
||
/// 网络层失败(超时 / 连不上 / 非 2xx)。
|
||
Network(String),
|
||
/// 校验失败(哈希 / 长度不符,或不是字体文件)。
|
||
Integrity(String),
|
||
/// 本地磁盘失败(建目录 / 写文件 / 换名)。
|
||
Disk(String),
|
||
}
|
||
|
||
impl FetchError {
|
||
/// 稳定类别名(`network` / `integrity` / `disk`;日志与 i18n 金表按它选词)。
|
||
#[must_use]
|
||
pub const fn class(&self) -> &'static str {
|
||
match self {
|
||
Self::Network(_) => "network",
|
||
Self::Integrity(_) => "integrity",
|
||
Self::Disk(_) => "disk",
|
||
}
|
||
}
|
||
|
||
/// 人类可读描述。
|
||
#[must_use]
|
||
pub fn message(&self) -> &str {
|
||
match self {
|
||
Self::Network(message) | Self::Integrity(message) | Self::Disk(message) => message,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// [`ensure`] 的结局。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum Fetched {
|
||
/// 本来就不需要(英文环境)。
|
||
NothingToDo,
|
||
/// 系统已有,直接用。
|
||
AlreadySystem {
|
||
/// 命中的族名。
|
||
family: &'static str,
|
||
},
|
||
/// 下载成功,拿到字节(交给调用方注册)。
|
||
Downloaded(Vec<u8>),
|
||
/// 探测不出,什么都没做。
|
||
Unknown,
|
||
}
|
||
|
||
/// 按策略执行:需要下载时才碰 [`Fetcher`]。
|
||
///
|
||
/// # Errors
|
||
/// 下载器返回的错误原样透出(不吞、不改写分类)。
|
||
pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, FetchError> {
|
||
match strategy {
|
||
Strategy::NotNeeded => Ok(Fetched::NothingToDo),
|
||
Strategy::System { family } => Ok(Fetched::AlreadySystem { family }),
|
||
Strategy::Unprobeable => Ok(Fetched::Unknown),
|
||
Strategy::NeedsDownload(font) => fetcher.fetch(font).map(Fetched::Downloaded),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "font/tests.rs"]
|
||
mod tests;
|