Files
Unlucky-Launcher/crates/app-iced/src/platform/mod.rs
T
AskaEth 449ded2cb7 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` 干净。截图三张见报告。
2026-11-08 14:38:23 +08:00

172 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点,
//! 以及系统语言真值源,三件事分居三处。
//!
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 →
//! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明;
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` +
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
//!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
//! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示
//! 适配器)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 + 没测试的观感」这条
//! 诚实的分界。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend;
pub mod data;
pub mod locale;
pub mod win32;
/// 窗口圆角的**判定**(M116)住在 `win32::corner`:判据是 Windows 版本与 DWM 能力,
/// 非 Windows 平台在这里恒得「直角」(平台没有可承诺的系统圆角)。
pub use win32::corner::{plan_corner, CornerExclusion, CornerPlan, CornerSource};
/// 本机 Windows 版本(`None` = 非 Windows / 读不到)。**进程内只探测一次**。
///
/// 为什么缓存:底层是 `reg.exe` 子进程(`unlucky_core::platform::windows_version`,实测约 50 ms),
/// 而圆角判定会在启动、切档、热重载时各问一次。探测结果在进程生命周期内不会变。
///
/// 为什么走 `unlucky_presenter::prefs` 的重导出而不是 `unlucky_core` 直连:
/// `unlucky-app-iced` 不直接依赖 core(分层:UI → presenter → core),这是既有的唯一通道。
#[must_use]
pub fn current_windows_version() -> Option<unlucky_presenter::prefs::WindowsVersion> {
static CACHED: std::sync::OnceLock<Option<unlucky_presenter::prefs::WindowsVersion>> =
std::sync::OnceLock::new();
*CACHED.get_or_init(unlucky_presenter::prefs::probe_windows_version)
}
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
///
/// - `requested <= 0.0`(含 `-0.0`)→ `None`:**拒绝整窗穿透**。α = 0 表示窗口内容全透明,
/// 用户会以为程序消失/卡死,所以这不是「合法的最小值」而是「不给设」(D36)。
/// 非有限值(`NaN` / `±∞`)同样 `None`——它们没有合法落点,也不该被 `clamp` 悄悄吞掉。
/// - 其余一律夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`。
/// - 后端**不**参与「能不能用窗口级 α」的判断:D36 **修订**了 D27 的旧结论——
/// CPU 渲染档同样允许整窗 α(CPU 档被限制的是面板 α / 玻璃 / 模糊,不是窗口 α)。
/// 因此三个后端分支给出同一个值;写成 `match` 是为了让「将来真分档」只改这一处,
/// 而不是让读代码的人以为这里漏了一个分支。
#[must_use]
pub fn effective_window_alpha(backend: Backend, requested: f64) -> Option<f64> {
if !requested.is_finite() || requested <= 0.0 {
return None;
}
match backend {
Backend::Gpu | Backend::Cpu | Backend::Auto => Some(requested.clamp(WINDOW_ALPHA_MIN, 1.0)),
}
}
#[cfg(test)]
mod tests {
use super::effective_window_alpha;
use crate::Backend;
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
const BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto];
#[test]
fn truth_table_valid_alpha_passes_through_every_backend() {
// GPU / CPU × 合法值:四个格子都要 Some(CPU 档不再强制不透明)。
for backend in BACKENDS {
assert_eq!(
effective_window_alpha(backend, 0.5),
Some(0.5),
"{backend:?}:合法 α 原样返回"
);
assert_eq!(effective_window_alpha(backend, 1.0), Some(1.0));
assert_eq!(
effective_window_alpha(backend, WINDOW_ALPHA_MIN),
Some(WINDOW_ALPHA_MIN)
);
}
}
#[test]
fn truth_table_out_of_range_is_clamped_not_refused() {
for backend in BACKENDS {
assert_eq!(
effective_window_alpha(backend, 0.001),
Some(WINDOW_ALPHA_MIN),
"{backend:?}:正数低于下限 → 夹到 0.08(钳位,不拒绝)"
);
assert_eq!(
effective_window_alpha(backend, 9.0),
Some(1.0),
"{backend:?}:高于 1.0 → 夹到 1.0"
);
}
}
#[test]
fn truth_table_non_positive_is_refused_on_every_backend() {
for backend in BACKENDS {
for requested in [0.0, -0.0, -0.01, -1.0] {
assert_eq!(
effective_window_alpha(backend, requested),
None,
"{backend:?}:requested={requested} → 拒绝整窗穿透"
);
}
}
}
#[test]
fn truth_table_non_finite_is_refused() {
// NaN 用 `<= 0.0` 判不出来(比较恒 false),所以必须先 `is_finite()`;
// 否则 NaN 会穿到 `clamp` 里再原样返回,变成「给窗口一个 NaN」。
for backend in BACKENDS {
for requested in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(
effective_window_alpha(backend, requested),
None,
"{backend:?}:requested={requested} 不是有限值"
);
}
}
}
#[test]
fn cpu_tier_is_not_forced_opaque() {
// D36 对 D27 的**修订**:旧文档写「CPU 档一律不透明」,这条测试钉住修订后的行为,
// 防止谁照着旧文档把 CPU 分支改回 `None`。
assert_eq!(effective_window_alpha(Backend::Cpu, 0.5), Some(0.5));
assert_eq!(effective_window_alpha(Backend::Gpu, 0.5), Some(0.5));
}
/// `appearance.palette_source` 的码值必须与 `design` 的 `Level::code()` 逐字一致。
///
/// 为什么这条断言住在这里:`unlucky-core` 按分层纪律不依赖 `unlucky-design`,
/// 它的测试**看不到** design,所以 core 自己只能钉字面量。本 crate 同时依赖
/// `unlucky-design`(`Level`)与 `unlucky-presenter`(重导出 `PaletteSource`),
/// 是全仓唯一能真做这次对照的地方。
///
/// 抓得住:design 侧改任一档的稳定码(例如把 `wallpaper` 改成 `desktop`)、或有人改了
/// core 的字符串表——两个方向都红(下面往返那条覆盖「只改一侧」)。
/// 抓不住:档位的**语义**是否仍对齐(只保证字符串一致),以及 UI 是否真按这个码值取色。
#[test]
fn palette_source_codes_match_design_level_codes() {
use unlucky_design::palette::Level;
use unlucky_presenter::prefs::PaletteSource;
for (source, level) in [
(PaletteSource::BackgroundImage, Level::BackgroundImage),
(PaletteSource::Wallpaper, Level::Wallpaper),
(PaletteSource::Preset, Level::Preset),
] {
assert_eq!(
source.as_str(),
level.code(),
"core 的 {source:?} 码值与 design 的 {level:?} 不一致"
);
// 反方向:design 的码值喂回 core 必须还原成同一档(防「两个字符串互换了」)。
assert_eq!(PaletteSource::from_key_value(level.code()), source);
// 且必须真的在 core 的允许值表里(否则 schema 会拒绝写这个值)。
assert!(unlucky_presenter::prefs::PALETTE_SOURCES.contains(&level.code()));
}
}
}