feat(m168): 中文字体 CDN 下载真接线(D28 后半)

M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。

判据 → 下载 → 注册(一条链,四个 crate 各管一段):

* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
  钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
  17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
  `FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
  sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
  缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
  可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
  (英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
  族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
  `install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
  原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
  视图模型,所以那一行会自己刷新。

配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。

守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。

测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 2a004a5754
commit 449ded2cb7
22 files changed
+2191 -184

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
//! 中文字体**下载状态行**的文案(M168):设置页 General 组那一行的四态文字 + 三类失败原因。
//!
//! ## 为什么单开一个域文件(而不是塞进 `font.rs`)
//!
//! `catalog/font.rs` 那四条(`FontCjkNeedsDownload` / `FontCjkDownloading` / `FontCjkDownloadFailed`
//! / `FontCjkReady`)是 M92「只发信号」时代的文案,位置在装配链**中间**。往那个域后面追加会把
//! 其后所有 key 在 `Key::ALL` 里整体平移一位 ⇒ 金表(逐 index 逐字节)整片红。
//!
//! 本域挂在**链尾**(`update_entries` 之后),于是 `Key::ALL` 的既有顺序一个不动,金表只需在
//! 末尾追加(M104 / M106 / M107 / M109 / M114 / M159 都是这条做法)。
//!
//! ## 四条状态各一句话(tower 定死的口径)
//!
//! | 状态 | 文案说什么 |
//! |---|---|
//! | 下载中 | 「正在获取中文字体…(≈17 MB)」——**没有**假进度条,有真实量(字节数)才写 |
//! | 完成 | 「中文字体已就绪:⟨族名⟩」(复用 `FontCjkReady`) |
//! | 失败 | 「获取失败(⟨三类原因之一⟩)。界面照常可用,用的是系统字体;再切一次语言可以重试」 |
//! | 无需下载 | 系统已有字体 / 当前语言不需要,两条分开说(对用户是两件事) |
//!
//! 「探测不出」也要有话说:那是「不知道」,既不是「没有」也不是「不需要」——写清楚「未下载」,
//! 免得用户以为我们偷偷下过一份。
//!
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
macro_rules! font_install_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* {
$($acc)*
// ── 中文字体下载状态行(M168:设置页 General 组)
FontCjkStatusLabel => ("Chinese font", "中文字体"),
FontCjkStatusNotNeeded => (
"Not needed for this interface language",
"当前界面语言不需要中文字体"
),
FontCjkStatusSystem => ("Ready (system font: {family})", "已就绪(系统字体:{family})"),
FontCjkStatusDownloading => (
"Fetching a Chinese font… ({size})",
"正在获取中文字体…({size})"
),
FontCjkStatusFailed => (
"Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.",
"获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"
),
FontCjkReasonNetwork => ("network unreachable", "网络不通"),
FontCjkReasonIntegrity => (
"the mirror sent content that failed verification",
"镜像内容校验不通过"
),
FontCjkReasonDisk => ("cannot write to the data directory", "写不进数据目录"),
FontCjkStatusUnknown => (
"Not determined yet; nothing was downloaded",
"尚未判定;没有下载任何字体"
),
FontCjkStatusUnprobeable => (
"System fonts cannot be probed, so nothing was downloaded",
"无法探测系统字体,因此没有下载"
),
FontCjkStatusHint => (
"Downloaded once into the data directory and cached; the next start does not download it again.",
"只下这一次,缓存在数据目录里,下次启动不重下。"
),
} }
};
}
pub(crate) use font_install_entries;