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

+111
View File
@@ -0,0 +1,111 @@
//! 数据根派生:**字体缓存放哪** + **一条设置键读什么**(M168)。
//!
//! ## 为什么需要它
//!
//! 字体下载要把 17 MiB 落在数据根里(`<launcher_home>/cache/fonts/`),而「数据根在哪」这件事
//! 已经有一个真源:`unlucky_presenter::settings_bridge::SettingsBridge::open()`(三模式数据根 +
//! `core.*` 覆盖键 + 便携回落,见那边的 `resolve_config_path`)。本模块**不重写**那套解析,
//! 只做两件它没暴露的事:
//!
//! 1. 由配置文件路径 `<home>/config/launcher.json` 反推 `<home>`,再用 [`LauncherLayout::cache`]
//! 取缓存分区(分区名只从 core 的 `layout` 常量来,不手拼字符串);
//! 2. 读一条**设置键**的生效值(`ui.font_cdn`):`SettingsBridge` 只暴露了 `ui.language` 的读写,
//! 而 `ui.font_cdn` 是 M168 新增的键。
//!
//! ## 诚实边界
//!
//! - **抓得住**:路径推导的形状(`<home>/config/x` → `<home>/cache`)与「配置读不到时返回 `None`
//! 而不是猜一个目录」;
//! - **抓不住**:真机上 `SettingsBridge::open()` 解析出哪个根(那是 presenter + core 的用例),
//! 以及 macOS `.app` 只读这类平台差异(没有真机)。
//!
//! ## 后续收口(已登记 finding)
//!
//! 更干净的做法是让 presenter 暴露一个通用的 `SettingsBridge::load_text(key)`,本模块就只剩
//! 「布局 + 一条键」。presenter 不在 M168 的 scope 里,所以这里先按上面的方式落地。
use std::path::PathBuf;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use unlucky_presenter::settings_bridge::SettingsBridge;
/// 中文字体下载地址的覆盖键(M168;空 = 用内置镜像)。
pub const FONT_CDN_KEY: &str = "ui.font_cdn";
/// 本进程的数据根布局(`None` = 数据根解析不出来,例如 exe 目录都拿不到)。
///
/// 解析只发生一次:配方来自 `SettingsBridge::open()`(它内部走 `DataDirRequest` + `Resolver`)。
pub fn layout() -> Option<LauncherLayout> {
static LAYOUT: std::sync::OnceLock<Option<LauncherLayout>> = std::sync::OnceLock::new();
LAYOUT
.get_or_init(|| {
let config = SettingsBridge::open().path()?.to_path_buf();
// `<home>/config/launcher.json` → `<home>`
let home = config.parent()?.parent()?.to_path_buf();
Some(LauncherLayout::new(home))
})
.clone()
}
/// 字体缓存的根(`<cache>/fonts/` 的父目录;**不**等于字体目录本身)。
///
/// 用 `cache/` 而不是 `data/`:字体随时可删、删了会自动重下(D28 的「数据根」语义里,
/// 这就是「可重建」的那一类)。
pub fn cache_root() -> Option<PathBuf> {
layout().map(|layout| layout.cache())
}
/// 读一条设置键的**生效值**(schema 默认值也算;空串会被 `trim` 掉但仍是 `Some`)。
///
/// `None` = 配置读不到(数据根解析失败 / 文件损坏到 `SettingStore` 都装不起来)。
pub fn setting_text(key: &str) -> Option<String> {
let path = SettingsBridge::open().path()?.to_path_buf();
let schema = launcher_schema();
let store = SettingStore::load(&path, schema);
let document: &SettingDocument = store.doc();
document.effective_str(key).map(str::to_owned)
}
/// `ui.font_cdn` 的覆盖值(`None` = 没配 / 配了空串 ⇒ 用内置镜像)。
pub fn font_cdn_override() -> Option<String> {
let raw = setting_text(FONT_CDN_KEY)?;
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
/// 键名与 core 的 schema 一致:写错一个字符,用户配了也不生效(而 UI 不会报错)。
///
/// 抓得住:本模块的常量与 `core::setting::keys` 里登记的键名对不上。
/// 抓不住:真机上读到的值(那要数据根与配置文件)。
#[test]
fn the_cdn_key_is_registered_in_the_launcher_schema() {
let schema = launcher_schema();
assert!(
schema.spec(FONT_CDN_KEY).is_some(),
"{FONT_CDN_KEY} 没在 core 的 schema 里登记"
);
}
/// 布局推导的形状:`<home>/config/launcher.json` → `<home>/cache`。
///
/// 抓得住:有人把「上两级」写成「上一级」(缓存会落进 `config/` 里),或改用 `data/` 分区。
/// 抓不住:真机上 `<home>` 解析成什么(presenter 的 `resolve_config_path` 管那一层)。
#[test]
fn the_cache_partition_is_the_sibling_of_config() {
let config = PathBuf::from("/tmp/ull-home/config/launcher.json");
let home = config.parent().unwrap().parent().unwrap().to_path_buf();
let layout = LauncherLayout::new(home);
assert_eq!(layout.cache(), PathBuf::from("/tmp/ull-home/cache"));
assert_eq!(
layout.cache().join("fonts"),
PathBuf::from("/tmp/ull-home/cache/fonts")
);
}
}
+1
View File
@@ -18,6 +18,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend;
pub mod data;
pub mod locale;
pub mod win32;