feat(m168): 中文字体 CDN 下载真接线(D28 后半)

M167 调研的结论是「中文字体 CDN 下载至今未实装」:`i18n::font` 只有 `Fetcher` trait 与
假实现,`needs-download` 分支什么也不做。这条把它接成一条真链路。

判据 → 下载 → 注册(一条链,四个 crate 各管一段):

* `unlucky_i18n::font`(零依赖):`Source::Pinned { urls, sha256, bytes }` 把「下什么」
  钉死——google/fonts 的 `NotoSansSC[wght].ttf` 钉到 commit `2894aab…`(不是 `@main`),
  17 772 300 B / sha256 `a3041811…`。可变字体一份覆盖 wght 100–900,省掉三份静态子集的 25 MiB。
  `FetchError` 补第三档 `Disk`(网络 / 校验 / 磁盘,UI 按类别选词)。
* `unlucky_core::download::font`(新):缓存命中 → 共享引擎 `download_bytes` → 复验(sha256 +
  sfnt 魔数 + 32 MiB 上限)→ 原子落盘。**复验在写盘之前**,所以「哈希不符」时盘上什么都没有;
  缓存按 `<sha256 前 16 位>` 命名,pin 一换自动换名。
* `unlucky_app_iced::fonts::{cdn,install}`(新):`CdnFetcher` 把 pin 接进 core(`ui.font_cdn`
  可覆盖 URL,哈希不换);`install` 是状态机 + 后台线程,只有 `NeedsDownload` 才起线程
  (英文 / 系统已有 / 探测不出一律零网络)。注册走 `iced_graphics::text::FontSystem::load_font`,
  族名从字体库读回来核对。失败只降级(继续用系统字体),不阻塞、不崩。
* 接入两条路径:`fonts::resolve_with`(启动)与 `fonts::cjk_for`(设置页切语言)都调
  `install::after_probe`;设置页 General 组多一行状态(下载中 / 完成 / 失败 / 无需下载 + 三类失败
  原因),文案进 catalog 金表(链尾追加,既有顺序不动);`App::advance` 每秒比对状态快照后重建
  视图模型,所以那一行会自己刷新。

配置:新增 `ui.font_cdn`(`keys/font.rs`,schema 1.7.0),空 = 内置 jsDelivr 镜像(含备用边缘)。

守卫(三条都能变红,变异证明见报告):core 侧 8 条(缓存不联网 / 哈希不符拒落盘 / HTML 不当字体
/ 坏缓存自愈 / 上限 / pin 校验),app 侧 6 条(三种闲置策略零调用、失败不注册、类别不串、幂等、
截图钉子、单行日志),页面侧 1 条(四态 × 两语言无缺译、不写假百分比)。

测试:`cargo test -p unlucky-i18n --lib` 31 绿;`-p unlucky-core --lib` 622 绿;
`-p unlucky-app-iced --lib` 684 绿;`cargo fmt --check` 干净。截图三张见报告。
This commit is contained in:
AskaEth committed 2026-11-08 14:38:23 +08:00
1 parent 2a004a5754
commit 449ded2cb7
22 files changed
+2191 -184

No files matched your search

