复审 r1(p1-1/fix-then-merge)五项全改:
1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`,
scope 漏掉了覆盖 schema 版本号的目录式 target):
- `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的
`ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。
- `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死
1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推
这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据
守卫静默失效)。相对推导不会过期。
2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net),
换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net`
(Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径),
并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。
3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` /
`TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试
`min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫
`a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。
顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到),
现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。
4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk`
那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成
「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。
5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` /
`Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉
`Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。
次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`,
`has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须
https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。
`download/font.rs` 拆出 `font/spec.rs` 守 400 行。
验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长):
- `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含
`setting` 那个 115 条、上一轮红 4 条的 integration target)
- `cargo test -p unlucky-i18n` → 31 passed / 0 failed
- `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed
- `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净
- `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400)
314 lines
12 KiB
Rust
314 lines
12 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(),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
|
||
///
|
||
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
|
||
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
|
||
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum Step {
|
||
/// 什么都不做(已经在下载 / 已就绪)。
|
||
Skip,
|
||
/// 只把状态改成这个(零网络)。
|
||
Apply(CjkInstall),
|
||
/// 真的发起一次下载。
|
||
Start(&'static font::CjkFont),
|
||
}
|
||
|
||
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
|
||
///
|
||
/// | 策略 | 当前状态 | 这一步 |
|
||
/// |---|---|---|
|
||
/// | `NotNeeded` / `System` / `Unprobeable` | 任意 | `Apply`(零网络) |
|
||
/// | `NeedsDownload` | 下载中 / 已就绪 | **`Skip`**(幂等:切一次语言不起第二条线程) |
|
||
/// | `NeedsDownload` | 未判定 / 失败过 / 其它 | `Start`(失败后的重试只能是用户再切一次语言) |
|
||
#[must_use]
|
||
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
|
||
match strategy {
|
||
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
|
||
Strategy::System { family } => Step::Apply(CjkInstall::System {
|
||
family: family.to_owned(),
|
||
}),
|
||
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
|
||
Strategy::NeedsDownload(font) => {
|
||
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
|
||
Step::Skip
|
||
} else {
|
||
Step::Start(font)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
|
||
///
|
||
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
|
||
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 17 MiB);
|
||
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网。
|
||
pub fn after_probe(strategy: Strategy) -> CjkInstall {
|
||
match plan(strategy, &status()) {
|
||
Step::Skip => status(),
|
||
Step::Apply(state) => {
|
||
update(state.clone());
|
||
state
|
||
}
|
||
Step::Start(font) => {
|
||
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;
|