diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 807b59c..a835487 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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):面板 α / 背景图 / 配色来源 / 诊断。 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 7c0073e..3d9d837 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -86,6 +86,10 @@ impl App { // 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在 // `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。 let lang = LangState::boot(&bridge, options.screenshot.is_some()); + // M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`, + // 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体 + // (D30:同一份代码要出同一张图)。产品路径不设这个开关。 + crate::fonts::install::set_offline(options.screenshot.is_some()); let mut view = pages::Model::new( pages::Screen::Instances, width, @@ -114,6 +118,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, diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index da1c759..03cb0e5 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -42,6 +42,13 @@ impl App { if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting { self.view_dirty = true; } + // 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行 + // 从「正在获取中文字体…(16.9 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)); diff --git a/crates/app-iced/src/fonts.rs b/crates/app-iced/src/fonts.rs index 8690f05..e1b123e 100644 --- a/crates/app-iced/src/fonts.rs +++ b/crates/app-iced/src/fonts.rs @@ -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}; @@ -173,16 +186,30 @@ fn register_termux_fonts() { } /// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。 +/// +/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是 +/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入, +/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。 pub fn resolve_with(setting: Option<&str>) -> Resolved { register_termux_fonts(); + resolve_with_faces(setting, system_faces().as_deref()) +} +/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。 +/// +/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。 +/// +/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`) +/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发, +/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。 +/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。 +/// +/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。 +pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved { let platform = Platform::current(); - let faces = system_faces(); let is_installed = |family: &str| -> bool { - faces - .as_ref() - .is_none_or(|faces: &Vec| faces.iter().any(|face| face.family == family)) + faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family)) }; let families = [ @@ -206,11 +233,11 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved { // 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」, // 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。 let weights = [Weight::Regular, Weight::Medium, Weight::Bold] - .map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref())); + .map(|weight| weight_report(platform, families[0].family, weight, faces)); // D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」, // 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。 - let cjk_report = cjk::probe_with(setting, faces.as_deref()); + let cjk_report = cjk::probe_with(setting, faces); Resolved { platform, @@ -220,20 +247,34 @@ 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 只负责把它打进日志、不让它静默。 +/// 那就是白块。 +/// +/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源 +/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。 +/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器 +/// 会白下一次 17 MiB。 +/// +/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经 +/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`** +/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳 +/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。 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 } /// 一条系统字面(族名 + 字重)。 +/// +/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一 +/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。 #[derive(Debug, Clone, PartialEq, Eq)] -struct Face { +pub struct Face { family: String, weight: u16, } diff --git a/crates/app-iced/src/fonts/cdn.rs b/crates/app-iced/src/fonts/cdn.rs new file mode 100644 index 0000000..5d022e8 --- /dev/null +++ b/crates/app-iced/src/fonts/cdn.rs @@ -0,0 +1,173 @@ +//! 真实字体下载器(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(交给 install 注册进 iced 的字体系统) +//! ``` +//! +//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。 +//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的 +//! 单测)。 +//! +//! ## 「不联网」的三条路 +//! +//! | 情形 | 谁拦住 | +//! |---|---| +//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] | +//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) | +//! | 二次启动 | 同上:缓存文件按 `` 命名,命中即不联网 | +//! +//! ## 设置改写只换 URL,不换哈希 +//! +//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容 +//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。 + +use std::cell::Cell; +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, + /// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。 + /// + /// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏, + /// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。 + /// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。 + last_from_cache: Cell>, +} + +impl CdnFetcher { + /// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。 + /// + /// # Errors + /// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。 + pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result { + Ok(Self { + cache_root, + spec: spec_of(font)?, + last_from_cache: Cell::new(None), + }) + } + + /// 缓存文件是不是已经在盘上(只 stat,不做校验)。 + /// + /// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据: + /// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」 + /// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。 + #[must_use] + pub fn has_cached(&self) -> bool { + core_font::cache_path(&self.cache_root, &self.spec).is_file() + } + + /// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。 + /// + /// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。 + #[must_use] + pub fn was_cached(&self) -> Option { + self.last_from_cache.get() + } + + /// 缓存文件落点(诊断日志用)。 + #[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, FetchError> { + let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate); + self.last_from_cache + .set(outcome.as_ref().ok().map(|fetch| fetch.from_cache)); + outcome.map(|fetch| fetch.bytes) + } +} + +/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。 +/// +/// # Errors +/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。 +pub fn spec_of(font: &CjkFont) -> Result { + let Source::Pinned { + urls, + sha256, + bytes, + } = font.source; + + let mut candidates: Vec = 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_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道"); + assert!(fetcher + .cache_file() + .ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf")); + } +} diff --git a/crates/app-iced/src/fonts/cjk.rs b/crates/app-iced/src/fonts/cjk.rs index 346f6f4..9d5be2a 100644 --- a/crates/app-iced/src/fonts/cjk.rs +++ b/crates/app-iced/src/fonts/cjk.rs @@ -19,8 +19,9 @@ //! //! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测); //! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser` -//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`); -//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。 +//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过); +//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] / +//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。 //! //! ## `ui.language` 的接线(M106) //! diff --git a/crates/app-iced/src/fonts/install.rs b/crates/app-iced/src/fonts/install.rs new file mode 100644 index 0000000..afe9f87 --- /dev/null +++ b/crates/app-iced/src/fonts/install.rs @@ -0,0 +1,361 @@ +//! 中文字体下载的**执行与状态**(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`)。 +//! +//! ## 为什么下载在后台线程而不是启动那一拍 +//! +//! 一份 16.9 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 里的期望值;用来写「16.9 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 { + static STATE: OnceLock> = 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 { + static FORCED: OnceLock> = 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 { + 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) -> Result, +) -> 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` 不含任何判据)。 +/// +/// | 当前状态 | 策略 | 这一步 | +/// |---|---|---| +/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) | +/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) | +/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) | +/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) | +/// +/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②) +/// +/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」 +/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**: +/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在 +/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是 +/// 常见情形。 +/// +/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底) +/// +/// | 档 | panic 语义 | 线程 panic 之后 | +/// |---|---|---| +/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) | +/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 | +/// +/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`), +/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的 +/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。 +/// +/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外 +/// 产生网络流量并可能在出图过程中注册新字体。 +#[must_use] +pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step { + if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) { + return Step::Skip; + } + 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(_) if offline => Step::Skip, + Strategy::NeedsDownload(font) => Step::Start(font), + } +} + +/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。 +#[must_use] +pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step { + plan_with_offline(strategy, current, offline()) +} + +/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。 +/// +/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB); +/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络); +/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网; +/// * 出图通道([`set_offline`])→ 永不发起。 +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() + } + } +} + +/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。 +/// +/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。 +/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体—— +/// 这既产生流量,也让「同一份代码出同一张图」不再成立。 +pub fn set_offline(offline: bool) { + OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed); +} + +/// 当前是否处于 [`set_offline`] 的状态。 +#[must_use] +pub fn offline() -> bool { + OFFLINE.load(std::sync::atomic::Ordering::Relaxed) +} + +/// 进程级联网开关(默认允许)。 +static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。 +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; diff --git a/crates/app-iced/src/fonts/install/tests.rs b/crates/app-iced/src/fonts/install/tests.rs new file mode 100644 index 0000000..8d1267f --- /dev/null +++ b/crates/app-iced/src/fonts/install/tests.rs @@ -0,0 +1,369 @@ +//! `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, + seen: RefCell>, + result: Result, FetchError>, +} + +impl FakeFetcher { + fn new(result: Result, 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, FetchError> { + *self.calls.borrow_mut() += 1; + *self.seen.borrow_mut() = Some(font.family); + self.result.clone() + } +} + +/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。 +type Registered = std::rc::Rc>>; + +/// 假注册器:记收到的字节数,返回一个族名。 +fn recorder() -> (Registered, impl Fn(Vec) -> Result) { + let seen = std::rc::Rc::new(RefCell::new(Vec::new())); + let recorder = std::rc::Rc::clone(&seen); + let register = move |bytes: Vec| { + 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, + .. + } + )); +} + +/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。 +/// +/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把 +/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。 +/// +/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在 +/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次 +/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体** +/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。 +/// +/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。 +/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。 +#[test] +fn a_started_or_finished_install_is_not_restarted() { + let downloading = CjkInstall::Downloading { bytes: 17_772_300 }; + let ready = CjkInstall::Ready { + family: "Noto Sans SC".to_owned(), + from_cache: true, + }; + + for current in [downloading, ready] { + assert_eq!( + plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, false), + Step::Skip, + "{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做" + ); + } + + // 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。 + for current in [ + CjkInstall::Unknown, + CjkInstall::NotNeeded, + CjkInstall::Failed { + class: FetchClass::Network, + message: "超时".to_owned(), + }, + ] { + assert_eq!( + plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, false), + Step::Start(&NOTO_SANS_SC), + "{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)" + ); + } +} + +/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。 +/// +/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的 +/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且 +/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。 +/// +/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷 +/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。 +/// 抓不住:真线程行为(`after_probe` 的执行部分)。 +#[test] +fn the_idle_strategies_only_apply_state() { + assert_eq!( + plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false), + Step::Apply(CjkInstall::NotNeeded) + ); + assert_eq!( + plan_with_offline( + Strategy::System { family: "SimSun" }, + &CjkInstall::Unknown, + false + ), + Step::Apply(CjkInstall::System { + family: "SimSun".to_owned() + }) + ); + // 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。 + // 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对 + // 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又 + // 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档): + // unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出, + // 不存在停在 Downloading 的状态。 + assert_eq!( + plan_with_offline( + Strategy::Unprobeable, + &CjkInstall::Downloading { bytes: 1 }, + false + ), + Step::Skip, + "在飞的下载不许被别的策略改写状态" + ); +} + +/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。 +/// +/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)—— +/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。 +/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。 +#[test] +fn every_strategy_skips_while_a_download_is_in_flight() { + let downloading = CjkInstall::Downloading { bytes: 17_772_300 }; + for strategy in [ + Strategy::NeedsDownload(&NOTO_SANS_SC), + Strategy::NotNeeded, + Strategy::System { family: "SimSun" }, + Strategy::Unprobeable, + ] { + assert_eq!( + plan_with_offline(strategy, &downloading, false), + Step::Skip, + "{strategy:?}:下载中不许改状态、不许起第二条线程" + ); + } +} + +/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。 +/// +/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示 +/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。 +/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。 +#[test] +fn the_screenshot_channel_never_starts_a_download() { + assert_eq!( + plan_with_offline( + Strategy::NeedsDownload(&NOTO_SANS_SC), + &CjkInstall::Unknown, + true + ), + Step::Skip, + "出图通道不许联网" + ); + assert_eq!( + plan_with_offline( + Strategy::NeedsDownload(&NOTO_SANS_SC), + &CjkInstall::Unknown, + false + ), + Step::Start(&NOTO_SANS_SC), + "正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)" + ); + // 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。 + for strategy in [ + Strategy::NotNeeded, + Strategy::System { family: "SimSun" }, + Strategy::Unprobeable, + ] { + assert_eq!( + plan_with_offline(strategy, &CjkInstall::Unknown, false), + plan_with_offline(strategy, &CjkInstall::Unknown, true), + "{strategy:?}:不联网的策略与通道无关" + ); + } +} + +/// 截图钉子(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}"); + } +} diff --git a/crates/app-iced/src/fonts/install/worker.rs b/crates/app-iced/src/fonts/install/worker.rs new file mode 100644 index 0000000..299008f --- /dev/null +++ b/crates/app-iced/src/fonts/install/worker.rs @@ -0,0 +1,164 @@ +//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。 +//! +//! ## 为什么拆出来 +//! +//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件: +//! +//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽; +//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。 +//! +//! ## 为什么下载在后台线程 +//! +//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。 +//! 在 UI 线程同步下完 = 窗口几十秒不响应。 +//! +//! ## 线程里做了什么(顺序即判据) +//! +//! ```text +//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限) +//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher +//! → 成功:注册进 iced,状态 Ready{family, from_cache} +//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用) +//! ``` +//! +//! ## panic 这件事分两档说(复审 r3 P2-1) +//! +//! | 档 | panic 语义 | 线程 panic 之后 | +//! |---|---|---| +//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 | +//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` | +//! +//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径 +//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`, +//! 那属于发布 profile 的决定,不在本 mission)。 +//! +//! ## 诚实边界 +//! +//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed); +//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了 +//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为 +//! (那要真跑一个 `--release` 的 panic 用例)。 + +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` 负责那道闸)。 +/// +/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是 +/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。 +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 cache_file = fetcher.cache_file(); + update(CjkInstall::Downloading { bytes }); + eprintln!( + "[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}", + font.family, + cache_file.display(), + // 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下, + // 真正的命中结论在 `fetcher.was_cached()`(取完之后)。 + fetcher.has_cached() + ); + + let spawned = std::thread::Builder::new() + .name("ull-cjk-font".to_owned()) + .spawn(move || run(font, fetcher)); + + if let Err(error) = spawned { + update(CjkInstall::Failed { + class: FetchClass::Disk, + message: format!("起不了字体下载线程({error})"), + }); + } +} + +/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。 +fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) { + let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ensure_for( + Strategy::NeedsDownload(font), + &fetcher, + ®ister_into_font_system, + ) + })) { + Ok(state) => state, + Err(_) => CjkInstall::Failed { + class: FetchClass::Disk, + message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(), + }, + }; + + // `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎—— + // 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。 + let from_cache = fetcher.was_cached().unwrap_or(false); + let state = match state { + CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache }, + 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) -> Result { + 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 = 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)) +} diff --git a/crates/app-iced/src/fonts/tests.rs b/crates/app-iced/src/fonts/tests.rs index 5616c58..d516123 100644 --- a/crates/app-iced/src/fonts/tests.rs +++ b/crates/app-iced/src/fonts/tests.rs @@ -173,3 +173,42 @@ fn type_roles_pick_the_display_stack_for_big_text() { ); assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text); } + +/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。 +/// +/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有 +/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。 +/// +/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法) +/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。 +/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。 +/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。 +#[test] +fn resolving_fonts_never_starts_a_download() { + use unlucky_i18n::font::Strategy; + + use crate::fonts::install; + + // 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。 + let latin: Vec = ["Segoe UI", "Consolas"] + .iter() + .map(|family| Face { + family: (*family).to_owned(), + weight: 400, + }) + .collect(); + + let before = install::status(); + let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin)); + let after = install::status(); + + assert_eq!( + resolved.cjk.strategy, + Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC), + "这张脸表必须判出「需要下载」,否则下面的断言证明不了什么" + ); + assert!( + !after.is_downloading(), + "求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})" + ); +} diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 9ece9c3..7b68593 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -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) { + 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 772 300 B → `16.9 MB`,按 1 MiB = 1048576 B)。只用于「≈」级别的量。 +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(); diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 8825de1..5a49fef 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -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 772 300 B → 16.9 MiB),不是假百分比。 + 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() { diff --git a/crates/app-iced/src/platform/data.rs b/crates/app-iced/src/platform/data.rs new file mode 100644 index 0000000..695db41 --- /dev/null +++ b/crates/app-iced/src/platform/data.rs @@ -0,0 +1,111 @@ +//! 数据根派生:**字体缓存放哪** + **一条设置键读什么**(M168)。 +//! +//! ## 为什么需要它 +//! +//! 字体下载要把 17 MiB 落在数据根里(`/cache/fonts/`),而「数据根在哪」这件事 +//! 已经有一个真源:`unlucky_presenter::settings_bridge::SettingsBridge::open()`(三模式数据根 + +//! `core.*` 覆盖键 + 便携回落,见那边的 `resolve_config_path`)。本模块**不重写**那套解析, +//! 只做两件它没暴露的事: +//! +//! 1. 由配置文件路径 `/config/launcher.json` 反推 ``,再用 [`LauncherLayout::cache`] +//! 取缓存分区(分区名只从 core 的 `layout` 常量来,不手拼字符串); +//! 2. 读一条**设置键**的生效值(`ui.font_cdn`):`SettingsBridge` 只暴露了 `ui.language` 的读写, +//! 而 `ui.font_cdn` 是 M168 新增的键。 +//! +//! ## 诚实边界 +//! +//! - **抓得住**:路径推导的形状(`/config/x` → `/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 { + static LAYOUT: std::sync::OnceLock> = std::sync::OnceLock::new(); + LAYOUT + .get_or_init(|| { + let config = SettingsBridge::open().path()?.to_path_buf(); + // `/config/launcher.json` → `` + let home = config.parent()?.parent()?.to_path_buf(); + Some(LauncherLayout::new(home)) + }) + .clone() +} + +/// 字体缓存的根(`/fonts/` 的父目录;**不**等于字体目录本身)。 +/// +/// 用 `cache/` 而不是 `data/`:字体随时可删、删了会自动重下(D28 的「数据根」语义里, +/// 这就是「可重建」的那一类)。 +pub fn cache_root() -> Option { + layout().map(|layout| layout.cache()) +} + +/// 读一条设置键的**生效值**(schema 默认值也算;空串会被 `trim` 掉但仍是 `Some`)。 +/// +/// `None` = 配置读不到(数据根解析失败 / 文件损坏到 `SettingStore` 都装不起来)。 +pub fn setting_text(key: &str) -> Option { + 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 { + 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 里登记" + ); + } + + /// 布局推导的形状:`/config/launcher.json` → `/cache`。 + /// + /// 抓得住:有人把「上两级」写成「上一级」(缓存会落进 `config/` 里),或改用 `data/` 分区。 + /// 抓不住:真机上 `` 解析成什么(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") + ); + } +} diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index e4e1e3f..4435113 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -18,6 +18,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; use crate::Backend; +pub mod data; pub mod locale; pub mod win32; diff --git a/crates/app-iced/src/ui/lang.rs b/crates/app-iced/src/ui/lang.rs index 1ef28d5..db693a1 100644 --- a/crates/app-iced/src/ui/lang.rs +++ b/crates/app-iced/src/ui/lang.rs @@ -139,8 +139,12 @@ impl LangState { report.strategy, unlucky_i18n::font::Strategy::NeedsDownload(_) ) { + // M168:这里**不再**说「通道还没做」——下载在这一拍之后就已经发起(同一份报告交给 + // `fonts::install::after_probe`),结果与状态在 `[font] cjk-download …` 那几行里, + // 也在设置页 General 组的状态行上。上一版这句 stderr 在下载发起后说「还是 PendingPin」, + // 等于当面否定刚做的事(复审 r1 P2-3)。 eprintln!( - "[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)" + "[i18n] 缺中文字体:已按 D28 发起下载,进度与结果见 [font] cjk-download … 与设置页「中文字体」那一行;失败会降级用系统字体" ); } } diff --git a/crates/core/src/download/font.rs b/crates/core/src/download/font.rs new file mode 100644 index 0000000..1d3f731 --- /dev/null +++ b/crates/core/src/download/font.rs @@ -0,0 +1,352 @@ +//! OFL 中文字体的取回(D28 后半):**缓存命中 → 共享引擎下载 → 复验 → 原子落盘**。 +//! +//! ## 一段话 +//! +//! `unlucky_i18n::font` 只做判据与「该下载什么」(URL 候选 + sha256 + 字节数),真正把字节弄到 +//! 盘上的活在这里: +//! +//! ```text +//! <数据根>/fonts/cjk-noto-sans-sc-.ttf +//! 已有且哈希 + 魔数都对 ⇒ 直接返回(零网络) +//! 否则 ⇒ 共享引擎的 download_bytes(进内存) +//! ⇒ 自己再算一遍 sha256、再看一眼是不是 sfnt、再看大小上限 +//! ⇒ 全过才 write_atomic 落盘 ⇒ 返回字节 +//! ``` +//! +//! ## 为什么用「内存下载」而不是「边下边写文件」 +//! +//! 一张 17 MiB 的字体本来就要整份进内存(注册进字体系统要 `Vec`),而**先复验再落盘**让 +//! 「哈希不符」这条判据简单到不可能出错:盘上要么没有这个文件,要么它是校验过的。文件下载路径 +//! (`DownloadEngine::download`)会先写 `.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; +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,整个简体字符集)16.95 MiB;再大的「字体」基本 +/// 可以判断不是给 UI 用的字体。上限的用处不是省流量,是**挡住被换掉的 CDN 把几十上百 MB 的东西 +/// 写进数据根**。 +pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024; + +/// **非最后一个候选**的单次尝试时限(不是连接超时,也不是空闲超时)。 +/// +/// 为什么必须有:引擎的 `read_timeout` 是 30 s **空闲**超时,一条每秒只挤几 KB、但一直在动的 +/// 连接永远不会被判失败。复审实测过这条路径(同一台机器、同一份文件): +/// +/// | 路径 | 结果 | +/// |---|---| +/// | Fastly 的 IPv6 边缘 | 60 s 只到 4.0 MB(≈67 KB/s,17.7 MB 要 ≈4 分钟) | +/// | 同一域名的 IPv4 边缘 | 1.2 s 下完(≈15 MB/s) | +/// +/// 60 s 是「明显不对劲就换源」的位置:健康线路(实测 1.1~16.6 s)根本用不到它。 +/// +/// ## 为什么**最后一个**候选不吃这个时限(复审 r2 P2-3 / r3 P2-3) +/// +/// 每个候选都从 0 字节重下(不做续传),所以「单次上限」直接就是一条**吞吐门槛**: +/// `17 772 300 B / 预算`。历史两版都踩在这里:90 s ⇒ 门槛 197 KB/s;120 s + 总 360 s ⇒ +/// 第一个候选吃掉 120 s 后最后一个只剩 240 s ⇒ 门槛 **72.3 KiB/s**,而 r1 实测那条 IPv6 是 +/// 67~77 KiB/s——**67 KiB/s 需要 259 s > 240 s,仍然必失败**(复审 r3 就是这么把上一版的 +/// 「49 KB/s」拆穿的:那个数是「第一个候选瞬间失败」的情形,不是这条改动要救的场景)。 +/// +/// 现在的规则(见 [`fetch_blocking_with_limits`]):前 n-1 个候选各拿 +/// `min(ATTEMPT_DEADLINE, 剩余)`,**最后一个候选拿整个剩余总预算**。 +/// +/// ## 三个数的完整算术(2 个候选的常见情形) +/// +/// | 情形 | 最后一个候选拿到的预算 | 门槛 = 17 772 300 B / 预算 | +/// |---|---|---| +/// | 第一个候选瞬间失败 | 420 s | **42.3 KiB/s** | +/// | 第一个候选爬满 60 s 被切 | 360 s | **49.4 KiB/s** | +/// +/// 两者都低于 r1 实测最慢的 67 KiB/s(需要 259 s),于是「慢而当当能下完」的线路仍有**一次** +/// 老老实实下完的机会。**超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。 +pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(60); + +/// 整条取回(所有候选加起来)的总时限,也是**最后一个候选**能拿到的预算上限。 +/// +/// 取值依据见 [`ATTEMPT_DEADLINE`] 的算术表:420 s 让「第一个候选爬满 60 s」这种最差情形 +/// 仍给最后一个候选 360 s(49.4 KiB/s 门槛)。 +/// +/// 代价:最坏情形下用户要在设置页看着「正在获取中文字体…(16.9 MB)」等 7 分钟。这是刻意的 +/// 取舍——**慢而最终成功**远好过**快而必然失败**(后者要用户自己再来一次,结果一样)。 +pub const TOTAL_DEADLINE: Duration = Duration::from_secs(420); + +/// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。 +#[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 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), + } + } +} + +/// 一次取回的产物。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FontFetch { + /// 字体字节(交给调用方注册进字体系统)。 + pub bytes: Vec, + /// 是否零网络命中缓存。 + pub from_cache: bool, + /// 成功的源标识(`cache` / 引擎给的候选源名)。 + pub source: String, + /// 耗时。 + pub elapsed: Duration, +} + +/// 取一套字体(**阻塞**:内部走进程共享运行时)。 +/// +/// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。 +/// +/// # Errors +/// 见 [`FontFetchError`]。失败**不是**致命错误:调用方应当降级回系统字体 / 英文界面。 +pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result { + fetch_blocking_with_limits(cache_root, spec, ATTEMPT_DEADLINE, TOTAL_DEADLINE) +} + +/// 同 [`fetch_blocking`],但两个时限由调用方给(单测用它把 60 s / 420 s 压成几百毫秒)。 +/// +/// 为什么把时限做成参数而不是读环境变量:这是**行为**判据,不是诊断开关——测试要能证明 +/// 「一条只爬不死的连接会在时限处被打断并换源」,而 90 s 的用例没人会跑。 +/// +/// # Errors +/// 同 [`fetch_blocking`];超时归 [`FontFetchError::Network`](文案里带「超时」二字)。 +pub fn fetch_blocking_with_limits( + cache_root: &Path, + spec: &FontSpec, + attempt_deadline: Duration, + total_deadline: Duration, +) -> Result { + 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 = None; + let mut integrity_error: Option = None; + + for (index, url) in spec.urls.iter().enumerate() { + // 总时限先看一眼:已经用完了就不必再开一条注定超时的连接。 + let remaining = total_deadline.saturating_sub(started.elapsed()); + if remaining.is_zero() { + network_error = Some(FontFetchError::Network(format!( + "取字体超过总时限 {} s(已试 {index} / {} 个候选;这是「太慢」,不是校验失败)", + total_deadline.as_secs(), + spec.urls.len() + ))); + break; + } + // 前 n-1 个候选:`min(单次上限, 剩余)`;**最后一个候选**拿整个剩余总预算。 + // 为什么对最后一个放宽:每个候选都从 0 字节重下,「单次上限」就是一条吞吐门槛, + // 卡死最后一个等于把「慢但能下完」判成永久失败(复审 r2 P2-3 的算术,见 ATTEMPT_DEADLINE)。 + let is_last = index + 1 == spec.urls.len(); + let budget = if is_last { + remaining + } else { + attempt_deadline.min(remaining) + }; + let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path) + .with_sha256(&spec.sha256) + .with_size(spec.bytes); + + let attempt = block_on(async { + // `tokio::time::timeout` 必须在**运行时上下文里**构造(它一创建就要注册计时器; + // 在外面构造会 panic「there is no reactor running」)。所以整段包进 async 块。 + tokio::time::timeout(budget, engine.download_bytes(request)).await + }); + let outcome = match attempt { + Ok(outcome) => outcome, + Err(_elapsed) => { + network_error = Some(FontFetchError::Network(format!( + "下载字体超时(单次上限 {} ms,总上限 {} ms):{url}", + budget.as_millis(), + total_deadline.as_millis() + ))); + continue; + } + }; + + match outcome { + 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>, 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 +} + +mod spec; + +pub use spec::{cache_path, FontSpec}; + +#[cfg(test)] +#[path = "font/tests.rs"] +mod tests; diff --git a/crates/core/src/download/font/spec.rs b/crates/core/src/download/font/spec.rs new file mode 100644 index 0000000..8c3cba1 --- /dev/null +++ b/crates/core/src/download/font/spec.rs @@ -0,0 +1,106 @@ +//! 「下什么、放哪」:字体 pin 的**值**表示([`FontSpec`])与缓存落点([`cache_path`])。 +//! +//! 从 `font.rs` 拆出只为守 400 行硬线(M168 复审 r1 加了整体时限与 URL 口径之后越线)。 +//! 判据(哪些 URL 可用)、复验、落盘编排都在 [`super`] 那边;这里只有数据结构与路径规则。 + +use std::path::{Path, PathBuf}; + +use super::{FontFetchError, MAX_FONT_BYTES}; + +/// 一套字体的「在哪拿、拿到什么」:URL 候选(首选 + 备用镜像)+ 期望 sha256 + 期望字节数。 +/// +/// 与 `unlucky_i18n::font::Source::Pinned` 是同一份事实的两种表示:i18n 那份是**常量**(零依赖 +/// crate 里写死),本结构是**值**(可被设置改写、可在测试里注入假 URL)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FontSpec { + /// URL 候选,按顺序尝试(首选 CDN 挂了才试备用镜像)。 + pub urls: Vec, + /// 期望 sha256(小写十六进制、64 位)。 + pub sha256: String, + /// 期望字节数(进度预算 + 大小上限的判据)。 + pub bytes: u64, +} + +impl FontSpec { + /// 构造并做基本自洽检查(URL 非空 / 哈希形态 / 大小上限)。 + /// + /// # Errors + /// pin 本身不合法时返回 [`FontFetchError::Integrity`](**不发请求**)。 + pub fn new( + urls: impl IntoIterator>, + sha256: impl Into, + bytes: u64, + ) -> Result { + let spec = Self { + urls: urls.into_iter().map(Into::into).collect(), + sha256: sha256.into().trim().to_ascii_lowercase(), + bytes, + }; + spec.check()?; + Ok(spec) + } + + /// pin 是否自洽(`super::fetch_blocking*` 与构造器都调它,故 `pub(super)`)。 + pub(super) 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| !is_acceptable_url(url)) { + return Err(FontFetchError::Integrity(format!( + "字体 pin 只接受 https(http 仅限回环地址,给单测用):{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] + } +} + +/// URL 是否可用:**https 一律可以;http 只给回环地址**(单测的回环 HTTP 服务要用)。 +/// +/// 为什么这条要写进校验而不是只写进注释:字体是从网络下来、直接注册进渲染器的东西。明文 http +/// 在 sha256 钉死的前提下不会导致「内容被换」,但会把「从哪拿」暴露给路上的任何人(可被替换成 +/// 任意大文件来耗流量/磁盘,虽然会被大小上限与哈希挡住)。契约写成「非回环必须 https」, +/// 注释与实现就是一个口径。 +fn is_acceptable_url(url: &str) -> bool { + if url.starts_with("https://") { + return true; + } + let Some(rest) = url.strip_prefix("http://") else { + return false; + }; + let host_port = rest.split('/').next().unwrap_or_default(); + let host = host_port + .rsplit_once(':') + .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit())) + .map_or(host_port, |(host, _)| host); + let host = host.trim_start_matches('[').trim_end_matches(']'); + matches!(host, "127.0.0.1" | "localhost" | "::1") || host.starts_with("127.") +} + +/// 缓存文件落点:`<数据根>/fonts/cjk-noto-sans-sc-.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())) +} diff --git a/crates/core/src/download/font/tests.rs b/crates/core/src/download/font/tests.rs new file mode 100644 index 0000000..06d3123 --- /dev/null +++ b/crates/core/src/download/font/tests.rs @@ -0,0 +1,319 @@ +//! `download::font` 的守卫(三条都能变红,变异证明见 M168 报告)。 +//! +//! ## 离线纪律 +//! +//! 服务端是 `127.0.0.1` 上的回环监听器,把**内存里的假字体字节**当响应体发出去:不碰任何 +//! 外网站点,跑 `cargo test` 不需要网络。真 CDN 的字节有没有变、17 MiB 要下多久,这些**不在** +//! 本文件的能力范围内(报告里写明)。 + +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use super::*; + +#[path = "tests/support.rs"] +mod support; + +use support::{dead_url, fake_font, root, serve, serve_trickle, sha256, spec_for}; + +/// 守卫 ③:**缓存命中不联网**。 +/// +/// 抓得住:把 `fetch_blocking` 开头的缓存分支删掉——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 不符时拒绝落盘**(正式路径上什么都不许留下)。 +/// +/// **两层校验是互备的**(复审 r3 顺手项①:上一版这里写「只拆一层也会红」是假的,复审变异实测 +/// 单独拆掉引擎的 `with_sha256` 或单独短路本模块复验,本用例都**仍然绿**——另一层会接住)。 +/// 本用例真正抓的是**两层同时被拆**:那时坏字节会被当成字体返回、`cache_path` 也会落盘,断言在 +/// `expect_err` 处红(r1 的变异证明就是这么打的)。 +/// +/// 抓不住:单层的实现细节(比如 `verify::matches` 的大小写处理)——那要各自的单元用例。 +#[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"403 Forbidden".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(); +} + +/// 守卫(M168 复审 r2 P2-1/P2-3):**单次尝试的时限会打断爬行连接并转下一个候选**。 +/// +/// 这个场景现在是真的:第一个候选是 `serve_trickle()`(发真实 CRLF 响应头,之后每 100 ms 一个 +/// 字节),第二个候选是正常服务端。 +/// +/// 抓得住:①`serve_trickle` 的响应头写错(上一版就是这个病:真实换行字节 + 4 字节窗口 ⇒ 恒 false +/// ⇒ 一个字节都不发)——那样第一个候选会在**读不到响应头**时按网络错失败,用例里 +/// `trickle_served` 的「服务端真的发过头」这条断言会红;②`budget` 不是 `min(单次, 剩余)` 而是 +/// 直接用总预算 ⇒ 本用例会等到总预算(5 s)才换源,`elapsed < 3 s` 红;③时限被归成校验失败。 +/// 抓不住:默认值(120 s / 360 s)在真弱网下够不够(那要真 CDN,见报告「未验」)。 +#[test] +fn a_trickling_candidate_is_cut_by_the_attempt_deadline() { + let dir = root("deadline-attempt"); + let good = fake_font(b"served by the second candidate"); + let (good_url, _served) = serve(good.clone(), 4); + let (trickle_url, proof) = serve_trickle(); + let spec = FontSpec::new( + vec![trickle_url, good_url], + sha256(&good), + good.len() as u64, + ) + .expect("pin"); + + let started = std::time::Instant::now(); + let fetched = fetch_blocking_with_limits( + &dir, + &spec, + Duration::from_millis(300), + Duration::from_secs(5), + ) + .expect("第二个候选应当成功"); + assert_eq!(fetched.bytes, good); + assert!( + started.elapsed() < Duration::from_secs(3), + "单次上限该在 300 ms 处切断第一个候选,实际 {:?}", + started.elapsed() + ); + assert!(cache_path(&dir, &spec).exists()); + // **这条是「夹具没坏」的判据**(复审 r2 P2-1):第一个候选必须真的发过响应头、真的挤过字节。 + // 少了这两条,一个「什么都不发的服务端」也能让本用例绿——那证明的是「连接挂着被掐断」, + // 而不是我们想证明的「活着但在爬」。 + assert!( + proof.header.load(Ordering::SeqCst), + "爬行服务端必须真的发出响应头" + ); + assert!( + proof.dripped.load(Ordering::SeqCst) >= 1, + "爬行服务端必须真的挤出过字节(否则引擎的空闲超时那条路径根本没被造出来)" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// 守卫(M168 复审 r3 P2-3):**最后一个候选拿整个剩余预算**(`is_last` 那一支)。 +/// +/// 形状是刻意挑的:**单候选** + `attempt = 300 ms` / `total = 5 s`。 +/// * 有 `is_last`:最后一个(也是唯一一个)候选拿到剩下的 ~5 s ⇒ 只在**总时限**处被打断, +/// 耗时 ≈5 s; +/// * 没有 `is_last`(一律 `attempt.min(remaining)`):300 ms 就切,耗时 ≈0.3 s。 +/// +/// 两种实现的解不同,于是这条用例能分辨它们——上一版给的是 `attempt=30 s > total=400 ms`, +/// 两种实现同解,天生测不出差别(复审 r3 就是据此说 `is_last` 没人守)。 +/// +/// 抓得住:删掉 `is_last` 那一支(或把预算写成 `attempt_deadline.min(remaining)` 一视同仁)⇒ +/// `elapsed >= 3 s` 当场红;也抓得住服务端其实不发字节(`proof` 的两个断言)。 +/// 抓不住:真弱网下 60 s / 420 s 够不够(要真 CDN,见报告「未验」)。 +#[test] +fn the_last_candidate_gets_the_whole_remaining_budget() { + let dir = root("deadline-last"); + let body = fake_font(b"not what the server sends"); + let (trickle_url, proof) = serve_trickle(); + let spec = FontSpec::new(vec![trickle_url], sha256(&body), body.len() as u64).expect("pin"); + + let started = std::time::Instant::now(); + let error = fetch_blocking_with_limits( + &dir, + &spec, + Duration::from_millis(300), + Duration::from_secs(5), + ) + .expect_err("爬行连接最终必须被总时限打断"); + let elapsed = started.elapsed(); + + assert_eq!(error.class(), "network", "超时属于网络档:{error}"); + assert!( + error.message().contains("超时"), + "文案要说得清是超时:{error}" + ); + assert!( + elapsed >= Duration::from_secs(3), + "最后一个候选该拿整个剩余预算(≈5 s),而不是在 300 ms 处就被切:{elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(9), + "不该超过总时限太多:{elapsed:?}" + ); + assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘"); + assert!( + proof.dripped.load(Ordering::SeqCst) >= 1, + "这条用例必须掐在「有字节在动」的连接上,而不是一个不响的端口" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// pin 的 URL 口径:**https 一律可以,http 只给回环**(M168 复审次要项)。 +/// +/// 抓得住:把 `http://` 一律放行(注释说「必须 https」、实现说「随便」的老毛病), +/// 也抓得住收紧过头(连回环的单测服务都不让用 ⇒ 一批用例会红)。 +#[test] +fn the_pin_only_accepts_https_or_loopback_http() { + let hash = "a".repeat(64); + assert!(FontSpec::new(vec!["https://mirror.example/a.ttf"], &hash, 1).is_ok()); + assert!(FontSpec::new(vec!["http://127.0.0.1:8080/a.ttf"], &hash, 1).is_ok()); + assert!(FontSpec::new(vec!["http://localhost:9/a.ttf"], &hash, 1).is_ok()); + assert!( + FontSpec::new(vec!["http://127.0.0.2:9/a.ttf"], &hash, 1).is_ok(), + "127.0.0.0/8 整个都是回环" + ); + assert!( + FontSpec::new(vec!["http://example.com/a.ttf"], &hash, 1).is_err(), + "非回环的明文 http 不许过" + ); + assert!(FontSpec::new(vec!["ftp://example.com/a.ttf"], &hash, 1).is_err()); +} + +/// 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::::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"")); + 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))) + ); +} diff --git a/crates/core/src/download/font/tests/support.rs b/crates/core/src/download/font/tests/support.rs new file mode 100644 index 0000000..89f4eed --- /dev/null +++ b/crates/core/src/download/font/tests/support.rs @@ -0,0 +1,177 @@ +//! `download::font` 的测试夹具(回环 HTTP 服务端 / 假字体字节 / 临时数据根)。 +//! +//! 从 `tests.rs` 拆出只为守 400 行硬线(复审 r2 加了两条时限用例之后越线)。这里只有夹具, +//! 断言全在 `tests.rs`——两个回环服务端(`serve` 正常、`serve_trickle` 只爬)是「缓存/校验/时限」 +//! 三组守卫的地基,改坏它们等于把守卫变成空转(复审 r2 P2-1 就是这么发现上一版的 +//! CRLF 字面量写坏了的)。 + +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use super::super::super::verify; +use super::super::{FontSpec, HashAlgo}; + +/// 每个用例一个独立数据根(共享引擎的登记处是进程级的,键撞了会互相干扰)。 +pub(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 当正常内容, +/// 「下载成功」这条路径就永远走不到。 +pub(super) fn fake_font(payload: &[u8]) -> Vec { + 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 +} + +pub(super) fn sha256(bytes: &[u8]) -> String { + verify::digest_bytes(HashAlgo::Sha256, bytes) +} + +/// 回环 HTTP 服务:不管请求什么,都回同一份字节;记下处理了几条请求。 +/// +/// 只讲 HTTP/1.1 keep-alive,够用来判「第二次调用有没有再发请求」。 +pub(super) fn serve(body: Vec, expected_requests: usize) -> (String, Arc) { + 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) +} + +/// 一个**不会**有人监听的地址:证明「没联网」比证明「联网成功」更有力。 +pub(super) 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") +} + +/// 爬行服务端的取证计数器:`header` = 真的发过响应头,`dripped` = 真的挤出过几个字节。 +/// +/// 为什么把这两个数字**带出去**:这类 `serve_*` 夹具坏掉时,用例会静默变成另一种场景 +/// (复审 r2 P2-1 就是:请求头永远读不完 ⇒ 一个字节不发 ⇒ 用例证明的是「连接挂着」,而不是 +/// 「活着但在爬」)。用例拿到这两个数就能断言「服务端确实在说话」。 +pub(super) struct TrickleProof { + /// 响应头是否真的写出去了。 + pub header: Arc, + /// 写出去了几个正文字节。 + pub dripped: Arc, +} + +/// 由 URL 候选 + 期望字节算 pin(哈希取这批字节的 sha256)。 +pub(super) fn spec_for(urls: Vec, bytes: &[u8]) -> FontSpec { + FontSpec::new(urls, sha256(bytes), bytes.len() as u64).expect("pin 应自洽") +} + +/// 爬行服务端:回完头之后每 100 ms 只挤 1 字节,**永远不结束**。 +/// +/// 这就是复审 r1 P2-2 实测到的那条路径(Fastly 的 IPv6 边缘 ≈67 KB/s):引擎的 `read_timeout` +/// 是 30 s **空闲**超时,而字节一直在动 ⇒ 引擎自己永远不会判它失败。只有我们这一层的整体时限 +/// 能打断它——这条服务端存在的唯一目的就是造出「活着但爬」的连接。 +/// +/// 注意这里的 `\r\n` 必须是**转义写法**:上一版这个函数里写成了真实换行字节(`b"\n\n"`,2 字节) +/// 却拿 4 字节窗口去比,`PartialEq` 先比长度 ⇒ 恒 false ⇒ 请求头永远读不完、一个字节都不发, +/// 用例证明的其实是另一件事(复审 r2 P2-1)。`serve()` 那边的写法是对的,别动它。 +pub(super) fn serve_trickle() -> (String, TrickleProof) { + let header_sent = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let dripped = Arc::new(AtomicUsize::new(0)); + let (header_flag, drip_flag) = (Arc::clone(&header_sent), Arc::clone(&dripped)); + let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口"); + let addr = listener.local_addr().expect("取端口"); + std::thread::spawn(move || { + let Ok((mut socket, _)) = listener.accept() else { + return; + }; + let _ = socket.set_read_timeout(Some(Duration::from_secs(5))); + // 先把请求头读干净(不读完就写响应,某些栈会 RST)。 + let mut seen = Vec::new(); + loop { + let mut chunk = [0u8; 1024]; + match socket.read(&mut chunk) { + Ok(0) | Err(_) => return, + Ok(read) => seen.extend_from_slice(&chunk[..read]), + } + if seen.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + // 声明一个够大的长度,然后开始「爬」:每 100 ms 一个字节。 + if socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: font/ttf\r\nContent-Length: 1000000\r\n\r\n", + ) + .is_err() + { + return; + } + header_flag.store(true, Ordering::SeqCst); + for _ in 0..600 { + if socket.write_all(&[0u8]).is_err() { + return; + } + drip_flag.fetch_add(1, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(100)); + } + }); + ( + format!("http://{addr}/cjk.ttf"), + TrickleProof { + header: header_sent, + dripped, + }, + ) +} diff --git a/crates/core/src/download/mod.rs b/crates/core/src/download/mod.rs index 291bb46..fbccf5e 100644 --- a/crates/core/src/download/mod.rs +++ b/crates/core/src/download/mod.rs @@ -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}; diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 9f35a38..ada28a0 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -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()) } diff --git a/crates/core/src/setting/keys/font.rs b/crates/core/src/setting/keys/font.rs new file mode 100644 index 0000000..6f3897c --- /dev/null +++ b/crates/core/src/setting/keys/font.rs @@ -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 必须过校验" + ); + } +} diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index 993fbee..3eb5910 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() { fn schema_registration_shape_is_right() { assert_eq!( launcher_version().minor, - 6, - "1.6.0 登记 M161 的自更新两键(update.auto_check / update.channel)" + 7, + "1.7.0 登记 M168 的 ui.font_cdn(1.6.0 是 M161 的自更新两键)" ); let schema = launcher_schema(); assert_eq!( diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index 7f479de..60d98a9 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -128,10 +128,9 @@ fn overrides_key_is_an_enum_whitelist() { #[test] fn schema_versions_register_the_new_keys_without_migration() { - // 1.6.0 登记 M161 的自更新两键(`update.auto_check` / `update.channel`; - // 1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」, - // 1.2.0 = D23 的 JDK 落点/优先级键)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0)); + // 1.7.0 登记 M168 的 `ui.font_cdn`(1.6.0 登记 M161 的自更新两键,1.5.0 = M116 窗口圆角, + // 1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,1.2.0 = D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index 3088eba..0168f55 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -66,8 +66,10 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() { #[test] fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { - // 1.6.0 = M161 的自更新两键(此前 1.5.0 是 M116 窗口圆角)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0)); + // 1.7.0 = M168 的中文字体下载地址覆盖键(`ui.font_cdn`;此前 1.6.0 是 M161 的自更新两键, + // 1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,不是巧合: + // 老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); assert_eq!( launcher_version().major, 1, diff --git a/crates/core/tests/setting/update_keys.rs b/crates/core/tests/setting/update_keys.rs index 646d89b..02f98c8 100644 --- a/crates/core/tests/setting/update_keys.rs +++ b/crates/core/tests/setting/update_keys.rs @@ -6,8 +6,9 @@ use serde_json::json; use unlucky_core::setting::document::{RenderMode, SettingDocument}; -use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::keys::update::UPDATE_CHANNELS; +use unlucky_core::setting::keys::{launcher_schema, launcher_version}; +use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion}; use unlucky_core::setting::spec::{Sensitivity, ValueKind}; use unlucky_core::setting::store::SettingStore; use unlucky_core::setting::update_prefs::{ @@ -286,16 +287,32 @@ fn the_receipt_gate_skips_a_replayed_source_byte_for_byte() { /// **`Refused`:主配置不可写时不动旧文件**(唯一有用户数据风险的分支)。 /// -/// 造法:主配置的 `$schema` 写成**更高的 minor**(1.7.0 > 1.6.0)⇒ `SchemaCompat` 判 -/// `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。 +/// 造法:主配置的 `$schema` 写成**比当前更高的 minor**(`launcher_version().minor + 1`)⇒ +/// `SchemaCompat` 判 `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。 +/// +/// **这里刻意不写死数字**(M168 复审 p1-1):上一版把「更高」写成了字面量 `1.7.0`,于是 +/// `launcher_version()` 一推到 1.7.0 这个夹具就变成「同版本」——文件可写 ⇒ 迁移真的执行 ⇒ +/// 旧文件被退役,`Refused` 分支再也走不到,而**断言还在,绿着**:一条守护用户数据的用例 +/// 就这样静默失效了。相对推导(current + 1)不会过期,版本往后推多少次它都还是「更高一档」。 /// 抓得住:把「写不进去」当成「迁完了」(旧文件被改名,用户的值两边都没有了)。 #[test] fn a_read_only_launcher_config_never_retires_the_legacy_file() { + // 比当前更高一档的 minor:`SchemaRef::url()` 是 `$schema` 的唯一产地,不手拼字符串。 + let newer = SchemaRef { + id: SchemaId::LAUNCHER, + version: SchemaVersion::new( + launcher_version().major, + launcher_version().minor + 1, + launcher_version().patch, + ), + }; + let newer_schema = newer.url(); + let temp = TempDir::new("m161-refused"); temp.write( "config/launcher.json", &json!({ - "$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.7.0", + "$schema": newer_schema, "schemaVersion": 1 }) .to_string(), @@ -323,9 +340,9 @@ fn a_read_only_launcher_config_never_retires_the_legacy_file() { ); // 不写收据(没迁成就不该有收据)。 assert!(!temp.exists(&format!("config/{RECEIPT_FILE}"))); - // 主配置一个字都没改(还是那份 1.7.0)。 + // 主配置一个字都没改(还是那份「更高 minor」的 `$schema`)。 let text = temp.read("config/launcher.json"); - assert!(text.contains("1.7.0"), "{text}"); + assert!(text.contains(&newer_schema), "主配置被改了:{text}"); assert!(!text.contains("auto_check"), "只读文件不许被写:{text}"); } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index d6a5f44..ad363e6 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -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, {} } diff --git a/crates/i18n/src/catalog/font_install.rs b/crates/i18n/src/catalog/font_install.rs new file mode 100644 index 0000000..e4056e1 --- /dev/null +++ b/crates/i18n/src/catalog/font_install.rs @@ -0,0 +1,72 @@ +//! 中文字体**下载状态行**的文案(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 定死的口径) +//! +//! | 状态 | 文案说什么 | +//! |---|---| +//! | 下载中 | 「正在获取中文字体…(16.9 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})。界面照常可用,用的是系统字体;再切一次语言可以重试。" + ), + // 超时也归这一档(M168 复审 P2-2):用户要分清「太慢/没网」与「内容不对」, + // 但不需要知道我们内部把它叫 Timeout 还是 Network。 + FontCjkReasonNetwork => ( + "network unreachable or too slow (timed out)", + "网络不通或太慢(超时)" + ), + 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. A mirror can be set with `ui.font_cdn`.", + "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。" + ), + } } + }; +} + +pub(crate) use font_install_entries; diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index d22447e..8f2d27f 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -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 or too slow (timed out)", "网络不通或太慢(超时)"), + ("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. A mirror can be set with `ui.font_cdn`.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), ]; diff --git a/crates/i18n/src/font.rs b/crates/i18n/src/font.rs index fb04985..634238a 100644 --- a/crates/i18n/src/font.rs +++ b/crates/i18n/src/font.rs @@ -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,30 @@ 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` 是**候选**:首选挂了才试下一个。两个候选**必须是不同的失败域**(Cloudflare 侧 + + /// Fastly 侧,见 [`NOTO_SANS_SC`] 的实测表);同一家 CDN 的两个域名不算冗余。 + /// + /// 每次尝试与整条取回都有整体时限(`unlucky_core::download::font` 的 `ATTEMPT_DEADLINE` / + /// `TOTAL_DEADLINE`):慢而不死的连接(IPv6 爬行实测 67 KB/s)会在时限处被打断、转下一个 + /// 候选,而不是让用户在设置页干等四分钟。超时归「网络」档,界面文案是「网络不通或太慢」。 + Pinned { + /// URL 候选(按顺序尝试)。 + urls: &'static [&'static str], + /// 期望 `sha256`(小写十六进制,64 位)。 + sha256: &'static str, + /// 期望字节数(进度预算 + 大小上限判据)。 + bytes: u64, + }, } /// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。 @@ -74,16 +97,57 @@ pub struct CjkFont { pub license: &'static str, /// 这套字体可用的字重。 pub weights: &'static [u16], - /// 下载源(尚未钉死)。 + /// 下载源(URL 候选 + sha256 + 字节数,见 [`Source::Pinned`])。 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(16.95 MiB) | +/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` | +/// +/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下 +/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。 +/// +/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB, +/// 而可变字体一份 16.9 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 { + // 两个候选**必须是不同的失败域**(M168 复审 P2-1):`cdn.jsdelivr.net` 与 + // `fastly.jsdelivr.net` 都 CNAME 到 `jsdelivr.map.fastly.net`(同一批 Fastly 边缘), + // 「一个坏了换另一个」等于没有冗余——上一版就是这样,被复审当场打掉。 + // + // 实测(两份独立的国内网络取证,同一份 17 772 300 B 内容,sha256 都是 a3041811…): + // + // | 域名 | 解析 | 本机(本 worktree 开发机) | 复审机(无代理) | + // |---|---|---|---| + // | gcore.jsdelivr.net | Cloudflare 104.16.x | 13.3 s 完整 | 16.6 s 完整 | + // | testingcf.jsdelivr.net | Cloudflare 104.16.x | 11.8 s 完整 | 14.8 s 完整 | + // | cdn.jsdelivr.net | Fastly 151.101.x | 11.9 s 完整(另两次 5.7 s / 14.9 s)| 1.1 s,但**走 IPv6 时 3 次都掉到 ≈67 KB/s**(60 s 只到 4.0 MB)| + // | fastly.jsdelivr.net | 同上(Fastly)| — | 20 s 只到 2.5 MB(比它「要备的那条」还慢)| + // + // 排序依据(不是「听说更稳」):**首选放 Cloudflare 那一侧**——Fastly 侧的 IPv6 边缘 + // 在复审环境里会掉进 60~77 KB/s 的慢路径(那条路上 17.7 MB 要 ≈4 分钟),而 Cloudflare + // 侧两份取证都完整下完、且是独立的失败域。二选留 Fastly 侧:本机它反而最快(5.7~11.9 s), + // 保留它是为了「Cloudflare 一侧整体不可用时还有另一家 CDN」。 + // 先后只影响速度,不影响正确性:sha256 只认一份,镜像给了别的内容会被当场拒。 + urls: &[ + "https://gcore.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", + "https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", + ], + sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da", + bytes: 17_772_300, + }, }; /// 字体策略结论。 @@ -134,22 +198,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, 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 +274,5 @@ pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result impl Fn(&str) -> bool { - move |family| installed.contains(&family) - } - - /// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。 - struct FakeFetcher { - calls: Cell, - result: Result, FetchError>, - seen: Cell>, - } - - impl FakeFetcher { - fn new(result: Result, FetchError>) -> Self { - FakeFetcher { - calls: Cell::new(0), - result, - seen: Cell::new(None), - } - } - } - - impl Fetcher for FakeFetcher { - fn fetch(&self, font: &'static CjkFont) -> Result, 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; diff --git a/crates/i18n/src/font/tests.rs b/crates/i18n/src/font/tests.rs new file mode 100644 index 0000000..db52bfc --- /dev/null +++ b/crates/i18n/src/font/tests.rs @@ -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, + result: Result, FetchError>, + seen: Cell>, +} + +impl FakeFetcher { + fn new(result: Result, FetchError>) -> Self { + FakeFetcher { + calls: Cell::new(0), + result, + seen: Cell::new(None), + } + } +} + +impl Fetcher for FakeFetcher { + fn fetch(&self, font: &'static CjkFont) -> Result, 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"); +}