+6
View File
@@ -130,6 +130,12 @@ pub struct App {
pub(super) refreshes: u64,
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
pub(super) auto_selected: bool,
/// 上一次看到的**中文字体下载状态**(M168)。
///
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
/// 而空闲态不会为了它每拍白重建一次。
pub(super) font_seen: crate::fonts::install::CjkInstall,
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
pub(super) height: f32,
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
@@ -114,6 +114,9 @@ impl App {
frames: 0,
refreshes: 0,
auto_selected: false,
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
font_seen: crate::fonts::install::status(),
height,
appearance,
appearance_prefs,
+7
View File
@@ -42,6 +42,13 @@ impl App {
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true;
}
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
// 从「正在获取中文字体…(17.0 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
let font_status = crate::fonts::install::status();
if font_status != self.font_seen {
self.font_seen = font_status;
self.view_dirty = true;
}
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
+25 -8
View File
@@ -18,18 +18,31 @@
//! ## 不做什么
//!
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
//! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,
//! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。
//!
//! ## 中文那一半(M92 / D28 后半)
//! ## 中文那一半(M92 / D28 后半 / M168 实装下载)
//!
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
//! cosmic-text 的全局状态,不是本模块能管的事)。
//!
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
//!
//! ```text
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
//! ```
//!
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
pub mod cdn;
mod cjk;
pub mod install;
use cjk::CjkReport;
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
@@ -211,6 +224,8 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
let cjk_report = cjk::probe_with(setting, faces.as_deref());
// M168:只有 `NeedsDownload` 才会在这里发起下载(后台线程),其余三路一次网络都不发。
install::after_probe(cjk_report.strategy);
Resolved {
platform,
@@ -220,15 +235,17 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
}
}
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值**
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
/// 语言切换后的中文策略重算(M106 判据 / M168 触发下载):`setting` 是配置里 `ui.language` 的
/// **显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
///
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`,
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。
/// 那就是白块。复算出的 `NeedsDownload` 现在**真的会去下载**(M168:`install::after_probe`),
/// 结果进设置页那一行(`pages::settings::general`)与 stderr 的 `[font] cjk-download …` 日志。
pub fn cjk_for(setting: Option<&str>) -> CjkReport {
cjk::probe_with(setting, system_faces().as_deref())
let report = cjk::probe_with(setting, system_faces().as_deref());
install::after_probe(report.strategy);
report
}
/// 一条系统字面(族名 + 字重)。
+154
View File
@@ -0,0 +1,154 @@
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
//!
//! ## 一句话
//!
//! ```text
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
//! core 的 FontSpec(值)
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
//! ```
//!
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
//! 单测)。
//!
//! ## 「不联网」的三条路
//!
//! | 情形 | 谁拦住 |
//! |---|---|
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
//!
//! ## 设置改写只换 URL,不换哈希
//!
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
use std::path::PathBuf;
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
use unlucky_i18n::font::{CjkFont, FetchError, Source};
use crate::platform::data;
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
#[derive(Debug, Clone)]
pub struct CdnFetcher {
cache_root: PathBuf,
spec: FontSpec,
}
impl CdnFetcher {
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
///
/// # Errors
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
Ok(Self {
cache_root,
spec: spec_of(font)?,
})
}
/// 缓存是不是已经在盘上(只 stat,不做校验)。
///
/// 用途只有两个:UI 那行「已就绪」的字样、日志里 `from_cache=true` 的标注。**它不承担守卫**:
/// 真正的命中判定在 `core::download::font`(读出来 + 复验魔数后才算命中)。
#[must_use]
pub fn has_cached(&self) -> bool {
core_font::cache_path(&self.cache_root, &self.spec).is_file()
}
/// 缓存文件落点(诊断日志用)。
#[must_use]
pub fn cache_file(&self) -> PathBuf {
core_font::cache_path(&self.cache_root, &self.spec)
}
}
impl unlucky_i18n::font::Fetcher for CdnFetcher {
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
core_font::fetch_blocking(&self.cache_root, &self.spec)
.map(|fetch| fetch.bytes)
.map_err(|error| translate(error))
}
}
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
///
/// # Errors
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
let Source::Pinned {
urls,
sha256,
bytes,
} = font.source;
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
if let Some(override_url) = data::font_cdn_override() {
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
candidates.insert(0, override_url);
}
FontSpec::new(candidates, sha256, bytes)
.map_err(|error| FetchError::Integrity(error.to_string()))
}
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
fn translate(error: FontFetchError) -> FetchError {
match error {
FontFetchError::Network(message) => FetchError::Network(message),
FontFetchError::Integrity(message) => FetchError::Integrity(message),
FontFetchError::Disk(message) => FetchError::Disk(message),
}
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_i18n::font::NOTO_SANS_SC;
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
///
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
#[test]
fn the_pin_survives_the_trip_to_core() {
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
let Source::Pinned {
urls,
sha256,
bytes,
} = NOTO_SANS_SC.source;
assert!(
spec.urls.len() >= urls.len(),
"候选不该变少:{:?}",
spec.urls
);
assert_eq!(spec.sha256, sha256);
assert_eq!(spec.bytes, bytes);
assert_eq!(
spec.urls[spec.urls.len() - urls.len()],
urls[0],
"内置首选不许被挤掉"
);
}
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
#[test]
fn an_empty_cache_root_is_not_a_hit() {
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
assert!(!fetcher.has_cached());
assert!(fetcher
.cache_file()
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
}
}
+290
View File
@@ -0,0 +1,290 @@
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
//!
//! ## 一句话
//!
//! ```text
//! i18n::font::decide(判据) → Strategy
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
//! fonts::install::register(注册) → iced 的字体系统里多一个族
//! ```
//!
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
//!
//! | 入口 | 时机 | 谁来 |
//! |---|---|---|
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
//!
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
//!
//! ## 为什么下载在后台线程而不是启动那一拍
//!
//! 一份 17 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
//!
//! ## 失败**不是**致命错误
//!
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
//! (「下载失败不崩」)。
//!
//! ## 状态是一份进程级快照
//!
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
//!
//! ## 截图通道
//!
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
use std::sync::{Mutex, OnceLock};
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchClass {
/// 网络层。
Network,
/// 校验层(哈希 / 魔数 / 大小上限)。
Integrity,
/// 磁盘层。
Disk,
}
impl FetchClass {
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Network => "network",
Self::Integrity => "integrity",
Self::Disk => "disk",
}
}
}
impl From<&FetchError> for FetchClass {
fn from(error: &FetchError) -> Self {
match error {
FetchError::Network(_) => Self::Network,
FetchError::Integrity(_) => Self::Integrity,
FetchError::Disk(_) => Self::Disk,
}
}
}
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CjkInstall {
/// 还没判定(启动前 / 判据没跑过)。
Unknown,
/// 英文环境:不需要中文字体(D28 的第一条)。
NotNeeded,
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
System {
/// 命中的族名。
family: String,
},
/// 探测不出(拿不到系统字体库)——**不下载**。
Unprobeable,
/// 正在下载。
Downloading {
/// 字节数(pin 里的期望值;用来写「≈17 MB」这种可核对的量)。
bytes: u64,
},
/// 已就绪(下载完成并注册进字体系统)。
Ready {
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
family: String,
/// 是否零网络命中缓存。
from_cache: bool,
},
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
Failed {
/// 类别。
class: FetchClass,
/// 人话原因(日志/诊断用;UI 只按类别选词)。
message: String,
},
}
impl CjkInstall {
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
#[must_use]
pub const fn is_downloading(&self) -> bool {
matches!(self, Self::Downloading { .. })
}
/// 一行日志片段(`cjk-install=…`)。
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Unknown => "cjk-install=unknown".to_owned(),
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
Self::Ready { family, from_cache } => {
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
}
Self::Failed { class, message } => {
format!("cjk-install=failed({},{message})", class.as_str())
}
}
}
}
/// 进程级状态(一份)。
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
}
/// 当前状态(设置页那一行、心跳、日志都读它)。
#[must_use]
pub fn status() -> CjkInstall {
if let Some(forced) = capture_override() {
return forced;
}
slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
fn capture_override() -> Option<CjkInstall> {
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
FORCED
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
.clone()
}
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
#[must_use]
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
let value = raw.trim();
Some(match value {
"not-needed" => CjkInstall::NotNeeded,
"unprobeable" => CjkInstall::Unprobeable,
"system" => CjkInstall::System {
family: "Noto Sans SC".to_owned(),
},
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
"ready" => CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
"failed" | "failed:network" => CjkInstall::Failed {
class: FetchClass::Network,
message: "capture fixture".to_owned(),
},
"failed:integrity" => CjkInstall::Failed {
class: FetchClass::Integrity,
message: "capture fixture".to_owned(),
},
"failed:disk" => CjkInstall::Failed {
class: FetchClass::Disk,
message: "capture fixture".to_owned(),
},
_ => return None,
})
}
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
///
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
pub fn ensure_for(
strategy: Strategy,
fetcher: &dyn Fetcher,
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
) -> CjkInstall {
match font::ensure(strategy, fetcher) {
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
family: family.to_owned(),
},
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
Ok(family) => CjkInstall::Ready {
family,
from_cache: false,
},
Err(message) => CjkInstall::Failed {
class: FetchClass::Disk,
message,
},
},
Err(error) => CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
},
}
}
/// 判据结论 → 现在这一步的结局(生产入口)。
///
/// 只有 `NeedsDownload` 且**当前不在下载/已就绪**时才真的发起下载:
///
/// * 英文环境 / 系统已有 → 只更新状态(零网络);
/// * 探测不出 → 只更新状态(零网络,**不**当成缺字体);
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载);
/// * 上次失败过 → **只有再次切语言才会重试**(用户显式动作),不会自己反复联网。
pub fn after_probe(strategy: Strategy) -> CjkInstall {
let current = status();
match strategy {
Strategy::NotNeeded => {
update(CjkInstall::NotNeeded);
CjkInstall::NotNeeded
}
Strategy::System { family } => {
let state = CjkInstall::System {
family: family.to_owned(),
};
update(state.clone());
state
}
Strategy::Unprobeable => {
update(CjkInstall::Unprobeable);
CjkInstall::Unprobeable
}
Strategy::NeedsDownload(font) => {
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
return current;
}
worker::start(font);
status()
}
}
}
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
pub(super) fn update(state: CjkInstall) {
let previous = {
let mut guard = slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::replace(&mut *guard, state.clone())
};
if previous != state {
eprintln!("[font] {}", state.describe());
}
}
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
mod worker;
#[cfg(test)]
#[path = "install/tests.rs"]
mod tests;
+243
View File
@@ -0,0 +1,243 @@
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
//!
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
use std::cell::RefCell;
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
use super::*;
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
struct FakeFetcher {
calls: RefCell<usize>,
seen: RefCell<Option<&'static str>>,
result: Result<Vec<u8>, FetchError>,
}
impl FakeFetcher {
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
Self {
calls: RefCell::new(0),
seen: RefCell::new(None),
result,
}
}
fn calls(&self) -> usize {
*self.calls.borrow()
}
}
impl Fetcher for FakeFetcher {
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
*self.calls.borrow_mut() += 1;
*self.seen.borrow_mut() = Some(font.family);
self.result.clone()
}
}
/// 假注册器:记收到的字节数,返回一个族名。
fn recorder() -> (
std::rc::Rc<RefCell<Vec<usize>>>,
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>| {
recorder.borrow_mut().push(bytes.len());
Ok("Noto Sans SC".to_owned())
};
(seen, register)
}
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
///
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
/// `CdnFetcher` 的缓存判定承担)。
#[test]
fn the_three_idle_strategies_never_touch_the_fetcher() {
let (registered, register) = recorder();
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
assert_eq!(
ensure_for(Strategy::NotNeeded, &idle, &register),
CjkInstall::NotNeeded
);
assert_eq!(
ensure_for(Strategy::System { family: "SimSun" }, &idle, &register),
CjkInstall::System {
family: "SimSun".to_owned()
}
);
assert_eq!(
ensure_for(Strategy::Unprobeable, &idle, &register),
CjkInstall::Unprobeable
);
assert_eq!(
idle.calls(),
0,
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
);
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
let working = FakeFetcher::new(Ok(vec![9; 4096]));
assert_eq!(
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, &register),
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false
}
);
assert_eq!(working.calls(), 1);
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
assert_eq!(*registered.borrow(), vec![4096]);
}
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
///
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
#[test]
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
let (registered, register) = recorder();
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, &register);
assert_eq!(
state,
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned()
}
);
assert!(registered.borrow().is_empty(), "校验失败不许注册");
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
}
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
#[test]
fn every_failure_class_survives_the_trip() {
let (_, register) = recorder();
for (error, class) in [
(FetchError::Network("超时".to_owned()), FetchClass::Network),
(
FetchError::Integrity("哈希不符".to_owned()),
FetchClass::Integrity,
),
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
] {
let fetcher = FakeFetcher::new(Err(error));
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, &register);
match state {
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
other => panic!("应当是 Failed:{other:?}"),
}
}
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
let state = ensure_for(
Strategy::NeedsDownload(&NOTO_SANS_SC),
&fetcher,
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
);
assert!(matches!(
state,
CjkInstall::Failed {
class: FetchClass::Disk,
..
}
));
}
/// 状态机的**幂等**:已就绪 / 下载中时再问一次不会起第二条下载。
///
/// 抓得住:`after_probe` 里删掉 `is_downloading() || Ready` 那半(用户来回切语言会起 N 条线程,
/// 每条都下一遍 17 MiB)。本用例只测纯判据那一层——`after_probe` 的线程行为要真网络,没测。
#[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;
}
}
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
///
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
#[test]
fn the_capture_switch_parses_every_state() {
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
assert!(matches!(
parse_capture("system"),
Some(CjkInstall::System { .. })
));
assert!(matches!(
parse_capture("downloading"),
Some(CjkInstall::Downloading { .. })
));
assert!(matches!(
parse_capture("ready"),
Some(CjkInstall::Ready {
from_cache: true,
..
})
));
for (raw, class) in [
("failed", FetchClass::Network),
("failed:network", FetchClass::Network),
("failed:integrity", FetchClass::Integrity),
("failed:disk", FetchClass::Disk),
] {
match parse_capture(raw) {
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
other => panic!("{raw} 应解析成 Failed:{other:?}"),
}
}
assert_eq!(parse_capture("bogus"), None);
assert_eq!(parse_capture(""), None);
}
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
#[test]
fn every_state_describes_itself_on_one_line() {
let states = [
CjkInstall::Unknown,
CjkInstall::NotNeeded,
CjkInstall::System {
family: "SimSun".to_owned(),
},
CjkInstall::Unprobeable,
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: false,
},
CjkInstall::Failed {
class: FetchClass::Integrity,
message: "sha256 不符".to_owned(),
},
];
for state in states {
let line = state.describe();
assert_eq!(line.lines().count(), 1, "{line}");
assert!(line.starts_with("cjk-install="), "{line}");
}
}
+125
View File
@@ -0,0 +1,125 @@
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
//!
//! ## 为什么拆出来
//!
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
//!
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
//!
//! ## 为什么下载在后台线程
//!
//! 一份 17 MiB 的字体在弱网下要几十秒,而两个触发点(启动编排、设置页切语言)都在 UI 线程上。
//! 在 UI 线程同步下完 = 窗口几十秒不响应(启动路径甚至是「窗口还没出来」)。
//!
//! ## 线程里做了什么(顺序即判据)
//!
//! ```text
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验)
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
//! ```
//!
//! ## 诚实边界
//!
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机)。
use unlucky_i18n::font::{self, Strategy};
use super::{ensure_for, update, CjkInstall, FetchClass};
use crate::fonts::cdn::CdnFetcher;
use crate::platform::data;
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
pub(super) fn start(font: &'static font::CjkFont) {
let font::Source::Pinned { bytes, .. } = font.source;
let Some(cache_root) = data::cache_root() else {
update(CjkInstall::Failed {
class: FetchClass::Disk,
message: "数据根解析不出来,字体无处可落".to_owned(),
});
return;
};
let fetcher = match CdnFetcher::new(font, cache_root) {
Ok(fetcher) => fetcher,
Err(error) => {
update(CjkInstall::Failed {
class: FetchClass::from(&error),
message: error.message().to_owned(),
});
return;
}
};
let cached = fetcher.has_cached();
update(CjkInstall::Downloading { bytes });
eprintln!(
"[font] cjk-download start family=\"{}\" bytes={bytes} from_cache={cached} file={}",
font.family,
fetcher.cache_file().display()
);
// 判据说「需要」,这条线程才存在:不需要的三种情形在 `after_probe` 就返回了。
std::thread::spawn(move || {
let state = ensure_for(
Strategy::NeedsDownload(font),
&fetcher,
&register_into_font_system,
);
let state = match state {
// 命中缓存的那一份要把 `from_cache` 标出来(状态行靠它区分「等了一会儿」与「真的下了一次」)。
CjkInstall::Ready { family, .. } => CjkInstall::Ready {
family,
from_cache: cached,
},
other => other,
};
match &state {
CjkInstall::Ready { family, from_cache } => eprintln!(
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} (中文字形现在由它兜底,界面不需要重启)"
),
CjkInstall::Failed { class, message } => eprintln!(
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
class.as_str()
),
other => eprintln!("[font] cjk-download {}", other.describe()),
}
update(state);
});
}
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
///
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
///
/// # Errors
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
let lock = iced::advanced::graphics::text::font_system();
let mut font_system = lock
.write()
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
font_system.load_font(std::borrow::Cow::Owned(bytes));
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
// 那个借用活过守卫(E0597)。
let families: Vec<String> = font_system
.raw()
.db()
.faces()
.flat_map(|face| face.families.iter())
.map(|(family, _)| family.clone())
.collect();
families
.iter()
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
.cloned()
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
}
@@ -46,6 +46,7 @@ use unlucky_design::typography::TypeRole;
use unlucky_i18n::catalog::Key;
use unlucky_i18n::LangChoice;
use crate::fonts::install::{CjkInstall, FetchClass};
use crate::pages::{frame, Language, Message};
use crate::theme::Ui;
@@ -71,6 +72,10 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let mut stacked = column![].spacing(0.0).width(Length::Fill);
stacked = stacked.push(language_row(ui, language));
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
stacked = stacked.push(divider(ui));
stacked = stacked.push(font_row(ui, language));
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
for (label, value) in [
@@ -85,6 +90,110 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
card(ui, stacked)
}
/// 中文字体状态行的**文案**(纯函数:状态 × 目录 → 一句话 + 可选的一句解释)。
///
/// 为什么单独拎出来:截图只能证明「画出来了」,而这里能被断言证明「四种状态的词都在、占位符都被
/// 替换、两套目录都没有 `⟪missing⟫`」(见 `tests/general_section.rs`)。
///
/// 失败态**不写具体错误串**:那是给我们看的(`[font] cjk-download failed` 那行日志),用户只需要
/// 知道「网络 / 内容 / 磁盘」里哪一类,以及「界面照常可用」。
pub(super) fn font_status(
catalog: &unlucky_i18n::catalog::Catalog,
status: &CjkInstall,
) -> (String, Option<String>) {
match status {
CjkInstall::Unknown => (catalog.text(Key::FontCjkStatusUnknown).to_owned(), None),
CjkInstall::Unprobeable => (catalog.text(Key::FontCjkStatusUnprobeable).to_owned(), None),
CjkInstall::NotNeeded => (catalog.text(Key::FontCjkStatusNotNeeded).to_owned(), None),
CjkInstall::System { family } => (
catalog.render(Key::FontCjkStatusSystem, &[("family", family)]),
None,
),
CjkInstall::Downloading { bytes } => (
catalog.render(
Key::FontCjkStatusDownloading,
&[("size", &format_size(*bytes))],
),
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
),
CjkInstall::Ready { family, .. } => (
catalog.render(Key::FontCjkReady, &[("family", family)]),
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
),
CjkInstall::Failed { class, .. } => {
let reason = catalog.text(match class {
FetchClass::Network => Key::FontCjkReasonNetwork,
FetchClass::Integrity => Key::FontCjkReasonIntegrity,
FetchClass::Disk => Key::FontCjkReasonDisk,
});
(
catalog.render(Key::FontCjkStatusFailed, &[("reason", reason)]),
None,
)
}
}
}
/// 字节数 → 人话体积(`17.0 MB`)。只用于「≈」级别的量,不追求精确位数。
fn format_size(bytes: u64) -> String {
const MIB: f64 = 1024.0 * 1024.0;
let mib = bytes as f64 / MIB;
if mib >= 1.0 {
format!("{mib:.1} MB")
} else {
format!("{:.0} KB", bytes as f64 / 1024.0)
}
}
/// 中文字体状态行:标签一行、状态一行(失败红、就绪主色)、需要时再补一句解释。
///
/// 为什么**竖排**而不是像主题那几行「标签左、取值右」:失败那句话是整句(「获取失败(网络不通)。
/// 界面照常可用…」),塞进右列会把它压成一行挤掉标签(实测截图就是一整行红字、标签消失)。
/// 语言行也是竖排(标题 + 选项 + 说明),这一行贴着它,观感一致。
fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
let catalog = language.effective.catalog();
let status = crate::fonts::install::status();
let (value, hint) = font_status(&catalog, &status);
let color = match status {
CjkInstall::Failed { .. } => ui.color(Role::Error),
CjkInstall::Ready { .. } | CjkInstall::System { .. } => ui.color(Role::Primary),
_ => ui.color(Role::OnSurfaceVariant),
};
let mut stacked = column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
catalog.text(Key::FontCjkStatusLabel),
ui.color(Role::OnSurface),
),
container(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
value,
color
))
.width(Length::Fill),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), 0.0]));
if let Some(hint) = hint {
stacked = stacked.push(container(frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
hint,
ui.color(Role::OnSurfaceVariant),
)));
}
stacked.into()
}
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
let catalog = language.effective.catalog();
@@ -35,6 +35,82 @@ fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
);
}
/// **M168(D28 后半)**:中文字体下载状态行的四种状态都得有话说,且两套目录都不许 `⟪missing⟫`。
///
/// 抓得住:①只画「下载成功」一种(失败的用户会以为程序坏了);②失败态的文案写成「保持英文」
/// (D28 的实际行为是**继续用系统字体**,界面语言不变);③占位符没被替换(用户会看到 `{family}`);
/// ④新 key 只进了英文表(中文界面上出现 `⟪missing⟫`)。
/// 抓不住:真下载时的样子(那要联网)、字体有没有真的出字(截图 + 真机,见报告)。
#[test]
fn the_font_status_row_speaks_in_four_states_and_two_languages() {
use crate::fonts::install::{CjkInstall, FetchClass};
let states = [
CjkInstall::Downloading { bytes: 17_772_300 },
CjkInstall::Ready {
family: "Noto Sans SC".to_owned(),
from_cache: true,
},
CjkInstall::Failed {
class: FetchClass::Network,
message: "timeout".to_owned(),
},
CjkInstall::System {
family: "Microsoft YaHei".to_owned(),
},
CjkInstall::NotNeeded,
CjkInstall::Unprobeable,
CjkInstall::Unknown,
];
for lang in Lang::ALL {
let catalog = lang.catalog();
assert!(
catalog.get(Key::FontCjkStatusLabel).is_some(),
"{} 目录里缺中文字体那一行的标签",
lang.tag()
);
for state in &states {
let (value, hint) = general::font_status(&catalog, state);
assert!(!value.is_empty(), "{state:?} 没话说");
assert!(!value.contains('{'), "{state:?} 占位符没替换:{value}");
assert!(
!value.contains(unlucky_i18n::catalog::MISSING),
"{state:?} 在 {} 目录里缺译:{value}",
lang.tag()
);
if let Some(hint) = hint {
assert!(!hint.contains('{'), "{state:?} 的提示没替换:{hint}");
}
}
}
// 失败态必须说清三类原因之一(而不是一句「失败了」)。
let zh = Lang::ZhCn.catalog();
for (class, reason) in [
(FetchClass::Network, "网络不通"),
(FetchClass::Integrity, "镜像内容校验不通过"),
(FetchClass::Disk, "写不进数据目录"),
] {
let (value, _) = general::font_status(
&zh,
&CjkInstall::Failed {
class,
message: "x".to_owned(),
},
);
assert!(value.contains(reason), "失败原因没进文案:{value}");
}
// 下载中写的是**真实**字节数(17.0 MB),不是假百分比。
let (value, _) = general::font_status(&zh, &CjkInstall::Downloading { bytes: 17_772_300 });
assert!(
value.contains("16.9 MB"),
"17 772 300 B ≈ 16.9 MiB:{value}"
);
assert!(!value.contains('%'), "没有真进度就不许写百分比:{value}");
}
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
#[test]
fn the_general_group_keeps_its_icon() {
+111
View File
@@ -0,0 +1,111 @@
//! 数据根派生:**字体缓存放哪** + **一条设置键读什么**(M168)。
//!
//! ## 为什么需要它
//!
//! 字体下载要把 17 MiB 落在数据根里(`<launcher_home>/cache/fonts/`),而「数据根在哪」这件事
//! 已经有一个真源:`unlucky_presenter::settings_bridge::SettingsBridge::open()`(三模式数据根 +
//! `core.*` 覆盖键 + 便携回落,见那边的 `resolve_config_path`)。本模块**不重写**那套解析,
//! 只做两件它没暴露的事:
//!
//! 1. 由配置文件路径 `<home>/config/launcher.json` 反推 `<home>`,再用 [`LauncherLayout::cache`]
//! 取缓存分区(分区名只从 core 的 `layout` 常量来,不手拼字符串);
//! 2. 读一条**设置键**的生效值(`ui.font_cdn`):`SettingsBridge` 只暴露了 `ui.language` 的读写,
//! 而 `ui.font_cdn` 是 M168 新增的键。
//!
//! ## 诚实边界
//!
//! - **抓得住**:路径推导的形状(`<home>/config/x` → `<home>/cache`)与「配置读不到时返回 `None`
//! 而不是猜一个目录」;
//! - **抓不住**:真机上 `SettingsBridge::open()` 解析出哪个根(那是 presenter + core 的用例),
//! 以及 macOS `.app` 只读这类平台差异(没有真机)。
//!
//! ## 后续收口(已登记 finding)
//!
//! 更干净的做法是让 presenter 暴露一个通用的 `SettingsBridge::load_text(key)`,本模块就只剩
//! 「布局 + 一条键」。presenter 不在 M168 的 scope 里,所以这里先按上面的方式落地。
use std::path::PathBuf;
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::setting::document::SettingDocument;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use unlucky_presenter::settings_bridge::SettingsBridge;
/// 中文字体下载地址的覆盖键(M168;空 = 用内置镜像)。
pub const FONT_CDN_KEY: &str = "ui.font_cdn";
/// 本进程的数据根布局(`None` = 数据根解析不出来,例如 exe 目录都拿不到)。
///
/// 解析只发生一次:配方来自 `SettingsBridge::open()`(它内部走 `DataDirRequest` + `Resolver`)。
pub fn layout() -> Option<LauncherLayout> {
static LAYOUT: std::sync::OnceLock<Option<LauncherLayout>> = std::sync::OnceLock::new();
LAYOUT
.get_or_init(|| {
let config = SettingsBridge::open().path()?.to_path_buf();
// `<home>/config/launcher.json` → `<home>`
let home = config.parent()?.parent()?.to_path_buf();
Some(LauncherLayout::new(home))
})
.clone()
}
/// 字体缓存的根(`<cache>/fonts/` 的父目录;**不**等于字体目录本身)。
///
/// 用 `cache/` 而不是 `data/`:字体随时可删、删了会自动重下(D28 的「数据根」语义里,
/// 这就是「可重建」的那一类)。
pub fn cache_root() -> Option<PathBuf> {
layout().map(|layout| layout.cache())
}
/// 读一条设置键的**生效值**(schema 默认值也算;空串会被 `trim` 掉但仍是 `Some`)。
///
/// `None` = 配置读不到(数据根解析失败 / 文件损坏到 `SettingStore` 都装不起来)。
pub fn setting_text(key: &str) -> Option<String> {
let path = SettingsBridge::open().path()?.to_path_buf();
let schema = launcher_schema();
let store = SettingStore::load(&path, schema);
let document: &SettingDocument = store.doc();
document.effective_str(key).map(str::to_owned)
}
/// `ui.font_cdn` 的覆盖值(`None` = 没配 / 配了空串 ⇒ 用内置镜像)。
pub fn font_cdn_override() -> Option<String> {
let raw = setting_text(FONT_CDN_KEY)?;
let trimmed = raw.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
/// 键名与 core 的 schema 一致:写错一个字符,用户配了也不生效(而 UI 不会报错)。
///
/// 抓得住:本模块的常量与 `core::setting::keys` 里登记的键名对不上。
/// 抓不住:真机上读到的值(那要数据根与配置文件)。
#[test]
fn the_cdn_key_is_registered_in_the_launcher_schema() {
let schema = launcher_schema();
assert!(
schema.spec(FONT_CDN_KEY).is_some(),
"{FONT_CDN_KEY} 没在 core 的 schema 里登记"
);
}
/// 布局推导的形状:`<home>/config/launcher.json` → `<home>/cache`。
///
/// 抓得住:有人把「上两级」写成「上一级」(缓存会落进 `config/` 里),或改用 `data/` 分区。
/// 抓不住:真机上 `<home>` 解析成什么(presenter 的 `resolve_config_path` 管那一层)。
#[test]
fn the_cache_partition_is_the_sibling_of_config() {
let config = PathBuf::from("/tmp/ull-home/config/launcher.json");
let home = config.parent().unwrap().parent().unwrap().to_path_buf();
let layout = LauncherLayout::new(home);
assert_eq!(layout.cache(), PathBuf::from("/tmp/ull-home/cache"));
assert_eq!(
layout.cache().join("fonts"),
PathBuf::from("/tmp/ull-home/cache/fonts")
);
}
}
+1
View File
@@ -18,6 +18,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend;
pub mod data;
pub mod locale;
pub mod win32;