fix(m168): 复审 r1 五项 —— 修红的 integration target、独立失败域、整体时限

复审 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)
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 449ded2cb7
commit 875b792206
17 files changed
+512 -170

No files matched your search

+25 -6
View File
@@ -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<Option<bool>>,
}
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<bool> {
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<Vec<u8>, 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"));
+3 -2
View File
@@ -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)
//!
+46 -23
View File
@@ -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()
}
+67 -20
View File
@@ -38,11 +38,11 @@ impl Fetcher for FakeFetcher {
}
}
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (
std::rc::Rc<RefCell<Vec<usize>>>,
impl Fn(Vec<u8>) -> Result<String, String>,
) {
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
let recorder = std::rc::Rc::clone(&seen);
let register = move |bytes: Vec<u8>| {
@@ -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 _ = &register;
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), &current),
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), &current),
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` 取值都能解析成一档状态,坏值不猜。
+10 -8
View File
@@ -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,
&register_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 {
@@ -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;
+5 -1
View File
@@ -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 … 与设置页「中文字体」那一行;失败会降级用系统字体"
);
}
}
+73 -84
View File
@@ -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<super::error::DownloadError> 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<String>,
/// 期望 sha256(小写十六进制、64 位)。
pub sha256: String,
/// 期望字节数(进度预算 + 大小上限的判据)。
pub bytes: u64,
}
impl FontSpec {
/// 构造并做基本自洽检查(URL 非空 / 哈希形态 / 大小上限)。
///
/// # Errors
/// pin 本身不合法时返回 [`FontFetchError::Integrity`](**不发请求**)。
pub fn new(
urls: impl IntoIterator<Item = impl Into<String>>,
sha256: impl Into<String>,
bytes: u64,
) -> Result<Self, FontFetchError> {
let spec = Self {
urls: urls.into_iter().map(Into::into).collect(),
sha256: sha256.into().trim().to_ascii_lowercase(),
bytes,
};
spec.check()?;
Ok(spec)
}
/// pin 是否自洽。
fn check(&self) -> Result<(), FontFetchError> {
if self.urls.is_empty() {
return Err(FontFetchError::Integrity("字体 pin 没有 URL".to_owned()));
}
if let Some(bad) = self
.urls
.iter()
.find(|url| !(url.starts_with("https://") || url.starts_with("http://")))
{
return Err(FontFetchError::Integrity(format!(
"字体 pin 只接受 http(s) URL:{bad}"
)));
}
if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(FontFetchError::Integrity(format!(
"字体 pin 的 sha256 不是 64 位十六进制:{}",
self.sha256
)));
}
if self.bytes == 0 || self.bytes > MAX_FONT_BYTES {
return Err(FontFetchError::Integrity(format!(
"字体 pin 的字节数 {} 不在 (0, {MAX_FONT_BYTES}] 内",
self.bytes
)));
}
Ok(())
}
/// 缓存文件名的哈希段(前 16 位:够区分,又不至于让路径长得没法看)。
#[must_use]
fn cache_key(&self) -> &str {
let end = self.sha256.len().min(16);
&self.sha256[..end]
}
}
/// 一次取回的产物。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FontFetch {
@@ -193,16 +143,6 @@ pub struct FontFetch {
pub elapsed: Duration,
}
/// 缓存文件落点:`<数据根>/fonts/cjk-noto-sans-sc-<sha256 前 16 位>.ttf`。
///
/// 文件名带哈希段是刻意的:pin 一改(换字体 / 换子集),新文件自然换名,旧文件不会冒充命中。
#[must_use]
pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf {
cache_root
.join("fonts")
.join(format!("cjk-noto-sans-sc-{}.ttf", spec.cache_key()))
}
/// 取一套字体(**阻塞**:内部走进程共享运行时)。
///
/// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。
@@ -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<FontFetch, FontFetchError> {
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<FontFetch, FontFetchError> {
let started = Instant::now();
spec.check()?;
@@ -227,12 +183,41 @@ pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result<FontFetch, F
let mut network_error: Option<FontFetchError> = None;
let mut integrity_error: Option<FontFetchError> = 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;
+106
View File
@@ -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<String>,
/// 期望 sha256(小写十六进制、64 位)。
pub sha256: String,
/// 期望字节数(进度预算 + 大小上限的判据)。
pub bytes: u64,
}
impl FontSpec {
/// 构造并做基本自洽检查(URL 非空 / 哈希形态 / 大小上限)。
///
/// # Errors
/// pin 本身不合法时返回 [`FontFetchError::Integrity`](**不发请求**)。
pub fn new(
urls: impl IntoIterator<Item = impl Into<String>>,
sha256: impl Into<String>,
bytes: u64,
) -> Result<Self, FontFetchError> {
let spec = Self {
urls: urls.into_iter().map(Into::into).collect(),
sha256: sha256.into().trim().to_ascii_lowercase(),
bytes,
};
spec.check()?;
Ok(spec)
}
/// pin 是否自洽(`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-<sha256 前 16 位>.ttf`。
///
/// 文件名带哈希段是刻意的:pin 一改(换字体 / 换子集),新文件自然换名,旧文件不会冒充命中。
#[must_use]
pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf {
cache_root
.join("fonts")
.join(format!("cjk-noto-sans-sc-{}.ttf", spec.cache_key()))
}
+108
View File
@@ -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() {
+2 -2
View File
@@ -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!(
+3 -4
View File
@@ -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 走)不需要新步骤,老文件缺键即默认值。
@@ -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,
+23 -6
View File
@@ -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}");
}
+8 -3
View File
@@ -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 指定。"
),
} }
};
+2 -2
View File
@@ -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 指定。"),
];
+26 -6
View File
@@ -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(国内多数线路可达;`@<commit>` 保证内容不变)
"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,