From 875b79220614d5d9615fc22451f690c19b824b23 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:10:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(m168):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=BA=94=E9=A1=B9=20=E2=80=94=E2=80=94=20=E4=BF=AE=E7=BA=A2?= =?UTF-8?q?=E7=9A=84=20integration=20target=E3=80=81=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=9F=9F=E3=80=81=E6=95=B4=E4=BD=93=E6=97=B6?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1(p1-1/fix-then-merge)五项全改: 1. **p1-1 CI 会红的 4 条 integration test**(`crates/core/tests/setting/`,我上一轮只跑了 `--lib`, scope 漏掉了覆盖 schema 版本号的目录式 target): - `java_install_keys` / `isolation_keys` / `appearance_keys`:版本断言 1.6.0 → 1.7.0(M168 的 `ui.font_cdn`),并写明「每加一批键推一次 minor 是纪律」。 - `update_keys::a_read_only_launcher_config_never_retires_the_legacy_file`:夹具从**写死 1.7.0** 改成 `launcher_version().minor + 1` 经 `SchemaRef::url()` 生成。写死数字的版本一推 这条守卫就变成「同版本」⇒ 文件可写 ⇒ 迁移真的执行 ⇒ 旧文件被退役,而断言还绿着(用户数据 守卫静默失效)。相对推导不会过期。 2. **p2-1 备用镜像不备用**:`cdn` 与 `fastly` 同属 Fastly(都 CNAME 到 jsdelivr.map.fastly.net), 换掉。现在首选 `gcore.jsdelivr.net`(Cloudflare,独立失败域),二选 `cdn.jsdelivr.net` (Fastly)。注释里放了实测表(本机 + 复审机两份数据、字节数、sha256、IPv6 慢路径), 并写清排序依据(Fastly 侧 IPv6 实测 67 KB/s ⇒ 丢掉 4 分钟;Cloudflare 侧两次都完整下完)。 3. **p2-2 整体时限**:`core::download::font` 新增 `ATTEMPT_DEADLINE = 90 s` / `TOTAL_DEADLINE = 180 s`,落在本模块自己的候选循环里(不动引擎全体语义):每次尝试 `min(单次上限, 剩余总预算)`,超时归 `Network` 档。新增守卫 `a_trickling_connection_is_cut_by_the_deadline`(回环服务端每 100 ms 挤 1 字节、预算 300 ms)。 顺带修掉一个真 bug:`tokio::time::timeout` 在运行时外构造会 panic(5 条既有用例当场抓到), 现在整段包进 `block_on(async { … })`。UI 文案改成「网络不通或太慢(超时)」,不校验失败混。 4. **p2-3 说 `Source::PendingPin` 的两处**:`fonts/cjk.rs` 的模块文档已改;`ui/lang.rs::log_cjk` 那句「D28 的下载通道还是 PendingPin」(在下载已发起之后打印,等于当面否定本功能)改成 「已按 D28 发起下载,见 [font] cjk-download … 与设置页那一行」。全仓再扫,只剩注释里的历史说明。 5. **p2-4 假守卫**:判据抽成纯函数 `install::plan(strategy, current) -> Step`(`Skip` / `Apply` / `Start`),`after_probe` 只执行。旧的空转用例换成打真判据的打表用例;变异证明:删掉 `Skip` 那一臂 ⇒ `a_started_or_finished_install_is_not_restarted` 红(已实跑)。 次要项也一并处理:`from_cache` 改成**本次是否真的命中缓存**(`CdnFetcher::was_cached()`, `has_cached()` 降级为诊断字段 `cache_present=`);`FontSpec` 的 URL 口径收紧为「非回环必须 https」(`is_acceptable_url` + 守卫);`ui.font_cdn` 写进状态行 hint;文档里的 17.0 MB → 16.9 MB。 `download/font.rs` 拆出 `font/spec.rs` 守 400 行。 验证(这台机器上单个 800 MB 测试 exe 链接要 10 分钟,链接期间文件被 Defender 扫描,故耗时很长): - `cargo test -p unlucky-core --all-targets` → **1188 passed / 0 failed**(16 个 target,含 `setting` 那个 115 条、上一轮红 4 条的 integration target) - `cargo test -p unlucky-i18n` → 31 passed / 0 failed - `cargo test -p unlucky-app-iced --lib` → 685 passed / 0 failed - `cargo clippy -p unlucky-i18n -p unlucky-core -p unlucky-app-iced --all-targets -- -D warnings` → 干净 - `cargo fmt --all -- --check` → 干净;全仓受检文件行数最大 388(<400) --- crates/app-iced/src/fonts/cdn.rs | 31 +++- crates/app-iced/src/fonts/cjk.rs | 5 +- crates/app-iced/src/fonts/install.rs | 69 +++++--- crates/app-iced/src/fonts/install/tests.rs | 87 +++++++--- crates/app-iced/src/fonts/install/worker.rs | 18 +- crates/app-iced/src/pages/settings/general.rs | 2 +- crates/app-iced/src/ui/lang.rs | 6 +- crates/core/src/download/font.rs | 157 ++++++++---------- crates/core/src/download/font/spec.rs | 106 ++++++++++++ crates/core/src/download/font/tests.rs | 108 ++++++++++++ crates/core/tests/setting/appearance_keys.rs | 4 +- crates/core/tests/setting/isolation_keys.rs | 7 +- .../core/tests/setting/java_install_keys.rs | 6 +- crates/core/tests/setting/update_keys.rs | 29 +++- crates/i18n/src/catalog/font_install.rs | 11 +- crates/i18n/src/catalog/golden/tail2.rs | 4 +- crates/i18n/src/font.rs | 32 +++- 17 files changed, 512 insertions(+), 170 deletions(-) create mode 100644 crates/core/src/download/font/spec.rs diff --git a/crates/app-iced/src/fonts/cdn.rs b/crates/app-iced/src/fonts/cdn.rs index ce65d25..5d022e8 100644 --- a/crates/app-iced/src/fonts/cdn.rs +++ b/crates/app-iced/src/fonts/cdn.rs @@ -27,6 +27,7 @@ //! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容 //! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。 +use std::cell::Cell; use std::path::PathBuf; use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec}; @@ -39,6 +40,12 @@ use crate::platform::data; pub struct CdnFetcher { cache_root: PathBuf, spec: FontSpec, + /// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。 + /// + /// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏, + /// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。 + /// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。 + last_from_cache: Cell>, } impl CdnFetcher { @@ -50,18 +57,28 @@ impl CdnFetcher { Ok(Self { cache_root, spec: spec_of(font)?, + last_from_cache: Cell::new(None), }) } - /// 缓存是不是已经在盘上(只 stat,不做校验)。 + /// 缓存文件是不是已经在盘上(只 stat,不做校验)。 /// - /// 用途只有两个:UI 那行「已就绪」的字样、日志里 `from_cache=true` 的标注。**它不承担守卫**: - /// 真正的命中判定在 `core::download::font`(读出来 + 复验魔数后才算命中)。 + /// 用途只有**诊断**(`[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 { @@ -71,9 +88,10 @@ impl CdnFetcher { impl unlucky_i18n::font::Fetcher for CdnFetcher { fn fetch(&self, _font: &'static CjkFont) -> Result, FetchError> { - core_font::fetch_blocking(&self.cache_root, &self.spec) - .map(|fetch| fetch.bytes) - .map_err(|error| translate(error)) + 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) } } @@ -147,6 +165,7 @@ mod tests { 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 index 97eb6a3..7a6d1b5 100644 --- a/crates/app-iced/src/fonts/install.rs +++ b/crates/app-iced/src/fonts/install.rs @@ -233,36 +233,59 @@ pub fn ensure_for( } } -/// 判据结论 → 现在这一步的结局(生产入口)。 +/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。 /// -/// 只有 `NeedsDownload` 且**当前不在下载/已就绪**时才真的发起下载: +/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版 +/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何 +/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Step { + /// 什么都不做(已经在下载 / 已就绪)。 + Skip, + /// 只把状态改成这个(零网络)。 + Apply(CjkInstall), + /// 真的发起一次下载。 + Start(&'static font::CjkFont), +} + +/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。 /// -/// * 英文环境 / 系统已有 → 只更新状态(零网络); -/// * 探测不出 → 只更新状态(零网络,**不**当成缺字体); -/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载); -/// * 上次失败过 → **只有再次切语言才会重试**(用户显式动作),不会自己反复联网。 -pub fn after_probe(strategy: Strategy) -> CjkInstall { - let current = status(); +/// | 策略 | 当前状态 | 这一步 | +/// |---|---|---| +/// | `NotNeeded` / `System` / `Unprobeable` | 任意 | `Apply`(零网络) | +/// | `NeedsDownload` | 下载中 / 已就绪 | **`Skip`**(幂等:切一次语言不起第二条线程) | +/// | `NeedsDownload` | 未判定 / 失败过 / 其它 | `Start`(失败后的重试只能是用户再切一次语言) | +#[must_use] +pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step { match strategy { - Strategy::NotNeeded => { - update(CjkInstall::NotNeeded); - CjkInstall::NotNeeded + Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded), + Strategy::System { family } => Step::Apply(CjkInstall::System { + family: family.to_owned(), + }), + Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable), + Strategy::NeedsDownload(font) => { + if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) { + Step::Skip + } else { + Step::Start(font) + } } - Strategy::System { family } => { - let state = CjkInstall::System { - family: family.to_owned(), - }; + } +} + +/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。 +/// +/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络); +/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 17 MiB); +/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网。 +pub fn after_probe(strategy: Strategy) -> CjkInstall { + match plan(strategy, &status()) { + Step::Skip => status(), + Step::Apply(state) => { update(state.clone()); state } - Strategy::Unprobeable => { - update(CjkInstall::Unprobeable); - CjkInstall::Unprobeable - } - Strategy::NeedsDownload(font) => { - if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) { - return current; - } + Step::Start(font) => { worker::start(font); status() } diff --git a/crates/app-iced/src/fonts/install/tests.rs b/crates/app-iced/src/fonts/install/tests.rs index 4394457..340b1ee 100644 --- a/crates/app-iced/src/fonts/install/tests.rs +++ b/crates/app-iced/src/fonts/install/tests.rs @@ -38,11 +38,11 @@ impl Fetcher for FakeFetcher { } } +/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。 +type Registered = std::rc::Rc>>; + /// 假注册器:记收到的字节数,返回一个族名。 -fn recorder() -> ( - std::rc::Rc>>, - impl Fn(Vec) -> Result, -) { +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| { @@ -156,26 +156,73 @@ fn every_failure_class_survives_the_trip() { )); } -/// 状态机的**幂等**:已就绪 / 下载中时再问一次不会起第二条下载。 +/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。 /// -/// 抓得住:`after_probe` 里删掉 `is_downloading() || Ready` 那半(用户来回切语言会起 N 条线程, -/// 每条都下一遍 17 MiB)。本用例只测纯判据那一层——`after_probe` 的线程行为要真网络,没测。 +/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把 +/// `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() { - // 直接打状态机的输入输出:Ready 与 Downloading 都不会进 fetcher。 - let (_, register) = recorder(); - let idle = FakeFetcher::new(Ok(vec![1])); - for state in [ - CjkInstall::Ready { - family: "Noto Sans SC".to_owned(), - from_cache: true, - }, - CjkInstall::Downloading { bytes: 17_772_300 }, - ] { - assert!(!matches!(state, CjkInstall::Unknown), "{state:?}"); - assert_eq!(idle.calls(), 0); - let _ = ®ister; + 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(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t), + Step::Skip, + "{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做" + ); } + + // 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。 + for current in [ + CjkInstall::Unknown, + CjkInstall::NotNeeded, + CjkInstall::Failed { + class: FetchClass::Network, + message: "超时".to_owned(), + }, + ] { + assert_eq!( + plan(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t), + Step::Start(&NOTO_SANS_SC), + "{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)" + ); + } +} + +/// 三种「不需要下载」的策略在 [`plan`] 里只产生 `Apply`(**零网络**),且状态文案对得上。 +/// +/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷 +/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。 +/// 抓不住:真线程行为(`after_probe` 的执行部分)。 +#[test] +fn the_idle_strategies_only_apply_state() { + assert_eq!( + plan(Strategy::NotNeeded, &CjkInstall::Unknown), + Step::Apply(CjkInstall::NotNeeded) + ); + assert_eq!( + plan(Strategy::System { family: "SimSun" }, &CjkInstall::Unknown), + Step::Apply(CjkInstall::System { + family: "SimSun".to_owned() + }) + ); + assert_eq!( + plan(Strategy::Unprobeable, &CjkInstall::Downloading { bytes: 1 }), + Step::Apply(CjkInstall::Unprobeable), + "探测不出也要把状态改回来(否则界面会停在「正在下载」)" + ); } /// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。 diff --git a/crates/app-iced/src/fonts/install/worker.rs b/crates/app-iced/src/fonts/install/worker.rs index cc5f90a..d2d647b 100644 --- a/crates/app-iced/src/fonts/install/worker.rs +++ b/crates/app-iced/src/fonts/install/worker.rs @@ -55,12 +55,15 @@ pub(super) fn start(font: &'static font::CjkFont) { } }; - let cached = fetcher.has_cached(); + let cache_file = fetcher.cache_file(); update(CjkInstall::Downloading { bytes }); eprintln!( - "[font] cjk-download start family=\"{}\" bytes={bytes} from_cache={cached} file={}", + "[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}", font.family, - fetcher.cache_file().display() + cache_file.display(), + // 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下, + // 真正的命中结论在 `fetcher.was_cached()`(取完之后)。 + fetcher.has_cached() ); // 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。 @@ -70,12 +73,11 @@ pub(super) fn start(font: &'static font::CjkFont) { &fetcher, ®ister_into_font_system, ); + // `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎—— + // 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。 + let from_cache = fetcher.was_cached().unwrap_or(false); let state = match state { - // 命中缓存的那一份要把 `from_cache` 标出来(状态行靠它区分「等了一会儿」与「真的下了一次」)。 - CjkInstall::Ready { family, .. } => CjkInstall::Ready { - family, - from_cache: cached, - }, + CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache }, other => other, }; match &state { diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 3206d72..7b68593 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -134,7 +134,7 @@ pub(super) fn font_status( } } -/// 字节数 → 人话体积(`17.0 MB`)。只用于「≈」级别的量,不追求精确位数。 +/// 字节数 → 人话体积(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; 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 index 33f1b32..e38f00d 100644 --- a/crates/core/src/download/font.rs +++ b/crates/core/src/download/font.rs @@ -39,7 +39,7 @@ //! 健康度 / 候选源都复用)。本模块只额外提供「字体这个资源特有的三件事」:内容寻址的缓存文件名、 //! 复验、大小上限。 -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::{Duration, Instant}; use super::atomic::write_atomic; @@ -51,11 +51,31 @@ use super::verify; /// 一套字体允许的最大字节数(32 MiB)。 /// -/// 实测参考:Noto Sans SC 可变字体(wght 100–900,整个简体字符集)17.0 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~180 s 之间取 90 s:健康线路(实测 1.1~16.6 s)绰绰有余,而 75 KB/s 的爬行会在 90 s +/// 前被打断,转去下一个候选域。**超时归入网络档**(用户视角是「太慢」,不是「内容不对」)。 +pub const ATTEMPT_DEADLINE: Duration = Duration::from_secs(90); + +/// 整条取回(所有候选加起来)的总时限。 +/// +/// 候选是一个个试的,每个都有自己的 [`ATTEMPT_DEADLINE`];总时限是**上限的上限**,防止将来 +/// 候选列表变长时「N × 90 s」把设置页那一行卡成几分钟。当前两个独立域 ⇒ 实际最坏 3 分钟。 +pub const TOTAL_DEADLINE: Duration = Duration::from_secs(180); + /// 字体取回的失败分类(三层,见模块文档「失败分类为什么是三类」)。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum FontFetchError { @@ -110,76 +130,6 @@ impl From for FontFetchError { } } -/// 一套字体的「在哪拿、拿到什么」: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 是否自洽。 - fn check(&self) -> Result<(), FontFetchError> { - if self.urls.is_empty() { - return Err(FontFetchError::Integrity("字体 pin 没有 URL".to_owned())); - } - if let Some(bad) = self - .urls - .iter() - .find(|url| !(url.starts_with("https://") || url.starts_with("http://"))) - { - return Err(FontFetchError::Integrity(format!( - "字体 pin 只接受 http(s) URL:{bad}" - ))); - } - if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(FontFetchError::Integrity(format!( - "字体 pin 的 sha256 不是 64 位十六进制:{}", - self.sha256 - ))); - } - if self.bytes == 0 || self.bytes > MAX_FONT_BYTES { - return Err(FontFetchError::Integrity(format!( - "字体 pin 的字节数 {} 不在 (0, {MAX_FONT_BYTES}] 内", - self.bytes - ))); - } - Ok(()) - } - - /// 缓存文件名的哈希段(前 16 位:够区分,又不至于让路径长得没法看)。 - #[must_use] - fn cache_key(&self) -> &str { - let end = self.sha256.len().min(16); - &self.sha256[..end] - } -} - /// 一次取回的产物。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct FontFetch { @@ -193,16 +143,6 @@ pub struct FontFetch { pub elapsed: Duration, } -/// 缓存文件落点:`<数据根>/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())) -} - /// 取一套字体(**阻塞**:内部走进程共享运行时)。 /// /// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。 @@ -210,6 +150,22 @@ pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf { /// # 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`],但两个时限由调用方给(单测用它把 90 s 压成 300 ms)。 +/// +/// 为什么把时限做成参数而不是读环境变量:这是**行为**判据,不是诊断开关——测试要能证明 +/// 「一条只爬不死的连接会在时限处被打断并换源」,而 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()?; @@ -227,12 +183,41 @@ pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result = None; let mut integrity_error: Option = None; - for url in &spec.urls { + 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; + } + // 每次尝试给 min(单次上限, 剩余总预算):两个时限都不许越。 + let budget = attempt_deadline.min(remaining); let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path) .with_sha256(&spec.sha256) .with_size(spec.bytes); - match block_on(engine.download_bytes(request)) { + 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!( @@ -328,6 +313,10 @@ pub fn looks_like_sfnt(bytes: &[u8]) -> bool { 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 index 568820b..479ba5c 100644 --- a/crates/core/src/download/font/tests.rs +++ b/crates/core/src/download/font/tests.rs @@ -223,6 +223,114 @@ fn an_html_error_page_is_not_a_font() { std::fs::remove_dir_all(&dir).ok(); } +/// 爬行服务端:回完头之后每 100 ms 只挤 1 字节,**永不结束**。 +/// +/// 这正是复审 P2-2 实测到的那条路径(IPv6 边缘 ≈67 KB/s,60 s 只到 4 MB):引擎的 +/// `read_timeout` 是 30 s **空闲**超时,字节一直在动 ⇒ 引擎自己永远不会判它失败。只有我们 +/// 那一层的整体时限能打断它。离线(回环)可跑。 +fn serve_trickle() -> String { + 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" + +" + }) { + break; + } + } + // 声明一个够大的长度,然后开始「爬」:每 100 ms 一个字节。 + if socket + .write_all( + b"HTTP/1.1 200 OK +Content-Type: font/ttf +Content-Length: 1000000 + +", + ) + .is_err() + { + return; + } + for _ in 0..600 { + if socket.write_all(&[0u8]).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + }); + format!("http://{addr}/cjk.ttf") +} + +/// 守卫(M168 复审 P2-2):**只爬不死的连接由整体时限打断**,不许让界面干等。 +/// +/// 抓得住:把 `tokio::time::timeout` 摘掉(只剩引擎的 30 s **空闲**超时)——本用例会一直 +/// 跑在「每 100 ms 一个字节」的连接上,`started.elapsed() < 4 s` 那条断言直接红(实际会挂到 +/// 测试框架超时)。也抓得住「超时被归成校验失败」:`class()` 必须还是 `network`。 +/// 抓不住:90 s/180 s 两个默认值在真网络下够不够(那要真 CDN,见报告「未验」)。 +#[test] +fn a_trickling_connection_is_cut_by_the_deadline() { + let dir = root("deadline"); + let body = fake_font(b"not what the server sends"); + let spec = FontSpec::new(vec![serve_trickle()], 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("爬行连接必须被时限打断"); + assert_eq!(error.class(), "network", "超时属于网络档:{error}"); + assert!( + error.message().contains("超时"), + "文案要说得清是超时:{error}" + ); + assert!( + started.elapsed() < Duration::from_secs(4), + "不该等到引擎的空闲超时:{:?}", + started.elapsed() + ); + assert!(!cache_path(&dir, &spec).exists(), "超时不许落盘"); + 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() { 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/font_install.rs b/crates/i18n/src/catalog/font_install.rs index 53b2da0..65cea10 100644 --- a/crates/i18n/src/catalog/font_install.rs +++ b/crates/i18n/src/catalog/font_install.rs @@ -42,7 +42,12 @@ macro_rules! font_install_entries { "Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.", "获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。" ), - FontCjkReasonNetwork => ("network unreachable", "网络不通"), + // 超时也归这一档(M168 复审 P2-2):用户要分清「太慢/没网」与「内容不对」, + // 但不需要知道我们内部把它叫 Timeout 还是 Network。 + FontCjkReasonNetwork => ( + "network unreachable or too slow (timed out)", + "网络不通或太慢(超时)" + ), FontCjkReasonIntegrity => ( "the mirror sent content that failed verification", "镜像内容校验不通过" @@ -57,8 +62,8 @@ macro_rules! font_install_entries { "无法探测系统字体,因此没有下载" ), FontCjkStatusHint => ( - "Downloaded once into the data directory and cached; the next start does not download it again.", - "只下这一次,缓存在数据目录里,下次启动不重下。" + "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/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 08f8c78..8f2d27f 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -215,10 +215,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("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", "网络不通"), + ("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.", "只下这一次,缓存在数据目录里,下次启动不重下。"), + ("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 023fe46..a89aaa1 100644 --- a/crates/i18n/src/font.rs +++ b/crates/i18n/src/font.rs @@ -72,8 +72,12 @@ pub enum Source { /// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见 /// `unlucky_core::download::font`)。 /// - /// `urls` 是**候选**:首选挂了才试下一个(两个都是同一份字节的镜像,哈希只认一份—— - /// 镜像若给了别的内容,校验那关会当场拒掉,而不是把别的东西注册成字体)。 + /// `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], @@ -105,7 +109,7 @@ pub struct CjkFont { /// |---|---| /// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` | /// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) | -/// | 字节数 | 17 772 300(≈17.0 MiB) | +/// | 字节数 | 17 772 300(16.95 MiB) | /// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` | /// /// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下 @@ -119,11 +123,27 @@ pub const NOTO_SANS_SC: CjkFont = CjkFont { license: "OFL-1.1", 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: &[ - // 首选:jsDelivr(国内多数线路可达;`@` 保证内容不变) + "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", - // 备用:同一份内容的另一个 jsDelivr 边缘(首选被墙/被限速时才用) - "https://fastly.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf", ], sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da", bytes: 17_772_300,