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` 干净。截图三张见报告。
172 lines
8.2 KiB
Rust
172 lines
8.2 KiB
Rust
//! 平台相关的窗口能力(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()));
|
||
}
|
||
}
|
||
}
|