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` 干净。截图三张见报告。
291 lines
11 KiB
Rust
291 lines
11 KiB
Rust
//! 中文字体下载的**执行与状态**(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;
|