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:
1 parent
449ded2cb7
commit
875b792206
17 files changed
+512
-170
No files matched your search
@@ -27,6 +27,7 @@
|
|||||||
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
|
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
|
||||||
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
|
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
|
||||||
|
|
||||||
|
use std::cell::Cell;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
|
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
|
||||||
@@ -39,6 +40,12 @@ use crate::platform::data;
|
|||||||
pub struct CdnFetcher {
|
pub struct CdnFetcher {
|
||||||
cache_root: PathBuf,
|
cache_root: PathBuf,
|
||||||
spec: FontSpec,
|
spec: FontSpec,
|
||||||
|
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
|
||||||
|
///
|
||||||
|
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
|
||||||
|
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
|
||||||
|
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
|
||||||
|
last_from_cache: Cell<Option<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CdnFetcher {
|
impl CdnFetcher {
|
||||||
@@ -50,18 +57,28 @@ impl CdnFetcher {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
cache_root,
|
cache_root,
|
||||||
spec: spec_of(font)?,
|
spec: spec_of(font)?,
|
||||||
|
last_from_cache: Cell::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 缓存是不是已经在盘上(只 stat,不做校验)。
|
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
|
||||||
///
|
///
|
||||||
/// 用途只有两个:UI 那行「已就绪」的字样、日志里 `from_cache=true` 的标注。**它不承担守卫**:
|
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
|
||||||
/// 真正的命中判定在 `core::download::font`(读出来 + 复验魔数后才算命中)。
|
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
|
||||||
|
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn has_cached(&self) -> bool {
|
pub fn has_cached(&self) -> bool {
|
||||||
core_font::cache_path(&self.cache_root, &self.spec).is_file()
|
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]
|
#[must_use]
|
||||||
pub fn cache_file(&self) -> PathBuf {
|
pub fn cache_file(&self) -> PathBuf {
|
||||||
@@ -71,9 +88,10 @@ impl CdnFetcher {
|
|||||||
|
|
||||||
impl unlucky_i18n::font::Fetcher for CdnFetcher {
|
impl unlucky_i18n::font::Fetcher for CdnFetcher {
|
||||||
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||||
core_font::fetch_blocking(&self.cache_root, &self.spec)
|
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
|
||||||
.map(|fetch| fetch.bytes)
|
self.last_from_cache
|
||||||
.map_err(|error| translate(error))
|
.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 _ = std::fs::remove_dir_all(&dir);
|
||||||
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
|
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
|
||||||
assert!(!fetcher.has_cached());
|
assert!(!fetcher.has_cached());
|
||||||
|
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
|
||||||
assert!(fetcher
|
assert!(fetcher
|
||||||
.cache_file()
|
.cache_file()
|
||||||
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
|
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
//!
|
//!
|
||||||
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
|
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
|
||||||
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
|
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
|
||||||
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`);
|
//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
|
||||||
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。
|
//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
|
||||||
|
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
|
||||||
//!
|
//!
|
||||||
//! ## `ui.language` 的接线(M106)
|
//! ## `ui.language` 的接线(M106)
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -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` 不含任何判据)。
|
||||||
///
|
///
|
||||||
/// * 英文环境 / 系统已有 → 只更新状态(零网络);
|
/// | 策略 | 当前状态 | 这一步 |
|
||||||
/// * 探测不出 → 只更新状态(零网络,**不**当成缺字体);
|
/// |---|---|---|
|
||||||
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载);
|
/// | `NotNeeded` / `System` / `Unprobeable` | 任意 | `Apply`(零网络) |
|
||||||
/// * 上次失败过 → **只有再次切语言才会重试**(用户显式动作),不会自己反复联网。
|
/// | `NeedsDownload` | 下载中 / 已就绪 | **`Skip`**(幂等:切一次语言不起第二条线程) |
|
||||||
pub fn after_probe(strategy: Strategy) -> CjkInstall {
|
/// | `NeedsDownload` | 未判定 / 失败过 / 其它 | `Start`(失败后的重试只能是用户再切一次语言) |
|
||||||
let current = status();
|
#[must_use]
|
||||||
|
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
|
||||||
match strategy {
|
match strategy {
|
||||||
Strategy::NotNeeded => {
|
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
|
||||||
update(CjkInstall::NotNeeded);
|
Strategy::System { family } => Step::Apply(CjkInstall::System {
|
||||||
CjkInstall::NotNeeded
|
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());
|
update(state.clone());
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
Strategy::Unprobeable => {
|
Step::Start(font) => {
|
||||||
update(CjkInstall::Unprobeable);
|
|
||||||
CjkInstall::Unprobeable
|
|
||||||
}
|
|
||||||
Strategy::NeedsDownload(font) => {
|
|
||||||
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
worker::start(font);
|
worker::start(font);
|
||||||
status()
|
status()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ impl Fetcher for FakeFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
|
||||||
|
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
|
||||||
|
|
||||||
/// 假注册器:记收到的字节数,返回一个族名。
|
/// 假注册器:记收到的字节数,返回一个族名。
|
||||||
fn recorder() -> (
|
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
|
||||||
std::rc::Rc<RefCell<Vec<usize>>>,
|
|
||||||
impl Fn(Vec<u8>) -> Result<String, String>,
|
|
||||||
) {
|
|
||||||
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
|
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
|
||||||
let recorder = std::rc::Rc::clone(&seen);
|
let recorder = std::rc::Rc::clone(&seen);
|
||||||
let register = move |bytes: Vec<u8>| {
|
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]
|
#[test]
|
||||||
fn a_started_or_finished_install_is_not_restarted() {
|
fn a_started_or_finished_install_is_not_restarted() {
|
||||||
// 直接打状态机的输入输出:Ready 与 Downloading 都不会进 fetcher。
|
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
|
||||||
let (_, register) = recorder();
|
let ready = CjkInstall::Ready {
|
||||||
let idle = FakeFetcher::new(Ok(vec![1]));
|
family: "Noto Sans SC".to_owned(),
|
||||||
for state in [
|
from_cache: true,
|
||||||
CjkInstall::Ready {
|
};
|
||||||
family: "Noto Sans SC".to_owned(),
|
|
||||||
from_cache: true,
|
for current in [downloading, ready] {
|
||||||
},
|
assert_eq!(
|
||||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
plan(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t),
|
||||||
] {
|
Step::Skip,
|
||||||
assert!(!matches!(state, CjkInstall::Unknown), "{state:?}");
|
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
|
||||||
assert_eq!(idle.calls(), 0);
|
);
|
||||||
let _ = ®ister;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 反方向(否则上面两条可能是「永远 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` 取值都能解析成一档状态,坏值不猜。
|
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
|
||||||
|
|||||||
@@ -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 });
|
update(CjkInstall::Downloading { bytes });
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[font] cjk-download start family=\"{}\" bytes={bytes} from_cache={cached} file={}",
|
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
|
||||||
font.family,
|
font.family,
|
||||||
fetcher.cache_file().display()
|
cache_file.display(),
|
||||||
|
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
|
||||||
|
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
|
||||||
|
fetcher.has_cached()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。
|
// 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。
|
||||||
@@ -70,12 +73,11 @@ pub(super) fn start(font: &'static font::CjkFont) {
|
|||||||
&fetcher,
|
&fetcher,
|
||||||
®ister_into_font_system,
|
®ister_into_font_system,
|
||||||
);
|
);
|
||||||
|
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
|
||||||
|
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
|
||||||
|
let from_cache = fetcher.was_cached().unwrap_or(false);
|
||||||
let state = match state {
|
let state = match state {
|
||||||
// 命中缓存的那一份要把 `from_cache` 标出来(状态行靠它区分「等了一会儿」与「真的下了一次」)。
|
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
|
||||||
CjkInstall::Ready { family, .. } => CjkInstall::Ready {
|
|
||||||
family,
|
|
||||||
from_cache: cached,
|
|
||||||
},
|
|
||||||
other => other,
|
other => other,
|
||||||
};
|
};
|
||||||
match &state {
|
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 {
|
fn format_size(bytes: u64) -> String {
|
||||||
const MIB: f64 = 1024.0 * 1024.0;
|
const MIB: f64 = 1024.0 * 1024.0;
|
||||||
let mib = bytes as f64 / MIB;
|
let mib = bytes as f64 / MIB;
|
||||||
|
|||||||
@@ -139,8 +139,12 @@ impl LangState {
|
|||||||
report.strategy,
|
report.strategy,
|
||||||
unlucky_i18n::font::Strategy::NeedsDownload(_)
|
unlucky_i18n::font::Strategy::NeedsDownload(_)
|
||||||
) {
|
) {
|
||||||
|
// M168:这里**不再**说「通道还没做」——下载在这一拍之后就已经发起(同一份报告交给
|
||||||
|
// `fonts::install::after_probe`),结果与状态在 `[font] cjk-download …` 那几行里,
|
||||||
|
// 也在设置页 General 组的状态行上。上一版这句 stderr 在下载发起后说「还是 PendingPin」,
|
||||||
|
// 等于当面否定刚做的事(复审 r1 P2-3)。
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)"
|
"[i18n] 缺中文字体:已按 D28 发起下载,进度与结果见 [font] cjk-download … 与设置页「中文字体」那一行;失败会降级用系统字体"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
//! 健康度 / 候选源都复用)。本模块只额外提供「字体这个资源特有的三件事」:内容寻址的缓存文件名、
|
//! 健康度 / 候选源都复用)。本模块只额外提供「字体这个资源特有的三件事」:内容寻址的缓存文件名、
|
||||||
//! 复验、大小上限。
|
//! 复验、大小上限。
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use super::atomic::write_atomic;
|
use super::atomic::write_atomic;
|
||||||
@@ -51,11 +51,31 @@ use super::verify;
|
|||||||
|
|
||||||
/// 一套字体允许的最大字节数(32 MiB)。
|
/// 一套字体允许的最大字节数(32 MiB)。
|
||||||
///
|
///
|
||||||
/// 实测参考:Noto Sans SC 可变字体(wght 100–900,整个简体字符集)17.0 MiB;再大的「字体」基本
|
/// 实测参考:Noto Sans SC 可变字体(wght 100–900,整个简体字符集)16.95 MiB;再大的「字体」基本
|
||||||
/// 可以判断不是给 UI 用的字体。上限的用处不是省流量,是**挡住被换掉的 CDN 把几十上百 MB 的东西
|
/// 可以判断不是给 UI 用的字体。上限的用处不是省流量,是**挡住被换掉的 CDN 把几十上百 MB 的东西
|
||||||
/// 写进数据根**。
|
/// 写进数据根**。
|
||||||
pub const MAX_FONT_BYTES: u64 = 32 * 1024 * 1024;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FontFetchError {
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FontFetch {
|
pub struct FontFetch {
|
||||||
@@ -193,16 +143,6 @@ pub struct FontFetch {
|
|||||||
pub elapsed: Duration,
|
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 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。
|
/// 顺序:pin 自洽检查 → 缓存复验 → 逐个 URL 走共享引擎 → 复验 + 魔数 + 上限 → 原子落盘。
|
||||||
@@ -210,6 +150,22 @@ pub fn cache_path(cache_root: &Path, spec: &FontSpec) -> PathBuf {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
/// 见 [`FontFetchError`]。失败**不是**致命错误:调用方应当降级回系统字体 / 英文界面。
|
/// 见 [`FontFetchError`]。失败**不是**致命错误:调用方应当降级回系统字体 / 英文界面。
|
||||||
pub fn fetch_blocking(cache_root: &Path, spec: &FontSpec) -> Result<FontFetch, 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();
|
let started = Instant::now();
|
||||||
spec.check()?;
|
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 network_error: Option<FontFetchError> = None;
|
||||||
let mut integrity_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)
|
let request = DownloadRequest::new(ResourceKind::Other, url.clone(), &final_path)
|
||||||
.with_sha256(&spec.sha256)
|
.with_sha256(&spec.sha256)
|
||||||
.with_size(spec.bytes);
|
.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) => {
|
Ok(bytes) => {
|
||||||
if bytes.len() as u64 > MAX_FONT_BYTES {
|
if bytes.len() as u64 > MAX_FONT_BYTES {
|
||||||
integrity_error = Some(FontFetchError::Integrity(format!(
|
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
|
tables > 0 && bytes.len() >= 12 + usize::from(tables) * 16
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod spec;
|
||||||
|
|
||||||
|
pub use spec::{cache_path, FontSpec};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "font/tests.rs"]
|
#[path = "font/tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -223,6 +223,114 @@ fn an_html_error_page_is_not_a_font() {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
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 白跑一趟网络)。
|
/// pin 自身不合法时**一个请求都不发**(免得为一个坏 pin 白跑一趟网络)。
|
||||||
#[test]
|
#[test]
|
||||||
fn an_invalid_pin_never_reaches_the_network() {
|
fn an_invalid_pin_never_reaches_the_network() {
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() {
|
|||||||
fn schema_registration_shape_is_right() {
|
fn schema_registration_shape_is_right() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
launcher_version().minor,
|
launcher_version().minor,
|
||||||
6,
|
7,
|
||||||
"1.6.0 登记 M161 的自更新两键(update.auto_check / update.channel)"
|
"1.7.0 登记 M168 的 ui.font_cdn(1.6.0 是 M161 的自更新两键)"
|
||||||
);
|
);
|
||||||
let schema = launcher_schema();
|
let schema = launcher_schema();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -128,10 +128,9 @@ fn overrides_key_is_an_enum_whitelist() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_versions_register_the_new_keys_without_migration() {
|
fn schema_versions_register_the_new_keys_without_migration() {
|
||||||
// 1.6.0 登记 M161 的自更新两键(`update.auto_check` / `update.channel`;
|
// 1.7.0 登记 M168 的 `ui.font_cdn`(1.6.0 登记 M161 的自更新两键,1.5.0 = M116 窗口圆角,
|
||||||
// 1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,
|
// 1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,1.2.0 = D23 的 JDK 落点/优先级键)。
|
||||||
// 1.2.0 = D23 的 JDK 落点/优先级键)。
|
assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0));
|
||||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0));
|
|
||||||
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0));
|
||||||
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER);
|
||||||
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
// major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() {
|
||||||
// 1.6.0 = M161 的自更新两键(此前 1.5.0 是 M116 窗口圆角)。
|
// 1.7.0 = M168 的中文字体下载地址覆盖键(`ui.font_cdn`;此前 1.6.0 是 M161 的自更新两键,
|
||||||
assert_eq!(launcher_version(), SchemaVersion::new(1, 6, 0));
|
// 1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,不是巧合:
|
||||||
|
// 老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。
|
||||||
|
assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
launcher_version().major,
|
launcher_version().major,
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use unlucky_core::setting::document::{RenderMode, SettingDocument};
|
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::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::spec::{Sensitivity, ValueKind};
|
||||||
use unlucky_core::setting::store::SettingStore;
|
use unlucky_core::setting::store::SettingStore;
|
||||||
use unlucky_core::setting::update_prefs::{
|
use unlucky_core::setting::update_prefs::{
|
||||||
@@ -286,16 +287,32 @@ fn the_receipt_gate_skips_a_replayed_source_byte_for_byte() {
|
|||||||
|
|
||||||
/// **`Refused`:主配置不可写时不动旧文件**(唯一有用户数据风险的分支)。
|
/// **`Refused`:主配置不可写时不动旧文件**(唯一有用户数据风险的分支)。
|
||||||
///
|
///
|
||||||
/// 造法:主配置的 `$schema` 写成**更高的 minor**(1.7.0 > 1.6.0)⇒ `SchemaCompat` 判
|
/// 造法:主配置的 `$schema` 写成**比当前更高的 minor**(`launcher_version().minor + 1`)⇒
|
||||||
/// `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。
|
/// `SchemaCompat` 判 `ReadOnlyPreserveSchema` ⇒ `FileAccess::ReadOnly` ⇒ `can_save()` false。
|
||||||
|
///
|
||||||
|
/// **这里刻意不写死数字**(M168 复审 p1-1):上一版把「更高」写成了字面量 `1.7.0`,于是
|
||||||
|
/// `launcher_version()` 一推到 1.7.0 这个夹具就变成「同版本」——文件可写 ⇒ 迁移真的执行 ⇒
|
||||||
|
/// 旧文件被退役,`Refused` 分支再也走不到,而**断言还在,绿着**:一条守护用户数据的用例
|
||||||
|
/// 就这样静默失效了。相对推导(current + 1)不会过期,版本往后推多少次它都还是「更高一档」。
|
||||||
/// 抓得住:把「写不进去」当成「迁完了」(旧文件被改名,用户的值两边都没有了)。
|
/// 抓得住:把「写不进去」当成「迁完了」(旧文件被改名,用户的值两边都没有了)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_read_only_launcher_config_never_retires_the_legacy_file() {
|
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");
|
let temp = TempDir::new("m161-refused");
|
||||||
temp.write(
|
temp.write(
|
||||||
"config/launcher.json",
|
"config/launcher.json",
|
||||||
&json!({
|
&json!({
|
||||||
"$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.7.0",
|
"$schema": newer_schema,
|
||||||
"schemaVersion": 1
|
"schemaVersion": 1
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -323,9 +340,9 @@ fn a_read_only_launcher_config_never_retires_the_legacy_file() {
|
|||||||
);
|
);
|
||||||
// 不写收据(没迁成就不该有收据)。
|
// 不写收据(没迁成就不该有收据)。
|
||||||
assert!(!temp.exists(&format!("config/{RECEIPT_FILE}")));
|
assert!(!temp.exists(&format!("config/{RECEIPT_FILE}")));
|
||||||
// 主配置一个字都没改(还是那份 1.7.0)。
|
// 主配置一个字都没改(还是那份「更高 minor」的 `$schema`)。
|
||||||
let text = temp.read("config/launcher.json");
|
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}");
|
assert!(!text.contains("auto_check"), "只读文件不许被写:{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.",
|
||||||
"获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"
|
"获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"
|
||||||
),
|
),
|
||||||
FontCjkReasonNetwork => ("network unreachable", "网络不通"),
|
// 超时也归这一档(M168 复审 P2-2):用户要分清「太慢/没网」与「内容不对」,
|
||||||
|
// 但不需要知道我们内部把它叫 Timeout 还是 Network。
|
||||||
|
FontCjkReasonNetwork => (
|
||||||
|
"network unreachable or too slow (timed out)",
|
||||||
|
"网络不通或太慢(超时)"
|
||||||
|
),
|
||||||
FontCjkReasonIntegrity => (
|
FontCjkReasonIntegrity => (
|
||||||
"the mirror sent content that failed verification",
|
"the mirror sent content that failed verification",
|
||||||
"镜像内容校验不通过"
|
"镜像内容校验不通过"
|
||||||
@@ -57,8 +62,8 @@ macro_rules! font_install_entries {
|
|||||||
"无法探测系统字体,因此没有下载"
|
"无法探测系统字体,因此没有下载"
|
||||||
),
|
),
|
||||||
FontCjkStatusHint => (
|
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 指定。"
|
||||||
),
|
),
|
||||||
} }
|
} }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
|||||||
("FontCjkStatusSystem", "Ready (system font: {family})", "已就绪(系统字体:{family})"),
|
("FontCjkStatusSystem", "Ready (system font: {family})", "已就绪(系统字体:{family})"),
|
||||||
("FontCjkStatusDownloading", "Fetching a Chinese font… ({size})", "正在获取中文字体…({size})"),
|
("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})。界面照常可用,用的是系统字体;再切一次语言可以重试。"),
|
("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", "镜像内容校验不通过"),
|
("FontCjkReasonIntegrity", "the mirror sent content that failed verification", "镜像内容校验不通过"),
|
||||||
("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"),
|
("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"),
|
||||||
("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"),
|
("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"),
|
||||||
("FontCjkStatusUnprobeable", "System fonts cannot be probed, so 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
@@ -72,8 +72,12 @@ pub enum Source {
|
|||||||
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
|
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
|
||||||
/// `unlucky_core::download::font`)。
|
/// `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 {
|
Pinned {
|
||||||
/// URL 候选(按顺序尝试)。
|
/// URL 候选(按顺序尝试)。
|
||||||
urls: &'static [&'static str],
|
urls: &'static [&'static str],
|
||||||
@@ -105,7 +109,7 @@ pub struct CjkFont {
|
|||||||
/// |---|---|
|
/// |---|---|
|
||||||
/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` |
|
/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` |
|
||||||
/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) |
|
/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) |
|
||||||
/// | 字节数 | 17 772 300(≈17.0 MiB) |
|
/// | 字节数 | 17 772 300(16.95 MiB) |
|
||||||
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
|
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
|
||||||
///
|
///
|
||||||
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
|
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
|
||||||
@@ -119,11 +123,27 @@ pub const NOTO_SANS_SC: CjkFont = CjkFont {
|
|||||||
license: "OFL-1.1",
|
license: "OFL-1.1",
|
||||||
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
|
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||||
source: Source::Pinned {
|
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: &[
|
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",
|
"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",
|
sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||||
bytes: 17_772_300,
|
bytes: 17_772_300,
|
||||||
|
|||||||
Reference in new issue
Block a user