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:
1 parent
2a004a5754
commit
449ded2cb7
22 files changed
+2191
-184
No files matched your search
@@ -130,6 +130,12 @@ pub struct App {
|
||||
pub(super) refreshes: u64,
|
||||
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
|
||||
pub(super) auto_selected: bool,
|
||||
/// 上一次看到的**中文字体下载状态**(M168)。
|
||||
///
|
||||
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
|
||||
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
|
||||
/// 而空闲态不会为了它每拍白重建一次。
|
||||
pub(super) font_seen: crate::fonts::install::CjkInstall,
|
||||
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
|
||||
pub(super) height: f32,
|
||||
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
|
||||
|
||||
@@ -114,6 +114,9 @@ impl App {
|
||||
frames: 0,
|
||||
refreshes: 0,
|
||||
auto_selected: false,
|
||||
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
|
||||
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
|
||||
font_seen: crate::fonts::install::status(),
|
||||
height,
|
||||
appearance,
|
||||
appearance_prefs,
|
||||
|
||||
@@ -42,6 +42,13 @@ impl App {
|
||||
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
|
||||
// 从「正在获取中文字体…(17.0 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
|
||||
let font_status = crate::fonts::install::status();
|
||||
if font_status != self.font_seen {
|
||||
self.font_seen = font_status;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
|
||||
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
|
||||
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
|
||||
|
||||
@@ -18,18 +18,31 @@
|
||||
|
||||
//! ## 不做什么
|
||||
//!
|
||||
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
|
||||
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
|
||||
//! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,
|
||||
//! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。
|
||||
//!
|
||||
//! ## 中文那一半(M92 / D28 后半)
|
||||
//! ## 中文那一半(M92 / D28 后半 / M168 实装下载)
|
||||
//!
|
||||
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
|
||||
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
|
||||
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
|
||||
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
|
||||
//! cosmic-text 的全局状态,不是本模块能管的事)。
|
||||
//!
|
||||
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
|
||||
//!
|
||||
//! ```text
|
||||
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
|
||||
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
|
||||
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
|
||||
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
|
||||
//! ```
|
||||
//!
|
||||
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
|
||||
|
||||
pub mod cdn;
|
||||
mod cjk;
|
||||
pub mod install;
|
||||
|
||||
use cjk::CjkReport;
|
||||
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
|
||||
@@ -211,6 +224,8 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
|
||||
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
|
||||
let cjk_report = cjk::probe_with(setting, faces.as_deref());
|
||||
// M168:只有 `NeedsDownload` 才会在这里发起下载(后台线程),其余三路一次网络都不发。
|
||||
install::after_probe(cjk_report.strategy);
|
||||
|
||||
Resolved {
|
||||
platform,
|
||||
@@ -220,15 +235,17 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
}
|
||||
}
|
||||
|
||||
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值**
|
||||
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
/// 语言切换后的中文策略重算(M106 判据 / M168 触发下载):`setting` 是配置里 `ui.language` 的
|
||||
/// **显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
///
|
||||
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
|
||||
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
|
||||
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`,
|
||||
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。
|
||||
/// 那就是白块。复算出的 `NeedsDownload` 现在**真的会去下载**(M168:`install::after_probe`),
|
||||
/// 结果进设置页那一行(`pages::settings::general`)与 stderr 的 `[font] cjk-download …` 日志。
|
||||
pub fn cjk_for(setting: Option<&str>) -> CjkReport {
|
||||
cjk::probe_with(setting, system_faces().as_deref())
|
||||
let report = cjk::probe_with(setting, system_faces().as_deref());
|
||||
install::after_probe(report.strategy);
|
||||
report
|
||||
}
|
||||
|
||||
/// 一条系统字面(族名 + 字重)。
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
|
||||
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
|
||||
//! core 的 FontSpec(值)
|
||||
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
|
||||
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
|
||||
//! ```
|
||||
//!
|
||||
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
|
||||
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
|
||||
//! 单测)。
|
||||
//!
|
||||
//! ## 「不联网」的三条路
|
||||
//!
|
||||
//! | 情形 | 谁拦住 |
|
||||
//! |---|---|
|
||||
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
|
||||
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
|
||||
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
|
||||
//!
|
||||
//! ## 设置改写只换 URL,不换哈希
|
||||
//!
|
||||
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
|
||||
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Source};
|
||||
|
||||
use crate::platform::data;
|
||||
|
||||
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdnFetcher {
|
||||
cache_root: PathBuf,
|
||||
spec: FontSpec,
|
||||
}
|
||||
|
||||
impl CdnFetcher {
|
||||
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
|
||||
///
|
||||
/// # Errors
|
||||
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
|
||||
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
|
||||
Ok(Self {
|
||||
cache_root,
|
||||
spec: spec_of(font)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 缓存是不是已经在盘上(只 stat,不做校验)。
|
||||
///
|
||||
/// 用途只有两个:UI 那行「已就绪」的字样、日志里 `from_cache=true` 的标注。**它不承担守卫**:
|
||||
/// 真正的命中判定在 `core::download::font`(读出来 + 复验魔数后才算命中)。
|
||||
#[must_use]
|
||||
pub fn has_cached(&self) -> bool {
|
||||
core_font::cache_path(&self.cache_root, &self.spec).is_file()
|
||||
}
|
||||
|
||||
/// 缓存文件落点(诊断日志用)。
|
||||
#[must_use]
|
||||
pub fn cache_file(&self) -> PathBuf {
|
||||
core_font::cache_path(&self.cache_root, &self.spec)
|
||||
}
|
||||
}
|
||||
|
||||
impl unlucky_i18n::font::Fetcher for CdnFetcher {
|
||||
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
core_font::fetch_blocking(&self.cache_root, &self.spec)
|
||||
.map(|fetch| fetch.bytes)
|
||||
.map_err(|error| translate(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
|
||||
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = font.source;
|
||||
|
||||
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
|
||||
if let Some(override_url) = data::font_cdn_override() {
|
||||
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
|
||||
candidates.insert(0, override_url);
|
||||
}
|
||||
|
||||
FontSpec::new(candidates, sha256, bytes)
|
||||
.map_err(|error| FetchError::Integrity(error.to_string()))
|
||||
}
|
||||
|
||||
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
|
||||
fn translate(error: FontFetchError) -> FetchError {
|
||||
match error {
|
||||
FontFetchError::Network(message) => FetchError::Network(message),
|
||||
FontFetchError::Integrity(message) => FetchError::Integrity(message),
|
||||
FontFetchError::Disk(message) => FetchError::Disk(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_i18n::font::NOTO_SANS_SC;
|
||||
|
||||
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
|
||||
///
|
||||
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
|
||||
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
|
||||
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
|
||||
#[test]
|
||||
fn the_pin_survives_the_trip_to_core() {
|
||||
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = NOTO_SANS_SC.source;
|
||||
|
||||
assert!(
|
||||
spec.urls.len() >= urls.len(),
|
||||
"候选不该变少:{:?}",
|
||||
spec.urls
|
||||
);
|
||||
assert_eq!(spec.sha256, sha256);
|
||||
assert_eq!(spec.bytes, bytes);
|
||||
assert_eq!(
|
||||
spec.urls[spec.urls.len() - urls.len()],
|
||||
urls[0],
|
||||
"内置首选不许被挤掉"
|
||||
);
|
||||
}
|
||||
|
||||
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
|
||||
#[test]
|
||||
fn an_empty_cache_root_is_not_a_hit() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
|
||||
assert!(!fetcher.has_cached());
|
||||
assert!(fetcher
|
||||
.cache_file()
|
||||
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n::font::decide(判据) → Strategy
|
||||
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
|
||||
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
|
||||
//! fonts::install::register(注册) → iced 的字体系统里多一个族
|
||||
//! ```
|
||||
//!
|
||||
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
|
||||
//!
|
||||
//! | 入口 | 时机 | 谁来 |
|
||||
//! |---|---|---|
|
||||
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
|
||||
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
|
||||
//!
|
||||
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
|
||||
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
|
||||
//!
|
||||
//! ## 为什么下载在后台线程而不是启动那一拍
|
||||
//!
|
||||
//! 一份 17 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
|
||||
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
|
||||
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
|
||||
//!
|
||||
//! ## 失败**不是**致命错误
|
||||
//!
|
||||
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
|
||||
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
|
||||
//! (「下载失败不崩」)。
|
||||
//!
|
||||
//! ## 状态是一份进程级快照
|
||||
//!
|
||||
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
|
||||
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
|
||||
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
|
||||
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
|
||||
//!
|
||||
//! ## 截图通道
|
||||
//!
|
||||
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
|
||||
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
|
||||
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
|
||||
|
||||
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FetchClass {
|
||||
/// 网络层。
|
||||
Network,
|
||||
/// 校验层(哈希 / 魔数 / 大小上限)。
|
||||
Integrity,
|
||||
/// 磁盘层。
|
||||
Disk,
|
||||
}
|
||||
|
||||
impl FetchClass {
|
||||
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Network => "network",
|
||||
Self::Integrity => "integrity",
|
||||
Self::Disk => "disk",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&FetchError> for FetchClass {
|
||||
fn from(error: &FetchError) -> Self {
|
||||
match error {
|
||||
FetchError::Network(_) => Self::Network,
|
||||
FetchError::Integrity(_) => Self::Integrity,
|
||||
FetchError::Disk(_) => Self::Disk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CjkInstall {
|
||||
/// 还没判定(启动前 / 判据没跑过)。
|
||||
Unknown,
|
||||
/// 英文环境:不需要中文字体(D28 的第一条)。
|
||||
NotNeeded,
|
||||
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
|
||||
System {
|
||||
/// 命中的族名。
|
||||
family: String,
|
||||
},
|
||||
/// 探测不出(拿不到系统字体库)——**不下载**。
|
||||
Unprobeable,
|
||||
/// 正在下载。
|
||||
Downloading {
|
||||
/// 字节数(pin 里的期望值;用来写「≈17 MB」这种可核对的量)。
|
||||
bytes: u64,
|
||||
},
|
||||
/// 已就绪(下载完成并注册进字体系统)。
|
||||
Ready {
|
||||
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
|
||||
family: String,
|
||||
/// 是否零网络命中缓存。
|
||||
from_cache: bool,
|
||||
},
|
||||
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
|
||||
Failed {
|
||||
/// 类别。
|
||||
class: FetchClass,
|
||||
/// 人话原因(日志/诊断用;UI 只按类别选词)。
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CjkInstall {
|
||||
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
|
||||
#[must_use]
|
||||
pub const fn is_downloading(&self) -> bool {
|
||||
matches!(self, Self::Downloading { .. })
|
||||
}
|
||||
|
||||
/// 一行日志片段(`cjk-install=…`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::Unknown => "cjk-install=unknown".to_owned(),
|
||||
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
|
||||
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
|
||||
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
|
||||
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
|
||||
Self::Ready { family, from_cache } => {
|
||||
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
|
||||
}
|
||||
Self::Failed { class, message } => {
|
||||
format!("cjk-install=failed({},{message})", class.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程级状态(一份)。
|
||||
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
|
||||
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
|
||||
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
|
||||
}
|
||||
|
||||
/// 当前状态(设置页那一行、心跳、日志都读它)。
|
||||
#[must_use]
|
||||
pub fn status() -> CjkInstall {
|
||||
if let Some(forced) = capture_override() {
|
||||
return forced;
|
||||
}
|
||||
slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
|
||||
fn capture_override() -> Option<CjkInstall> {
|
||||
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
|
||||
FORCED
|
||||
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
|
||||
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
|
||||
|
||||
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
|
||||
#[must_use]
|
||||
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
|
||||
let value = raw.trim();
|
||||
Some(match value {
|
||||
"not-needed" => CjkInstall::NotNeeded,
|
||||
"unprobeable" => CjkInstall::Unprobeable,
|
||||
"system" => CjkInstall::System {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
},
|
||||
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
"ready" => CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
"failed" | "failed:network" => CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
"failed:integrity" => CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
"failed:disk" => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
|
||||
///
|
||||
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
|
||||
pub fn ensure_for(
|
||||
strategy: Strategy,
|
||||
fetcher: &dyn Fetcher,
|
||||
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
|
||||
) -> CjkInstall {
|
||||
match font::ensure(strategy, fetcher) {
|
||||
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
|
||||
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
|
||||
family: family.to_owned(),
|
||||
},
|
||||
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
|
||||
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
|
||||
Ok(family) => CjkInstall::Ready {
|
||||
family,
|
||||
from_cache: false,
|
||||
},
|
||||
Err(message) => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message,
|
||||
},
|
||||
},
|
||||
Err(error) => CjkInstall::Failed {
|
||||
class: FetchClass::from(&error),
|
||||
message: error.message().to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 判据结论 → 现在这一步的结局(生产入口)。
|
||||
///
|
||||
/// 只有 `NeedsDownload` 且**当前不在下载/已就绪**时才真的发起下载:
|
||||
///
|
||||
/// * 英文环境 / 系统已有 → 只更新状态(零网络);
|
||||
/// * 探测不出 → 只更新状态(零网络,**不**当成缺字体);
|
||||
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载);
|
||||
/// * 上次失败过 → **只有再次切语言才会重试**(用户显式动作),不会自己反复联网。
|
||||
pub fn after_probe(strategy: Strategy) -> CjkInstall {
|
||||
let current = status();
|
||||
match strategy {
|
||||
Strategy::NotNeeded => {
|
||||
update(CjkInstall::NotNeeded);
|
||||
CjkInstall::NotNeeded
|
||||
}
|
||||
Strategy::System { family } => {
|
||||
let state = CjkInstall::System {
|
||||
family: family.to_owned(),
|
||||
};
|
||||
update(state.clone());
|
||||
state
|
||||
}
|
||||
Strategy::Unprobeable => {
|
||||
update(CjkInstall::Unprobeable);
|
||||
CjkInstall::Unprobeable
|
||||
}
|
||||
Strategy::NeedsDownload(font) => {
|
||||
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
|
||||
return current;
|
||||
}
|
||||
worker::start(font);
|
||||
status()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
|
||||
pub(super) fn update(state: CjkInstall) {
|
||||
let previous = {
|
||||
let mut guard = slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
std::mem::replace(&mut *guard, state.clone())
|
||||
};
|
||||
if previous != state {
|
||||
eprintln!("[font] {}", state.describe());
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
|
||||
mod worker;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,243 @@
|
||||
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
|
||||
//!
|
||||
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
|
||||
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
|
||||
struct FakeFetcher {
|
||||
calls: RefCell<usize>,
|
||||
seen: RefCell<Option<&'static str>>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(0),
|
||||
seen: RefCell::new(None),
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
*self.calls.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
*self.calls.borrow_mut() += 1;
|
||||
*self.seen.borrow_mut() = Some(font.family);
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 假注册器:记收到的字节数,返回一个族名。
|
||||
fn recorder() -> (
|
||||
std::rc::Rc<RefCell<Vec<usize>>>,
|
||||
impl Fn(Vec<u8>) -> Result<String, String>,
|
||||
) {
|
||||
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
|
||||
let recorder = std::rc::Rc::clone(&seen);
|
||||
let register = move |bytes: Vec<u8>| {
|
||||
recorder.borrow_mut().push(bytes.len());
|
||||
Ok("Noto Sans SC".to_owned())
|
||||
};
|
||||
(seen, register)
|
||||
}
|
||||
|
||||
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
|
||||
///
|
||||
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
|
||||
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
|
||||
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
|
||||
/// `CdnFetcher` 的缓存判定承担)。
|
||||
#[test]
|
||||
fn the_three_idle_strategies_never_touch_the_fetcher() {
|
||||
let (registered, register) = recorder();
|
||||
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::NotNeeded, &idle, ®ister),
|
||||
CjkInstall::NotNeeded
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::System { family: "SimSun" }, &idle, ®ister),
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::Unprobeable, &idle, ®ister),
|
||||
CjkInstall::Unprobeable
|
||||
);
|
||||
assert_eq!(
|
||||
idle.calls(),
|
||||
0,
|
||||
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
|
||||
);
|
||||
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
|
||||
|
||||
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
|
||||
let working = FakeFetcher::new(Ok(vec![9; 4096]));
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, ®ister),
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: false
|
||||
}
|
||||
);
|
||||
assert_eq!(working.calls(), 1);
|
||||
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
|
||||
assert_eq!(*registered.borrow(), vec![4096]);
|
||||
}
|
||||
|
||||
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
|
||||
///
|
||||
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
|
||||
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
|
||||
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
|
||||
#[test]
|
||||
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
|
||||
let (registered, register) = recorder();
|
||||
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
|
||||
|
||||
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, ®ister);
|
||||
assert_eq!(
|
||||
state,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "sha256 不符".to_owned()
|
||||
}
|
||||
);
|
||||
assert!(registered.borrow().is_empty(), "校验失败不许注册");
|
||||
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
|
||||
}
|
||||
|
||||
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
|
||||
#[test]
|
||||
fn every_failure_class_survives_the_trip() {
|
||||
let (_, register) = recorder();
|
||||
for (error, class) in [
|
||||
(FetchError::Network("超时".to_owned()), FetchClass::Network),
|
||||
(
|
||||
FetchError::Integrity("哈希不符".to_owned()),
|
||||
FetchClass::Integrity,
|
||||
),
|
||||
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
|
||||
] {
|
||||
let fetcher = FakeFetcher::new(Err(error));
|
||||
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, ®ister);
|
||||
match state {
|
||||
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
|
||||
other => panic!("应当是 Failed:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
|
||||
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
|
||||
let state = ensure_for(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&fetcher,
|
||||
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
|
||||
);
|
||||
assert!(matches!(
|
||||
state,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// 状态机的**幂等**:已就绪 / 下载中时再问一次不会起第二条下载。
|
||||
///
|
||||
/// 抓得住:`after_probe` 里删掉 `is_downloading() || Ready` 那半(用户来回切语言会起 N 条线程,
|
||||
/// 每条都下一遍 17 MiB)。本用例只测纯判据那一层——`after_probe` 的线程行为要真网络,没测。
|
||||
#[test]
|
||||
fn a_started_or_finished_install_is_not_restarted() {
|
||||
// 直接打状态机的输入输出:Ready 与 Downloading 都不会进 fetcher。
|
||||
let (_, register) = recorder();
|
||||
let idle = FakeFetcher::new(Ok(vec![1]));
|
||||
for state in [
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
] {
|
||||
assert!(!matches!(state, CjkInstall::Unknown), "{state:?}");
|
||||
assert_eq!(idle.calls(), 0);
|
||||
let _ = ®ister;
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
|
||||
///
|
||||
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
|
||||
#[test]
|
||||
fn the_capture_switch_parses_every_state() {
|
||||
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
|
||||
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
|
||||
assert!(matches!(
|
||||
parse_capture("system"),
|
||||
Some(CjkInstall::System { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_capture("downloading"),
|
||||
Some(CjkInstall::Downloading { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_capture("ready"),
|
||||
Some(CjkInstall::Ready {
|
||||
from_cache: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
for (raw, class) in [
|
||||
("failed", FetchClass::Network),
|
||||
("failed:network", FetchClass::Network),
|
||||
("failed:integrity", FetchClass::Integrity),
|
||||
("failed:disk", FetchClass::Disk),
|
||||
] {
|
||||
match parse_capture(raw) {
|
||||
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
|
||||
other => panic!("{raw} 应解析成 Failed:{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(parse_capture("bogus"), None);
|
||||
assert_eq!(parse_capture(""), None);
|
||||
}
|
||||
|
||||
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
|
||||
#[test]
|
||||
fn every_state_describes_itself_on_one_line() {
|
||||
let states = [
|
||||
CjkInstall::Unknown,
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned(),
|
||||
},
|
||||
CjkInstall::Unprobeable,
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: false,
|
||||
},
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "sha256 不符".to_owned(),
|
||||
},
|
||||
];
|
||||
for state in states {
|
||||
let line = state.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk-install="), "{line}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
|
||||
//!
|
||||
//! ## 为什么拆出来
|
||||
//!
|
||||
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
|
||||
//!
|
||||
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
|
||||
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
|
||||
//!
|
||||
//! ## 为什么下载在后台线程
|
||||
//!
|
||||
//! 一份 17 MiB 的字体在弱网下要几十秒,而两个触发点(启动编排、设置页切语言)都在 UI 线程上。
|
||||
//! 在 UI 线程同步下完 = 窗口几十秒不响应(启动路径甚至是「窗口还没出来」)。
|
||||
//!
|
||||
//! ## 线程里做了什么(顺序即判据)
|
||||
//!
|
||||
//! ```text
|
||||
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验)
|
||||
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
|
||||
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
|
||||
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
|
||||
//! ```
|
||||
//!
|
||||
//! ## 诚实边界
|
||||
//!
|
||||
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
|
||||
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
|
||||
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机)。
|
||||
|
||||
use unlucky_i18n::font::{self, Strategy};
|
||||
|
||||
use super::{ensure_for, update, CjkInstall, FetchClass};
|
||||
use crate::fonts::cdn::CdnFetcher;
|
||||
use crate::platform::data;
|
||||
|
||||
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
|
||||
pub(super) fn start(font: &'static font::CjkFont) {
|
||||
let font::Source::Pinned { bytes, .. } = font.source;
|
||||
let Some(cache_root) = data::cache_root() else {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "数据根解析不出来,字体无处可落".to_owned(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let fetcher = match CdnFetcher::new(font, cache_root) {
|
||||
Ok(fetcher) => fetcher,
|
||||
Err(error) => {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::from(&error),
|
||||
message: error.message().to_owned(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cached = fetcher.has_cached();
|
||||
update(CjkInstall::Downloading { bytes });
|
||||
eprintln!(
|
||||
"[font] cjk-download start family=\"{}\" bytes={bytes} from_cache={cached} file={}",
|
||||
font.family,
|
||||
fetcher.cache_file().display()
|
||||
);
|
||||
|
||||
// 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。
|
||||
std::thread::spawn(move || {
|
||||
let state = ensure_for(
|
||||
Strategy::NeedsDownload(font),
|
||||
&fetcher,
|
||||
®ister_into_font_system,
|
||||
);
|
||||
let state = match state {
|
||||
// 命中缓存的那一份要把 `from_cache` 标出来(状态行靠它区分「等了一会儿」与「真的下了一次」)。
|
||||
CjkInstall::Ready { family, .. } => CjkInstall::Ready {
|
||||
family,
|
||||
from_cache: cached,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
match &state {
|
||||
CjkInstall::Ready { family, from_cache } => eprintln!(
|
||||
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} (中文字形现在由它兜底,界面不需要重启)"
|
||||
),
|
||||
CjkInstall::Failed { class, message } => eprintln!(
|
||||
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
|
||||
class.as_str()
|
||||
),
|
||||
other => eprintln!("[font] cjk-download {}", other.describe()),
|
||||
}
|
||||
update(state);
|
||||
});
|
||||
}
|
||||
|
||||
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
|
||||
///
|
||||
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
|
||||
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
|
||||
///
|
||||
/// # Errors
|
||||
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
|
||||
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
|
||||
let lock = iced::advanced::graphics::text::font_system();
|
||||
let mut font_system = lock
|
||||
.write()
|
||||
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
|
||||
|
||||
font_system.load_font(std::borrow::Cow::Owned(bytes));
|
||||
|
||||
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
|
||||
// 那个借用活过守卫(E0597)。
|
||||
let families: Vec<String> = font_system
|
||||
.raw()
|
||||
.db()
|
||||
.faces()
|
||||
.flat_map(|face| face.families.iter())
|
||||
.map(|(family, _)| family.clone())
|
||||
.collect();
|
||||
|
||||
families
|
||||
.iter()
|
||||
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
|
||||
}
|
||||
@@ -46,6 +46,7 @@ use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::LangChoice;
|
||||
|
||||
use crate::fonts::install::{CjkInstall, FetchClass};
|
||||
use crate::pages::{frame, Language, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
@@ -71,6 +72,10 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stacked = column![].spacing(0.0).width(Length::Fill);
|
||||
stacked = stacked.push(language_row(ui, language));
|
||||
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
|
||||
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(font_row(ui, language));
|
||||
|
||||
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
for (label, value) in [
|
||||
@@ -85,6 +90,110 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 中文字体状态行的**文案**(纯函数:状态 × 目录 → 一句话 + 可选的一句解释)。
|
||||
///
|
||||
/// 为什么单独拎出来:截图只能证明「画出来了」,而这里能被断言证明「四种状态的词都在、占位符都被
|
||||
/// 替换、两套目录都没有 `⟪missing⟫`」(见 `tests/general_section.rs`)。
|
||||
///
|
||||
/// 失败态**不写具体错误串**:那是给我们看的(`[font] cjk-download failed` 那行日志),用户只需要
|
||||
/// 知道「网络 / 内容 / 磁盘」里哪一类,以及「界面照常可用」。
|
||||
pub(super) fn font_status(
|
||||
catalog: &unlucky_i18n::catalog::Catalog,
|
||||
status: &CjkInstall,
|
||||
) -> (String, Option<String>) {
|
||||
match status {
|
||||
CjkInstall::Unknown => (catalog.text(Key::FontCjkStatusUnknown).to_owned(), None),
|
||||
CjkInstall::Unprobeable => (catalog.text(Key::FontCjkStatusUnprobeable).to_owned(), None),
|
||||
CjkInstall::NotNeeded => (catalog.text(Key::FontCjkStatusNotNeeded).to_owned(), None),
|
||||
CjkInstall::System { family } => (
|
||||
catalog.render(Key::FontCjkStatusSystem, &[("family", family)]),
|
||||
None,
|
||||
),
|
||||
CjkInstall::Downloading { bytes } => (
|
||||
catalog.render(
|
||||
Key::FontCjkStatusDownloading,
|
||||
&[("size", &format_size(*bytes))],
|
||||
),
|
||||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||||
),
|
||||
CjkInstall::Ready { family, .. } => (
|
||||
catalog.render(Key::FontCjkReady, &[("family", family)]),
|
||||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||||
),
|
||||
CjkInstall::Failed { class, .. } => {
|
||||
let reason = catalog.text(match class {
|
||||
FetchClass::Network => Key::FontCjkReasonNetwork,
|
||||
FetchClass::Integrity => Key::FontCjkReasonIntegrity,
|
||||
FetchClass::Disk => Key::FontCjkReasonDisk,
|
||||
});
|
||||
(
|
||||
catalog.render(Key::FontCjkStatusFailed, &[("reason", reason)]),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人话体积(`17.0 MB`)。只用于「≈」级别的量,不追求精确位数。
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const MIB: f64 = 1024.0 * 1024.0;
|
||||
let mib = bytes as f64 / MIB;
|
||||
if mib >= 1.0 {
|
||||
format!("{mib:.1} MB")
|
||||
} else {
|
||||
format!("{:.0} KB", bytes as f64 / 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文字体状态行:标签一行、状态一行(失败红、就绪主色)、需要时再补一句解释。
|
||||
///
|
||||
/// 为什么**竖排**而不是像主题那几行「标签左、取值右」:失败那句话是整句(「获取失败(网络不通)。
|
||||
/// 界面照常可用…」),塞进右列会把它压成一行挤掉标签(实测截图就是一整行红字、标签消失)。
|
||||
/// 语言行也是竖排(标题 + 选项 + 说明),这一行贴着它,观感一致。
|
||||
fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let status = crate::fonts::install::status();
|
||||
let (value, hint) = font_status(&catalog, &status);
|
||||
let color = match status {
|
||||
CjkInstall::Failed { .. } => ui.color(Role::Error),
|
||||
CjkInstall::Ready { .. } | CjkInstall::System { .. } => ui.color(Role::Primary),
|
||||
_ => ui.color(Role::OnSurfaceVariant),
|
||||
};
|
||||
|
||||
let mut stacked = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
catalog.text(Key::FontCjkStatusLabel),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
container(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
value,
|
||||
color
|
||||
))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]));
|
||||
|
||||
if let Some(hint) = hint {
|
||||
stacked = stacked.push(container(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)));
|
||||
}
|
||||
|
||||
stacked.into()
|
||||
}
|
||||
|
||||
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
|
||||
fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
|
||||
@@ -35,6 +35,82 @@ fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **M168(D28 后半)**:中文字体下载状态行的四种状态都得有话说,且两套目录都不许 `⟪missing⟫`。
|
||||
///
|
||||
/// 抓得住:①只画「下载成功」一种(失败的用户会以为程序坏了);②失败态的文案写成「保持英文」
|
||||
/// (D28 的实际行为是**继续用系统字体**,界面语言不变);③占位符没被替换(用户会看到 `{family}`);
|
||||
/// ④新 key 只进了英文表(中文界面上出现 `⟪missing⟫`)。
|
||||
/// 抓不住:真下载时的样子(那要联网)、字体有没有真的出字(截图 + 真机,见报告)。
|
||||
#[test]
|
||||
fn the_font_status_row_speaks_in_four_states_and_two_languages() {
|
||||
use crate::fonts::install::{CjkInstall, FetchClass};
|
||||
|
||||
let states = [
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "timeout".to_owned(),
|
||||
},
|
||||
CjkInstall::System {
|
||||
family: "Microsoft YaHei".to_owned(),
|
||||
},
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::Unprobeable,
|
||||
CjkInstall::Unknown,
|
||||
];
|
||||
|
||||
for lang in Lang::ALL {
|
||||
let catalog = lang.catalog();
|
||||
assert!(
|
||||
catalog.get(Key::FontCjkStatusLabel).is_some(),
|
||||
"{} 目录里缺中文字体那一行的标签",
|
||||
lang.tag()
|
||||
);
|
||||
for state in &states {
|
||||
let (value, hint) = general::font_status(&catalog, state);
|
||||
assert!(!value.is_empty(), "{state:?} 没话说");
|
||||
assert!(!value.contains('{'), "{state:?} 占位符没替换:{value}");
|
||||
assert!(
|
||||
!value.contains(unlucky_i18n::catalog::MISSING),
|
||||
"{state:?} 在 {} 目录里缺译:{value}",
|
||||
lang.tag()
|
||||
);
|
||||
if let Some(hint) = hint {
|
||||
assert!(!hint.contains('{'), "{state:?} 的提示没替换:{hint}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败态必须说清三类原因之一(而不是一句「失败了」)。
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
for (class, reason) in [
|
||||
(FetchClass::Network, "网络不通"),
|
||||
(FetchClass::Integrity, "镜像内容校验不通过"),
|
||||
(FetchClass::Disk, "写不进数据目录"),
|
||||
] {
|
||||
let (value, _) = general::font_status(
|
||||
&zh,
|
||||
&CjkInstall::Failed {
|
||||
class,
|
||||
message: "x".to_owned(),
|
||||
},
|
||||
);
|
||||
assert!(value.contains(reason), "失败原因没进文案:{value}");
|
||||
}
|
||||
|
||||
// 下载中写的是**真实**字节数(17.0 MB),不是假百分比。
|
||||
let (value, _) = general::font_status(&zh, &CjkInstall::Downloading { bytes: 17_772_300 });
|
||||
assert!(
|
||||
value.contains("16.9 MB"),
|
||||
"17 772 300 B ≈ 16.9 MiB:{value}"
|
||||
);
|
||||
assert!(!value.contains('%'), "没有真进度就不许写百分比:{value}");
|
||||
}
|
||||
|
||||
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
|
||||
#[test]
|
||||
fn the_general_group_keeps_its_icon() {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
use crate::Backend;
|
||||
|
||||
pub mod data;
|
||||
pub mod locale;
|
||||
pub mod win32;
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! OFL 中文字体的取回(D28 后半):**缓存命中 → 共享引擎下载 → 复验 → 原子落盘**。
|
||||
//!
|
||||
//! ## 一段话
|
||||
//!
|
||||
//! `unlucky_i18n::font` 只做判据与「该下载什么」(URL 候选 + sha256 + 字节数),真正把字节弄到
|
||||
//! 盘上的活在这里:
|
||||
//!
|
||||
//! ```text
|
||||
//! <数据根>/fonts/cjk-noto-sans-sc-<sha256 前 16 位>.ttf
|
||||
//! 已有且哈希 + 魔数都对 ⇒ 直接返回(零网络)
|
||||
//! 否则 ⇒ 共享引擎的 download_bytes(进内存)
|
||||
//! ⇒ 自己再算一遍 sha256、再看一眼是不是 sfnt、再看大小上限
|
||||
//! ⇒ 全过才 write_atomic 落盘 ⇒ 返回字节
|
||||
//! ```
|
||||
//!
|
||||
//! ## 为什么用「内存下载」而不是「边下边写文件」
|
||||
//!
|
||||
//! 一张 17 MiB 的字体本来就要整份进内存(注册进字体系统要 `Vec<u8>`),而**先复验再落盘**让
|
||||
//! 「哈希不符」这条判据简单到不可能出错:盘上要么没有这个文件,要么它是校验过的。文件下载路径
|
||||
//! (`DownloadEngine::download`)会先写 `<dest>.ullpart-*` 再换名,中间态与「拒绝落盘」混在一起,
|
||||
//! 对这一份内容寻址的小资源没有收益。
|
||||
//!
|
||||
//! ## 三条判据(都有守卫,见 `tests.rs`)
|
||||
//!
|
||||
//! 1. **缓存命中不联网**:命中就 `from_cache`,一条 HTTP 都不发(同进程第二次、二次启动都一样);
|
||||
//! 哈希不符的旧文件会被删掉重下(自愈),不冒充命中、也不永远挡路。
|
||||
//! 2. **sha256 不符拒绝落盘**:复验在任何写盘动作**之前**;不符 ⇒ 正式路径上什么都没有。
|
||||
//! 3. **大小上限**:[`MAX_FONT_BYTES`]。pin 声称超过上限的直接拒(不发请求);实得字节超上限的
|
||||
//! 也拒。挡的是「CDN 被换成一个大文件」这类事故,不是省流量。
|
||||
//!
|
||||
//! ## 失败分类为什么是三类
|
||||
//!
|
||||
//! 上层(设置页那一行)只需决定「说什么话」:网络类可重试、校验类是内容不对、磁盘类得腾空间。
|
||||
//! 再多分类对用户没有区别。
|
||||
//!
|
||||
//! ## 与 M165 共享引擎的关系
|
||||
//!
|
||||
//! **不另写 HTTP 通路**:请求一律走 [`super::shared::engine_for`] 那个进程级引擎(连接池 / 限流 /
|
||||
//! 健康度 / 候选源都复用)。本模块只额外提供「字体这个资源特有的三件事」:内容寻址的缓存文件名、
|
||||
//! 复验、大小上限。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::atomic::write_atomic;
|
||||
use super::blocking::block_on;
|
||||
use super::error::DownloadErrorKind;
|
||||
use super::shared;
|
||||
use super::types::{DownloadRequest, HashAlgo, ResourceKind};
|
||||
use super::verify;
|
||||
|
||||
/// 一套字体允许的最大字节数(32 MiB)。
|
||||
///
|
||||
/// 实测参考:Noto Sans SC 可变字体(wght 100–900,整个简体字符集)17.0 MiB;再大的「字体」基本
|
||||
/// 可以判断不是给 UI 用的字体。上限的用处不是省流量,是**挡住被换掉的 CDN 把几十上百 MB 的东西
|
||||
/// 写进数据根**。
|
||||
pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FontFetchError {
|
||||
/// 网络层:连不上 / 超时 / 非 2xx / 所有候选源都失败。
|
||||
Network(String),
|
||||
/// 内容层:sha256 不符 / 不是字体文件 / 超出大小上限。
|
||||
Integrity(String),
|
||||
/// 本地磁盘层:建目录、写文件、换名失败。
|
||||
Disk(String),
|
||||
}
|
||||
|
||||
impl FontFetchError {
|
||||
/// 稳定类别名(`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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FontFetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.class(), self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FontFetchError {}
|
||||
|
||||
impl From<super::error::DownloadError> for FontFetchError {
|
||||
fn from(error: super::error::DownloadError) -> Self {
|
||||
// 用叶子成因:`download_bytes` 的整体失败会带上它(全 404 → NotFound,出现网络错 → Network,
|
||||
// 校验不符 → Integrity)。少了叶子这一层,哈希不符会被读成「没联网」。
|
||||
let message = error.to_string();
|
||||
match error.effective_kind() {
|
||||
DownloadErrorKind::Io => Self::Disk(message),
|
||||
DownloadErrorKind::Integrity | DownloadErrorKind::InvalidRequest => {
|
||||
Self::Integrity(message)
|
||||
}
|
||||
_ => Self::Network(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一套字体的「在哪拿、拿到什么」:URL 候选(首选 + 备用镜像)+ 期望 sha256 + 期望字节数。
|
||||
///
|
||||
/// 与 `unlucky_i18n::font::Source::Pinned` 是同一份事实的两种表示:i18n 那份是**常量**(零依赖
|
||||
/// crate 里写死),本结构是**值**(可被设置改写、可在测试里注入假 URL)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FontSpec {
|
||||
/// URL 候选,按顺序尝试(首选 CDN 挂了才试备用镜像)。
|
||||
pub urls: Vec<String>,
|
||||
/// 期望 sha256(小写十六进制、64 位)。
|
||||
pub sha256: String,
|
||||
/// 期望字节数(进度预算 + 大小上限的判据)。
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
impl FontSpec {
|
||||
/// 构造并做基本自洽检查(URL 非空 / 哈希形态 / 大小上限)。
|
||||
///
|
||||
/// # Errors
|
||||
/// pin 本身不合法时返回 [`FontFetchError::Integrity`](**不发请求**)。
|
||||
pub fn new(
|
||||
urls: impl IntoIterator<Item = impl Into<String>>,
|
||||
sha256: impl Into<String>,
|
||||
bytes: u64,
|
||||
) -> Result<Self, FontFetchError> {
|
||||
let spec = Self {
|
||||
urls: urls.into_iter().map(Into::into).collect(),
|
||||
sha256: sha256.into().trim().to_ascii_lowercase(),
|
||||
bytes,
|
||||
};
|
||||
spec.check()?;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// pin 是否自洽。
|
||||
fn check(&self) -> Result<(), FontFetchError> {
|
||||
if self.urls.is_empty() {
|
||||
return Err(FontFetchError::Integrity("字体 pin 没有 URL".to_owned()));
|
||||
}
|
||||
if let Some(bad) = self
|
||||
.urls
|
||||
.iter()
|
||||
.find(|url| !(url.starts_with("https://") || url.starts_with("http://")))
|
||||
{
|
||||
return Err(FontFetchError::Integrity(format!(
|
||||
"字体 pin 只接受 http(s) URL:{bad}"
|
||||
)));
|
||||
}
|
||||
if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(FontFetchError::Integrity(format!(
|
||||
"字体 pin 的 sha256 不是 64 位十六进制:{}",
|
||||
self.sha256
|
||||
)));
|
||||
}
|
||||
if self.bytes == 0 || self.bytes > MAX_FONT_BYTES {
|
||||
return Err(FontFetchError::Integrity(format!(
|
||||
"字体 pin 的字节数 {} 不在 (0, {MAX_FONT_BYTES}] 内",
|
||||
self.bytes
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 缓存文件名的哈希段(前 16 位:够区分,又不至于让路径长得没法看)。
|
||||
#[must_use]
|
||||
fn cache_key(&self) -> &str {
|
||||
let end = self.sha256.len().min(16);
|
||||
&self.sha256[..end]
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次取回的产物。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FontFetch {
|
||||
/// 字体字节(交给调用方注册进字体系统)。
|
||||
pub bytes: Vec<u8>,
|
||||
/// 是否零网络命中缓存。
|
||||
pub from_cache: bool,
|
||||
/// 成功的源标识(`cache` / 引擎给的候选源名)。
|
||||
pub source: String,
|
||||
/// 耗时。
|
||||
pub elapsed: Duration,
|
||||
}
|
||||
|
||||
/// 缓存文件落点:`<数据根>/fonts/cjk-noto-sans-sc-<sha256 前 16 位>.ttf`。
|
||||
///
|
||||
/// 文件名带哈希段是刻意的:pin 一改(换字体 / 换子集),新文件自然换名,旧文件不会冒充命中。
|
||||
#[must_use]
|
||||
pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf {
|
||||
cache_root
|
||||
.join("fonts")
|
||||
.join(format!("cjk-noto-sans-sc-{}.ttf", spec.cache_key()))
|
||||
}
|
||||
|
||||
/// 取一套字体(**阻塞**:内部走进程共享运行时)。
|
||||
///
|
||||
/// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。
|
||||
///
|
||||
/// # Errors
|
||||
/// 见 [`FontFetchError`]。失败**不是**致命错误:调用方应当降级回系统字体 / 英文界面。
|
||||
pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result<FontFetch, FontFetchError> {
|
||||
let started = Instant::now();
|
||||
spec.check()?;
|
||||
|
||||
let final_path = cache_path(cache_root, spec);
|
||||
if let Some(bytes) = read_verified(&final_path, spec)? {
|
||||
return Ok(FontFetch {
|
||||
bytes,
|
||||
from_cache: true,
|
||||
source: "cache".to_owned(),
|
||||
elapsed: started.elapsed(),
|
||||
});
|
||||
}
|
||||
|
||||
let engine = shared::engine_for(cache_root);
|
||||
let mut network_error: Option<FontFetchError> = None;
|
||||
let mut integrity_error: Option<FontFetchError> = None;
|
||||
|
||||
for url in &spec.urls {
|
||||
let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path)
|
||||
.with_sha256(&spec.sha256)
|
||||
.with_size(spec.bytes);
|
||||
|
||||
match block_on(engine.download_bytes(request)) {
|
||||
Ok(bytes) => {
|
||||
if bytes.len() as u64 > MAX_FONT_BYTES {
|
||||
integrity_error = Some(FontFetchError::Integrity(format!(
|
||||
"字体超过大小上限:{} > {MAX_FONT_BYTES}",
|
||||
bytes.len()
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
// 引擎的流式校验与这一道是**两处独立**的判据:这里同时看哈希与魔数,且都在写盘之前。
|
||||
let actual = verify::digest_bytes(HashAlgo::Sha256, &bytes);
|
||||
if !verify::matches(&spec.sha256, &actual) {
|
||||
integrity_error = Some(FontFetchError::Integrity(format!(
|
||||
"字体 sha256 不符:期望 {} 实得 {actual}({url})",
|
||||
spec.sha256
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
if !looks_like_sfnt(&bytes) {
|
||||
integrity_error = Some(FontFetchError::Integrity(format!(
|
||||
"取回的不是字体文件(sfnt 魔数不符):{url}"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
write_atomic(&final_path, &bytes).map_err(|error| {
|
||||
FontFetchError::Disk(format!(
|
||||
"字体落盘失败({}):{error}",
|
||||
final_path.display()
|
||||
))
|
||||
})?;
|
||||
return Ok(FontFetch {
|
||||
bytes,
|
||||
from_cache: false,
|
||||
source: url.clone(),
|
||||
elapsed: started.elapsed(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let mapped = FontFetchError::from(error);
|
||||
match mapped {
|
||||
FontFetchError::Integrity(_) => integrity_error = Some(mapped),
|
||||
_ => network_error = Some(mapped),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 报错时优先报「内容不对」:那说明源给了不该给的东西,比「连不上」更需要人看一眼。
|
||||
Err(integrity_error
|
||||
.or(network_error)
|
||||
.unwrap_or_else(|| FontFetchError::Network("字体 pin 没有可用 URL".to_owned())))
|
||||
}
|
||||
|
||||
/// 读缓存并复验(`Ok(None)` = 不存在或校验不过,**且坏文件已被删掉**)。
|
||||
fn read_verified(path: &Path, spec: &FontSpec) -> Result<Option<Vec<u8>>, FontFetchError> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(FontFetchError::Disk(format!(
|
||||
"读字体缓存失败({}):{error}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let actual = verify::digest_bytes(HashAlgo::Sha256, &bytes);
|
||||
if !verify::matches(&spec.sha256, &actual) || !looks_like_sfnt(&bytes) {
|
||||
// 坏缓存自愈:删掉,交给下载路径重来(不把坏文件当命中,也不让它一直挡路)。
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
/// 是不是一个 sfnt 字体文件(TTF / OTF / TTC)。
|
||||
///
|
||||
/// 只认魔数 + 表目录读得出来:够挡住「CDN 回了一个 HTML 错误页」这类事故,**不**做字形覆盖校验
|
||||
/// (那要 `ttf-parser`,core 的依赖纪律不允许为一个判据引它)。
|
||||
#[must_use]
|
||||
pub fn looks_like_sfnt(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 12 {
|
||||
return false;
|
||||
}
|
||||
let tag = &bytes[..4];
|
||||
if tag == b"ttcf" {
|
||||
// 字体集合:只要长度说得过去就收(成员表的细节不值得在这里解析)。
|
||||
return bytes.len() >= 16;
|
||||
}
|
||||
if !matches!(tag, b"\x00\x01\x00\x00" | b"OTTO" | b"true") {
|
||||
return false;
|
||||
}
|
||||
let tables = u16::from_be_bytes([bytes[4], bytes[5]]);
|
||||
tables > 0 && bytes.len() >= 12 + usize::from(tables) * 16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "font/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,280 @@
|
||||
//! `download::font` 的守卫(三条都能变红,变异证明见 M168 报告)。
|
||||
//!
|
||||
//! ## 离线纪律
|
||||
//!
|
||||
//! 服务端是 `127.0.0.1` 上的回环监听器,把**内存里的假字体字节**当响应体发出去:不碰任何
|
||||
//! 外网站点,跑 `cargo test` 不需要网络。真 CDN 的字节有没有变、17 MiB 要下多久,这些**不在**
|
||||
//! 本文件的能力范围内(报告里写明)。
|
||||
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::types::HashAlgo;
|
||||
use super::*;
|
||||
|
||||
/// 每个用例一个独立数据根(共享引擎的登记处是进程级的,键撞了会互相干扰)。
|
||||
fn root(tag: &str) -> PathBuf {
|
||||
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("ull-font-{tag}-{}-{seq}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建数据根");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 一段「像字体」的字节:sfnt 头 + 一张表记录 + 载荷。
|
||||
///
|
||||
/// 为什么造它而不是塞随机字节:`looks_like_sfnt` 是落盘前的一道闸,测试若拿 HTML 当正常内容,
|
||||
/// 「下载成功」这条路径就永远走不到。
|
||||
fn fake_font(payload: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"\x00\x01\x00\x00"); // sfnt 版本
|
||||
bytes.extend_from_slice(&1u16.to_be_bytes()); // numTables
|
||||
bytes.extend_from_slice(&[0u8; 6]); // searchRange / entrySelector / rangeShift
|
||||
bytes.extend_from_slice(b"TEST"); // 表名
|
||||
bytes.extend_from_slice(&0u32.to_be_bytes()); // checksum
|
||||
bytes.extend_from_slice(&28u32.to_be_bytes()); // offset
|
||||
bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
verify::digest_bytes(HashAlgo::Sha256, bytes)
|
||||
}
|
||||
|
||||
/// 回环 HTTP 服务:不管请求什么,都回同一份字节;记下处理了几条请求。
|
||||
///
|
||||
/// 只讲 HTTP/1.1 keep-alive,够用来判「第二次调用有没有再发请求」。
|
||||
fn serve(body: Vec<u8>, expected_requests: usize) -> (String, Arc<AtomicUsize>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口");
|
||||
let addr = listener.local_addr().expect("取端口");
|
||||
let served = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&served);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut requests = 0usize;
|
||||
while requests < expected_requests {
|
||||
let Ok((mut socket, _)) = listener.accept() else {
|
||||
break;
|
||||
};
|
||||
let _ = socket.set_read_timeout(Some(Duration::from_secs(10)));
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
match socket.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(read) => seen.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
if !seen.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
continue;
|
||||
}
|
||||
requests += 1;
|
||||
counter.store(requests, Ordering::SeqCst);
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
if socket.write_all(head.as_bytes()).is_err() || socket.write_all(&body).is_err() {
|
||||
break;
|
||||
}
|
||||
seen.clear();
|
||||
if requests >= expected_requests {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(format!("http://{addr}/cjk.ttf"), served)
|
||||
}
|
||||
|
||||
/// 一个**不会**有人监听的地址:证明「没联网」比证明「联网成功」更有力。
|
||||
fn dead_url() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("占一个端口");
|
||||
let addr = listener.local_addr().expect("取端口");
|
||||
drop(listener);
|
||||
format!("http://{addr}/cjk.ttf")
|
||||
}
|
||||
|
||||
fn spec_for(urls: Vec<String>, bytes: &[u8]) -> FontSpec {
|
||||
FontSpec::new(urls, sha256(bytes), bytes.len() as u64).expect("pin 应自洽")
|
||||
}
|
||||
|
||||
/// 守卫 ③:**缓存命中不联网**。
|
||||
///
|
||||
/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉(或改成「只 stat 不校验」以外的任何短路取消)
|
||||
/// —— 那样 URL 指向的死人端口会被真的去连,本用例当场红。
|
||||
/// 抓不住:真机上 17 MiB 文件读盘有多快。
|
||||
#[test]
|
||||
fn cache_hit_never_touches_the_network() {
|
||||
let dir = root("cache");
|
||||
let body = fake_font(b"cached payload");
|
||||
let spec = spec_for(vec![dead_url()], &body);
|
||||
let path = cache_path(&dir, &spec);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).expect("建字体目录");
|
||||
std::fs::write(&path, &body).expect("预置缓存");
|
||||
|
||||
let fetched = fetch_blocking(&dir, &spec).expect("缓存命中应成功");
|
||||
assert!(fetched.from_cache, "必须报「来自缓存」");
|
||||
assert_eq!(fetched.bytes, body);
|
||||
assert_eq!(fetched.source, "cache");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 守卫 ②:**sha256 不符时拒绝落盘**(正式路径上什么都不许留下)。
|
||||
///
|
||||
/// 抓得住:把复验换成「信引擎的」或把中间文件直接当成品换名——本用例会在 `cache_path` 存在
|
||||
/// 与「错误类别」两处同时红。
|
||||
/// 抓不住:引擎自身的流式校验(那是 `transfer` 的守卫)。
|
||||
#[test]
|
||||
fn sha256_mismatch_is_refused_and_nothing_lands() {
|
||||
let dir = root("mismatch");
|
||||
let served = fake_font(b"what the cdn claims");
|
||||
let expected = fake_font(b"what the pin expects");
|
||||
let (url, _served) = serve(served.clone(), 4);
|
||||
let spec = FontSpec::new(vec![url], sha256(&expected), served.len() as u64).expect("pin");
|
||||
|
||||
let error = fetch_blocking(&dir, &spec).expect_err("哈希不符必须失败");
|
||||
assert_eq!(error.class(), "integrity", "{error}");
|
||||
let path = cache_path(&dir, &spec);
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"校验不过的字体不许出现在 {}",
|
||||
path.display()
|
||||
);
|
||||
// 字体目录里什么都不该留下:复验在写盘之前,所以连中间态都不存在。
|
||||
let leftovers = std::fs::read_dir(path.parent().unwrap())
|
||||
.map_or(0, |entries| entries.filter_map(Result::ok).count());
|
||||
assert_eq!(leftovers, 0, "校验不过时字体目录必须干净");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 守卫 ③ 的另一半:**下过一次就再也不下**(同进程第二次调用零请求)。
|
||||
///
|
||||
/// 抓得住:缓存写失败(换名没做)/ 缓存文件名不带哈希(pin 换了还命中旧文件)/ 每次调用都
|
||||
/// 重新下载——第一条与第三条都会让 `served == 2`,本用例红。
|
||||
#[test]
|
||||
fn the_second_call_is_served_from_the_cache() {
|
||||
let dir = root("second");
|
||||
let body = fake_font(b"payload of the second call");
|
||||
// 服务端最多伺候 4 条请求:若第二次调用又联网,计数会变成 2。
|
||||
let (url, served) = serve(body.clone(), 4);
|
||||
let spec = spec_for(vec![url], &body);
|
||||
|
||||
let first = fetch_blocking(&dir, &spec).expect("第一次应成功");
|
||||
assert!(
|
||||
!first.from_cache,
|
||||
"第一次必须是真下载(否则这条用例什么也没证明"
|
||||
);
|
||||
assert_eq!(first.bytes, body);
|
||||
|
||||
let second = fetch_blocking(&dir, &spec).expect("第二次应命中缓存");
|
||||
assert!(second.from_cache, "第二次不许再联网");
|
||||
assert_eq!(
|
||||
served.load(Ordering::SeqCst),
|
||||
1,
|
||||
"整个过程只该有一条 HTTP 请求"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 坏缓存**自愈**:哈希不符的旧文件要被删掉重下,而不是被当成命中,也不是永远挡路。
|
||||
#[test]
|
||||
fn a_corrupt_cache_file_is_healed_by_a_fresh_download() {
|
||||
let dir = root("heal");
|
||||
let body = fake_font(b"fresh bytes");
|
||||
let spec = spec_for(vec![dead_url()], &body);
|
||||
let path = cache_path(&dir, &spec);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).expect("建字体目录");
|
||||
std::fs::write(&path, fake_font(b"rotten bytes")).expect("预置坏缓存");
|
||||
|
||||
// 坏缓存 + 死人 URL ⇒ 只能报网络错(若把坏文件当命中会返回 Ok,本用例红)。
|
||||
let error = fetch_blocking(&dir, &spec).expect_err("坏缓存不该被当成命中");
|
||||
assert_eq!(error.class(), "network", "{error}");
|
||||
assert!(!path.exists(), "坏缓存必须被删掉");
|
||||
|
||||
// 换成真服务端:同一次进程里重来一次就能落地(自愈)。
|
||||
let (url, _served) = serve(body.clone(), 4);
|
||||
let spec = spec_for(vec![url], &body);
|
||||
let fetched = fetch_blocking(&dir, &spec).expect("自愈后应成功");
|
||||
assert!(!fetched.from_cache);
|
||||
assert!(path.exists());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// CDN 回了一个 HTML 错误页(哈希还对不上/对得上都一样):不许当字体落盘。
|
||||
///
|
||||
/// 抓得住:`looks_like_sfnt` 这道闸被删掉——哈希正确时它会让一堆 HTML 进字体系统。
|
||||
/// 抓不住:真字体里子集不全这类内容问题(那要 cmap 检查)。
|
||||
#[test]
|
||||
fn an_html_error_page_is_not_a_font() {
|
||||
let dir = root("html");
|
||||
let body = b"<html><body>403 Forbidden</body></html>".to_vec();
|
||||
let (url, _served) = serve(body.clone(), 4);
|
||||
let spec = spec_for(vec![url], &body);
|
||||
|
||||
let error = fetch_blocking(&dir, &spec).expect_err("HTML 不是字体");
|
||||
assert_eq!(error.class(), "integrity", "{error}");
|
||||
assert!(!cache_path(&dir, &spec).exists());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// pin 自身不合法时**一个请求都不发**(免得为一个坏 pin 白跑一趟网络)。
|
||||
#[test]
|
||||
fn an_invalid_pin_never_reaches_the_network() {
|
||||
let dir = root("pin");
|
||||
let served = fake_font(b"x");
|
||||
let (url, counter) = serve(served.clone(), 4);
|
||||
|
||||
// 哈希形态不对
|
||||
assert!(FontSpec::new(vec![url.clone()], "not-a-hash", 10).is_err());
|
||||
// 非 http(s)
|
||||
assert!(FontSpec::new(vec!["file:///etc/passwd"], sha256(&served), 10).is_err());
|
||||
// 没有 URL
|
||||
assert!(FontSpec::new(Vec::<String>::new(), sha256(&served), 10).is_err());
|
||||
// 超过大小上限
|
||||
assert!(FontSpec::new(vec![url], sha256(&served), MAX_FONT_BYTES + 1).is_err());
|
||||
// 零字节
|
||||
assert!(FontSpec::new(vec!["https://example.invalid/f.ttf"], sha256(&served), 0).is_err());
|
||||
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 0, "坏 pin 不许发请求");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `looks_like_sfnt`:只认魔数 + 表目录,不猜内容。
|
||||
#[test]
|
||||
fn sfnt_sniffing_accepts_fonts_and_rejects_junk() {
|
||||
assert!(looks_like_sfnt(&fake_font(b"x")));
|
||||
// OTF(CFF 轮廓):魔数 OTTO + 一张表
|
||||
let mut otf = b"OTTO".to_vec();
|
||||
otf.extend_from_slice(&1u16.to_be_bytes());
|
||||
otf.extend_from_slice(&[0u8; 6]);
|
||||
otf.extend_from_slice(&[0u8; 16]);
|
||||
assert!(looks_like_sfnt(&otf));
|
||||
// TTC(字体集合):长度够就收
|
||||
assert!(looks_like_sfnt(
|
||||
b"ttcf\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
|
||||
));
|
||||
assert!(!looks_like_sfnt(b"<html>"));
|
||||
assert!(!looks_like_sfnt(b"\x00\x01\x00\x00\x00\x00")); // 表数 0
|
||||
assert!(!looks_like_sfnt(b"\x00\x01\x00\x00\x00\x02")); // 长度不够装两张表
|
||||
assert!(!looks_like_sfnt(&[]));
|
||||
}
|
||||
|
||||
/// 缓存文件名带 pin 的哈希段:换 pin(换字体/换子集)⇒ 换文件名 ⇒ 不会命中旧内容。
|
||||
#[test]
|
||||
fn the_cache_name_changes_with_the_pin() {
|
||||
let dir: &Path = std::path::Path::new("/tmp/ull-font-name");
|
||||
let a = FontSpec::new(vec!["https://x.invalid/a.ttf"], "a".repeat(64), 1).expect("pin a");
|
||||
let b = FontSpec::new(vec!["https://x.invalid/a.ttf"], "b".repeat(64), 1).expect("pin b");
|
||||
assert_ne!(cache_path(dir, &a), cache_path(dir, &b));
|
||||
assert_eq!(
|
||||
cache_path(dir, &a),
|
||||
dir.join("fonts")
|
||||
.join(format!("cjk-noto-sans-sc-{}.ttf", "a".repeat(16)))
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ pub mod chunked;
|
||||
pub mod config;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod font;
|
||||
pub mod health;
|
||||
pub mod jdk_sources;
|
||||
pub mod mirrors;
|
||||
@@ -81,6 +82,9 @@ pub use self::chunked::{ByteRange, ChunkedConfig, RangeSupport};
|
||||
pub use self::config::EngineConfig;
|
||||
pub use self::engine::DownloadEngine;
|
||||
pub use self::error::{DownloadError, DownloadErrorKind};
|
||||
pub use self::font::{
|
||||
cache_path, fetch_blocking, FontFetch, FontFetchError, FontSpec, MAX_FONT_BYTES,
|
||||
};
|
||||
pub use self::health::{HealthKey, HealthRegistry, SourceHealth};
|
||||
pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
|
||||
pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule};
|
||||
|
||||
@@ -33,12 +33,12 @@ pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"];
|
||||
pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
|
||||
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
|
||||
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
|
||||
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
|
||||
/// 与 `game::java::vendor::VENDOR_IDS` 必须同一份集合(setting 层不依赖 game 层),
|
||||
/// 一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
|
||||
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。与 `game::java::vendor::VENDOR_IDS`
|
||||
/// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。
|
||||
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
|
||||
|
||||
/// 自更新两键(M161;拆到自己的文件守 400 行)。
|
||||
/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)。主表各留一行登记。
|
||||
pub mod font;
|
||||
pub mod update;
|
||||
|
||||
/// launcher.json 当前 schema 版本。
|
||||
@@ -54,17 +54,17 @@ pub mod update;
|
||||
/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。
|
||||
/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移
|
||||
/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。
|
||||
/// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。
|
||||
///
|
||||
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
|
||||
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
|
||||
/// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。
|
||||
/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、
|
||||
/// `save` 跳过),不是「只是少一档」。这是 minor 提升的既定代价——也正是这条键必须推 minor
|
||||
/// 而不是偷偷塞进 1.4.0 的原因:回退到旧版本会出现「设置改不动」,而原因只在
|
||||
/// `$schema` 那一行里,不看版本号无从排查。
|
||||
/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、`save` 跳过),
|
||||
/// 不是「只是少一档」。这是 minor 提升的既定代价——也正是新键必须推 minor 而不是偷偷塞进旧版本的
|
||||
/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。
|
||||
#[must_use]
|
||||
pub fn launcher_version() -> SchemaVersion {
|
||||
SchemaVersion::new(1, 6, 0)
|
||||
SchemaVersion::new(1, 7, 0)
|
||||
}
|
||||
|
||||
/// `config/launcher.json` 的 schema。
|
||||
@@ -396,4 +396,5 @@ pub fn launcher_schema() -> SettingSchema {
|
||||
"启动器日志级别",
|
||||
))
|
||||
.with_all(update::specs())
|
||||
.with(font::cdn_spec())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 中文字体下载地址的覆盖键(M168:D28 后半「下载开源中文字体」)。
|
||||
//!
|
||||
//! 从 `keys.rs` 拆出(那边贴着 400 行硬线):主表只留一行 `.with(font::cdn_spec())`。
|
||||
//!
|
||||
//! ## 为什么需要这个键
|
||||
//!
|
||||
//! 内置 pin(`unlucky_i18n::font::NOTO_SANS_SC`)把 URL 钉到了 jsDelivr 的一个 git commit 上
|
||||
//! ——那是**一个**出口。用户所在网络如果连不上它(或想走自建镜像 / 代理),要有办法换,否则
|
||||
//! 「自动下载中文字体」在某些网络上等于不存在。
|
||||
//!
|
||||
//! ## 为什么只换 URL,不换哈希
|
||||
//!
|
||||
//! 字体是**注册进渲染器的可执行内容**,不是一张图。这个键只允许改「从哪拿」,`sha256` 与字节数
|
||||
//! 仍取内置那一份:镜像给了别的内容 ⇒ 校验当场拒 ⇒ 降级回系统字体,并在设置页那一行说「镜像
|
||||
//! 内容校验不通过」。**宁可不下载,也不把没校验过的东西注册成字体。**
|
||||
//!
|
||||
//! 想让用户能换「整套字体」(换族、换子集)就得连哈希一起进配置——那是另一件事:现在的 pin 是
|
||||
//! 代码里的常量,改它要发版。理由是此刻只有一套官方子集,多条可写路径只会多一个「校验值和字体
|
||||
//! 对不上」的支持面。
|
||||
|
||||
use crate::setting::spec::{AiPolicy, SettingSpec};
|
||||
|
||||
/// `ui.font_cdn` 的登记项(走 [`SettingSpec::text`]:空串 = 用内置镜像)。
|
||||
#[must_use]
|
||||
pub fn cdn_spec() -> SettingSpec {
|
||||
SettingSpec::text(
|
||||
"ui.font_cdn",
|
||||
"",
|
||||
512,
|
||||
AiPolicy::audited(),
|
||||
"中文字体下载地址(空 = 内置国内可达镜像;填整条 https URL,内容必须与内置 sha256 一致)",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// 默认值是空串(= 用内置镜像),且不是 `Path` 类型——空串对 `Path` 会被 schema 拒。
|
||||
///
|
||||
/// 抓得住:有人把键改成 `SettingSpec::path(...)`(空串过不了校验 ⇒ 出厂配置自己就非法)。
|
||||
#[test]
|
||||
fn the_cdn_key_defaults_to_the_builtin_mirror() {
|
||||
let spec = cdn_spec();
|
||||
assert_eq!(spec.key, "ui.font_cdn");
|
||||
assert_eq!(spec.default, json!(""));
|
||||
assert!(
|
||||
spec.kind.check(&json!("")).is_ok(),
|
||||
"空串(= 内置镜像)必须过校验"
|
||||
);
|
||||
assert!(
|
||||
spec.kind
|
||||
.check(&json!("https://mirror.example/NotoSansSC.ttf"))
|
||||
.is_ok(),
|
||||
"一条 https URL 必须过校验"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,7 @@ mod content;
|
||||
mod core_text;
|
||||
mod download;
|
||||
mod font;
|
||||
mod font_install;
|
||||
mod general;
|
||||
mod instances;
|
||||
mod launch;
|
||||
@@ -186,6 +187,7 @@ use content::content_entries;
|
||||
use core_text::core_text_entries;
|
||||
use download::{download_entries, version_entries};
|
||||
use font::font_entries;
|
||||
use font_install::font_install_entries;
|
||||
use general::general_entries;
|
||||
use instances::instances_entries;
|
||||
use launch::launch_entries;
|
||||
@@ -228,6 +230,9 @@ nav_entries! {
|
||||
account_entries,
|
||||
// M159:自更新(设置页「更新」分组 + 顶部提示条)。同样只追加在链尾之后。
|
||||
update_entries,
|
||||
// M168:中文字体下载状态行(设置页 General 组)。同样只追加在链尾之后——
|
||||
// `catalog/font.rs` 那四条老文案在链中间,往那里追加会平移其后所有 key(金表整片红)。
|
||||
font_install_entries,
|
||||
finish,
|
||||
{}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -209,4 +209,16 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
|
||||
("UpdateBannerRestartNow", "Restart now", "立即重启"),
|
||||
("UpdateBannerRetry", "Try again", "重试"),
|
||||
// M168:中文字体下载状态行(链尾追加,既有顺序不动)
|
||||
("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.", "只下这一次,缓存在数据目录里,下次启动不重下。"),
|
||||
];
|
||||
+94
-167
@@ -1,4 +1,4 @@
|
||||
//! 中文字体策略(D28 后半):**只做判据,不联网**。
|
||||
//! 中文字体策略(D28 后半):**只做判据与「该下载什么」,不联网**。
|
||||
//!
|
||||
//! ## 三句话
|
||||
//!
|
||||
@@ -6,22 +6,28 @@
|
||||
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
|
||||
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
|
||||
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
|
||||
//! 真正下载由注入的 [`Fetcher`] 实现,本 mission 不写网络实现(D28 的 CDN 下载是后续 mission)。
|
||||
//! 真正下载由注入的 [`Fetcher`] 实现(M168 起的真实现是
|
||||
//! `crates/app-iced/src/fonts/cdn.rs`,底层走 `unlucky_core::download::font`)。本 crate 保持
|
||||
//! **零依赖**:它只携带「下什么」([`Source::Pinned`]:URL 候选 + sha256 + 字节数),不碰网络。
|
||||
//!
|
||||
//! ## 扫描为什么在 UI 壳层而不是这里
|
||||
//! ## 谁在什么时刻问这里
|
||||
//!
|
||||
//! 真扫系统字体要 FontConfig / DirectWrite / iced 的 `font_system()`——任何一条都会把 iced 或平台
|
||||
//! SDK 拉进本 crate,破掉零依赖纪律。所以本模块只收一个 `installed: &dyn Fn(&str) -> bool`
|
||||
//! (iced 版实现在 `crates/app-iced/src/fonts/cjk.rs`),单测用假闭包,脱机可跑。
|
||||
//! | 调用点 | 时机 | 后果 |
|
||||
//! |---|---|---|
|
||||
//! | `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 做过,本 mission 不引依赖);
|
||||
//! ②下载下来的字节怎么注册进 iced 的字体系统——iced 0.14 有 `iced::font::load(bytes) -> Task`
|
||||
//! 可在运行时注册(已核对上游源码),但「什么时候调、失败怎么回退、和启动日志怎么对账」
|
||||
//! 属于下载 mission,本 crate 不碰。
|
||||
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 crate 不引依赖);
|
||||
//! ②下载下来的字节能不能在**这台机器**的渲染器上正常出字(注册与回退在 app-iced;macOS/Linux
|
||||
//! 没有真机验证,见 M168 报告)。
|
||||
|
||||
use crate::lang::Lang;
|
||||
|
||||
@@ -56,13 +62,26 @@ pub const CJK_FAMILIES: &[&str] = &[
|
||||
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
|
||||
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
|
||||
|
||||
/// 可下载字体的来源。本 mission **不 pin URL/哈希**——见 [`Source::PendingPin`]。
|
||||
/// 可下载字体的来源:**已钉死**的 URL 候选 + `sha256` + 字节数。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// 待下载 mission 钉死:确切 URL + `sha256` + 子集范围(简体常用字 + 标点),
|
||||
/// 并按 [`REQUIRED_WEIGHTS`] 覆盖 400/500/700。**故意不写一个随手想出来的 URL**:
|
||||
/// 猜错的 CDN 路径会被后面所有人当事实用。
|
||||
PendingPin,
|
||||
/// 钉死的一套下载参数(M168)。
|
||||
///
|
||||
/// 为什么是「常量 + 多重校验」而不是「让下载器自己挑」:CDN 上的字节会变(上游改字体、
|
||||
/// 镜像换了实现),而**字体是注册进渲染器的可执行内容**。所以 URL 钉到 **git commit**
|
||||
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
|
||||
/// `unlucky_core::download::font`)。
|
||||
///
|
||||
/// `urls` 是**候选**:首选挂了才试下一个(两个都是同一份字节的镜像,哈希只认一份——
|
||||
/// 镜像若给了别的内容,校验那关会当场拒掉,而不是把别的东西注册成字体)。
|
||||
Pinned {
|
||||
/// URL 候选(按顺序尝试)。
|
||||
urls: &'static [&'static str],
|
||||
/// 期望 `sha256`(小写十六进制,64 位)。
|
||||
sha256: &'static str,
|
||||
/// 期望字节数(进度预算 + 大小上限判据)。
|
||||
bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
|
||||
@@ -78,12 +97,37 @@ pub struct CjkFont {
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)。
|
||||
/// 缺字体时的默认方案: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(≈17.0 MiB) |
|
||||
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
|
||||
///
|
||||
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
|
||||
/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。
|
||||
///
|
||||
/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB,
|
||||
/// 而可变字体一份 17 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: &[300, 400, 500, 700, 900],
|
||||
source: Source::PendingPin,
|
||||
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
source: Source::Pinned {
|
||||
urls: &[
|
||||
// 首选:jsDelivr(国内多数线路可达;`@<commit>` 保证内容不变)
|
||||
"https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||||
// 备用:同一份内容的另一个 jsDelivr 边缘(首选被墙/被限速时才用)
|
||||
"https://fastly.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||||
],
|
||||
sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||
bytes: 17_772_300,
|
||||
},
|
||||
};
|
||||
|
||||
/// 字体策略结论。
|
||||
@@ -134,22 +178,50 @@ pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
|
||||
})
|
||||
}
|
||||
|
||||
/// 字体下载器接口(**注入式**;本 mission 没有网络实现)。
|
||||
/// 字体下载器接口(**注入式**: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`] 的结局。
|
||||
@@ -182,150 +254,5 @@ pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, Fetc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||
move |family| installed.contains(&family)
|
||||
}
|
||||
|
||||
/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。
|
||||
struct FakeFetcher {
|
||||
calls: Cell<usize>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
seen: Cell<Option<&'static str>>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
FakeFetcher {
|
||||
calls: Cell::new(0),
|
||||
result,
|
||||
seen: Cell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
self.seen.set(Some(font.family));
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_never_wants_a_cjk_font() {
|
||||
// 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。
|
||||
// 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。
|
||||
assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded);
|
||||
assert_eq!(
|
||||
decide(Lang::En, &only(&["Microsoft YaHei"])),
|
||||
Strategy::NotNeeded,
|
||||
"装了中文字体也不该进入中文分支"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_prefers_the_first_installed_candidate() {
|
||||
// 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。
|
||||
// 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。
|
||||
assert_eq!(
|
||||
decide(
|
||||
Lang::ZhCn,
|
||||
&only(&["Segoe UI", "Microsoft YaHei", "SimSun"])
|
||||
),
|
||||
Strategy::System {
|
||||
family: "Microsoft YaHei"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["PingFang SC"])),
|
||||
Strategy::System {
|
||||
family: "PingFang SC"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])),
|
||||
Strategy::System {
|
||||
family: "Noto Sans CJK SC"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_without_cjk_fonts_emits_the_download_signal() {
|
||||
// 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。
|
||||
// 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。
|
||||
let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"]));
|
||||
let Strategy::NeedsDownload(font) = strategy else {
|
||||
panic!("应当要求下载:{strategy:?}");
|
||||
};
|
||||
assert_eq!(font.family, "Noto Sans SC");
|
||||
assert_eq!(font.license, "OFL-1.1");
|
||||
assert_eq!(font.source, Source::PendingPin, "URL/哈希留给下载 mission");
|
||||
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_fonts_cover_the_required_weights() {
|
||||
// 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。
|
||||
// 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。
|
||||
for weight in REQUIRED_WEIGHTS {
|
||||
assert!(
|
||||
NOTO_SANS_SC.weights.contains(weight),
|
||||
"缺 {weight} 档:{:?}",
|
||||
NOTO_SANS_SC.weights
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_only_touches_the_fetcher_when_download_is_needed() {
|
||||
// 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求);
|
||||
// 也抓得住下载失败被吞掉(错误必须原样透出)。
|
||||
// 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo));
|
||||
assert_eq!(
|
||||
ensure(Strategy::System { family: "SimSun" }, &idle),
|
||||
Ok(Fetched::AlreadySystem { family: "SimSun" })
|
||||
);
|
||||
assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown));
|
||||
assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网");
|
||||
|
||||
let ok = FakeFetcher::new(Ok(vec![9; 4]));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok),
|
||||
Ok(Fetched::Downloaded(vec![9; 4]))
|
||||
);
|
||||
assert_eq!(ok.calls.get(), 1);
|
||||
assert_eq!(ok.seen.get(), Some("Noto Sans SC"));
|
||||
|
||||
let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned())));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken),
|
||||
Err(FetchError::Network("超时".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_describe_is_one_line_for_every_case() {
|
||||
// 抓得住:日志片段里混进换行(启动日志是按行解析的)。
|
||||
// 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。
|
||||
let cases = [
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
Strategy::Unprobeable,
|
||||
];
|
||||
for case in cases {
|
||||
let line = case.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk="), "{line}");
|
||||
}
|
||||
assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed");
|
||||
assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable");
|
||||
}
|
||||
}
|
||||
#[path = "font/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `i18n::font` 的单测(拆出来只为守 400 行硬线)。
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||
move |family| installed.contains(&family)
|
||||
}
|
||||
|
||||
/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。
|
||||
struct FakeFetcher {
|
||||
calls: Cell<usize>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
seen: Cell<Option<&'static str>>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
FakeFetcher {
|
||||
calls: Cell::new(0),
|
||||
result,
|
||||
seen: Cell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
self.seen.set(Some(font.family));
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_never_wants_a_cjk_font() {
|
||||
// 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。
|
||||
// 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。
|
||||
assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded);
|
||||
assert_eq!(
|
||||
decide(Lang::En, &only(&["Microsoft YaHei"])),
|
||||
Strategy::NotNeeded,
|
||||
"装了中文字体也不该进入中文分支"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_prefers_the_first_installed_candidate() {
|
||||
// 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。
|
||||
// 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。
|
||||
assert_eq!(
|
||||
decide(
|
||||
Lang::ZhCn,
|
||||
&only(&["Segoe UI", "Microsoft YaHei", "SimSun"])
|
||||
),
|
||||
Strategy::System {
|
||||
family: "Microsoft YaHei"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["PingFang SC"])),
|
||||
Strategy::System {
|
||||
family: "PingFang SC"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])),
|
||||
Strategy::System {
|
||||
family: "Noto Sans CJK SC"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_without_cjk_fonts_emits_the_download_signal() {
|
||||
// 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。
|
||||
// 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。
|
||||
let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"]));
|
||||
let Strategy::NeedsDownload(font) = strategy else {
|
||||
panic!("应当要求下载:{strategy:?}");
|
||||
};
|
||||
assert_eq!(font.family, "Noto Sans SC");
|
||||
assert_eq!(font.license, "OFL-1.1");
|
||||
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
|
||||
}
|
||||
|
||||
/// pin 的形态守卫(M168):URL 走 https、哈希是 64 位小写十六进制、字节数在大小上限内。
|
||||
///
|
||||
/// 抓得住:①有人把 URL 改成 `@main` 这类会漂移的引用(不是不行,但那样哈希必须跟着变,
|
||||
/// 这条断言至少逼他看见自己在动什么);②手抄哈希抄错一位(下一个人只会在真机上看到
|
||||
/// 「校验失败」然后降级);③把三个静态子集塞进来却没改字节数。
|
||||
/// 抓不住:CDN 上今天还发不发得出这份字节(要真联网,报告里写明未验)。
|
||||
#[test]
|
||||
fn the_pinned_source_is_well_formed() {
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = NOTO_SANS_SC.source;
|
||||
assert!(!urls.is_empty(), "至少要有一个 URL");
|
||||
for url in urls {
|
||||
assert!(url.starts_with("https://"), "字体必须走 https:{url}");
|
||||
assert!(
|
||||
!url.contains("@main"),
|
||||
"钉 commit 而不是会漂移的分支:{url}"
|
||||
);
|
||||
}
|
||||
assert_eq!(sha256.len(), 64, "{sha256}");
|
||||
assert!(
|
||||
sha256.bytes().all(|byte| byte.is_ascii_hexdigit()),
|
||||
"{sha256}"
|
||||
);
|
||||
assert_eq!(sha256, sha256.to_ascii_lowercase(), "哈希用小写:{sha256}");
|
||||
assert!(bytes > 0 && bytes < 32 * 1024 * 1024, "{bytes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_fonts_cover_the_required_weights() {
|
||||
// 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。
|
||||
// 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。
|
||||
for weight in REQUIRED_WEIGHTS {
|
||||
assert!(
|
||||
NOTO_SANS_SC.weights.contains(weight),
|
||||
"缺 {weight} 档:{:?}",
|
||||
NOTO_SANS_SC.weights
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_only_touches_the_fetcher_when_download_is_needed() {
|
||||
// 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求);
|
||||
// 也抓得住下载失败被吞掉(错误必须原样透出)。
|
||||
// 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo));
|
||||
assert_eq!(
|
||||
ensure(Strategy::System { family: "SimSun" }, &idle),
|
||||
Ok(Fetched::AlreadySystem { family: "SimSun" })
|
||||
);
|
||||
assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown));
|
||||
assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网");
|
||||
|
||||
let ok = FakeFetcher::new(Ok(vec![9; 4]));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok),
|
||||
Ok(Fetched::Downloaded(vec![9; 4]))
|
||||
);
|
||||
assert_eq!(ok.calls.get(), 1);
|
||||
assert_eq!(ok.seen.get(), Some("Noto Sans SC"));
|
||||
|
||||
let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned())));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken),
|
||||
Err(FetchError::Network("超时".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_describe_is_one_line_for_every_case() {
|
||||
// 抓得住:日志片段里混进换行(启动日志是按行解析的)。
|
||||
// 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。
|
||||
let cases = [
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
Strategy::Unprobeable,
|
||||
];
|
||||
for case in cases {
|
||||
let line = case.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk="), "{line}");
|
||||
}
|
||||
assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed");
|
||||
assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable");
|
||||
}
|
||||
Reference in new issue
Block a user