feat(appearance): M91 —— 外观下层三键 + win32 整窗 α FFI + unsafe 落点门禁

core(D36/D37/D38 的键与范围)
- appearance.window_opacity(0.08..=1.0,默认 1.0,越界钳位且不报错;下限 D36 定案)
- appearance.titlebar_mode(auto|always|never,默认 auto;判定逻辑不在本 mission)
- appearance.palette_source(background-image|wallpaper|preset,与 design 的 Level::code() 同名)
- schema 1.3.0 → 1.4.0:纯增量、不加迁移步骤(新键出厂值 = 老文件缺键)
- appearance 拆成目录 mod/modes/tests(单文件 ≤400 行),非法枚举由 diagnostics() 给可读回落说明

presenter(只读语义,不破 M89 零 UI 门禁)
- AppearanceBase { window_opacity, titlebar_mode, palette_source } + from_document()
- 单测:缺键→出厂值、越界→钳位、非法枚举→回落且合法值照读

app-iced(D36 唯一 unsafe 落点)
- platform::effective_window_alpha(backend, requested) 纯函数:≤0/非有限 → None(拒绝整窗穿透),
  其余夹到 0.08..=1.0;CPU 档同样允许窗口级 α(D36 对 D27 的修订),4 组真值表测试
- platform/win32/transparency.rs:SetLayeredWindowAttributes(LWA_ALPHA) 的 FFI +
  alpha_byte 量化 + apply_window_alpha;非 Windows 同签名空实现返回 Unsupported

CI
- guards.yml 新增 unsafe-scope job:全仓 allow(unsafe_code) 文件集合必须恰好等于
  crates/app-iced/src/platform/win32/transparency.rs(集合相等,不是黑名单命中)

版本号绊线
- isolation_keys / java_install_keys:字面量 1.3.0 → 1.4.0(绊线保留字面量)
- store / document 的两个关系夹具改成相对 launcher_version() 构造(「更高 minor 只读」/
  「同 minor 更高 patch 保留 $schema」),下次 bump 不再假绿或假红
This commit is contained in:
AskaEth committed 2026-11-02 13:58:33 +08:00
1 parent 006eb6869a
commit 7406785325
15 files changed
+902 -18

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
//! 平台相关的窗口能力(P4 / D36):整窗透明度的**判据**与唯一 `unsafe` 落点分居两处。
//!
//! - 本文件 = 纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(`SetLayeredWindowAttributes`)。
//!
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
//! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示
//! 适配器)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 + 没测试的观感」这条
//! 诚实的分界。
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
use crate::Backend;
pub mod win32;
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
///
/// - `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));
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Win32 专属的窗口能力。
//!
//! 目前只有一个 [`transparency`]:D36 定案的整窗 α 唯一实现落点。
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
//!
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
//! 所以调用点不需要 `#[cfg]`,行为(返回「本平台不支持」)可被测试断言。
pub mod transparency;
@@ -0,0 +1,187 @@
//! 整窗透明度(D36):**全仓唯一**允许 `unsafe` 的文件。
//!
//! ## 为什么只有这里有 unsafe
//!
//! 窗口级 α 没有任何「安全」的 Rust API 可达:`SetLayeredWindowAttributes` 是 Win32 的
//! 唯一路径(D36 逐路核实过 wgpu / iced / winit 都没有代理入口)。所以把**整个仓库**的
//! FFI 面收在这一处,其余一层([`super::super::effective_window_alpha`])保持纯函数。
//! 守门在 `.github/workflows/guards.yml` 的 `unsafe-scope` job:全仓带这个 allow 的文件集合
//! 必须**恰好等于**本文件的路径(集合相等,不是黑名单命中)。
//!
//! ## 只做 LWA_ALPHA
//!
//! `LWA_COLORKEY` / `DwmEnableBlurBehindWindow` 一律不用:色键会把某个颜色整块变透明(视觉
//! 事故),模糊是另一条路线(D36 已排除)。`WS_EX_LAYERED` 是调用前提,由窗口创建侧设置,
//! 本文件不负责——它只做「给定句柄,把 α 下发下去」。
//!
//! ## 非 Windows
//!
//! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`,
//! 而「这个平台不支持」这件事本身可以被断言。
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
#![allow(unsafe_code)]
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
///
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
pub const LWA_ALPHA: u32 = 0x0000_0002;
/// 一次整窗 α 应用的结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaOutcome {
/// α ≥ 1.0:完全不透明,**不调 FFI**(也就不需要分层窗口)。
Opaque,
/// 已下发,`byte` 是 Win32 收到的 `0..=255`。
Applied {
/// 量化后的字节值。
byte: u8,
},
/// 拒绝执行。
Rejected(AlphaRejection),
/// 非 Windows 平台:空实现,什么都没做。
Unsupported,
/// FFI 自己失败(如句柄无效),`code` 来自 `GetLastError`。
Failed {
/// Win32 错误码(`1400` = `ERROR_INVALID_WINDOW_HANDLE`)。
code: u32,
},
}
/// [`AlphaOutcome::Rejected`] 的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlphaRejection {
/// 没有可用的 α(`None` / 非正 / 非有限)——整窗穿透的拒绝在这里拦住。
NoAlpha,
/// 句柄为 0:还没拿到窗口,或窗口已销毁。
NoWindow,
}
/// α → Win32 的 `bAlpha`(`0..=255`)。
///
/// 输入先夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`(NaN 取下限):调用方即使漏了
/// [`super::super::effective_window_alpha`] 的过滤,也不会把窗口写成穿透。
#[must_use]
pub fn alpha_byte(alpha: f64) -> u8 {
let clamped = if alpha.is_nan() {
WINDOW_ALPHA_MIN
} else {
alpha.clamp(WINDOW_ALPHA_MIN, 1.0)
};
(clamped * 255.0).round() as u8
}
/// 把整窗 α 下发到窗口。
///
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
pub fn apply_window_alpha(hwnd: isize, alpha: Option<f64>) -> AlphaOutcome {
let Some(alpha) = alpha.filter(|value| value.is_finite() && *value > 0.0) else {
return AlphaOutcome::Rejected(AlphaRejection::NoAlpha);
};
if alpha >= 1.0 {
return AlphaOutcome::Opaque;
}
if hwnd == 0 {
return AlphaOutcome::Rejected(AlphaRejection::NoWindow);
}
set_layered(hwnd, alpha_byte(alpha))
}
#[cfg(target_os = "windows")]
#[link(name = "user32")]
extern "system" {
/// `SetLayeredWindowAttributes`:窗口必须已带 `WS_EX_LAYERED`,α 才生效。
///
/// 签名照 Win32 头文件:`BOOL SetLayeredWindowAttributes(HWND, COLORREF, BYTE, DWORD)`。
fn SetLayeredWindowAttributes(hwnd: isize, color_key: u32, alpha: u8, flags: u32) -> i32;
}
#[cfg(target_os = "windows")]
fn set_layered(hwnd: isize, byte: u8) -> AlphaOutcome {
// SAFETY: 四个参数都是按值传入的 POD;`flags` 只带 LWA_ALPHA,于是系统忽略 `color_key`
// (传 0 与之无关)。句柄由调用方给出:无效句柄只会让 Win32 返回 0(走下面的 Failed 分支),
// 不会造成未定义行为——本调用不持有指针,也没有释放语义。
let ok = unsafe { SetLayeredWindowAttributes(hwnd, 0, byte, LWA_ALPHA) };
if ok == 0 {
let code = std::io::Error::last_os_error()
.raw_os_error()
.unwrap_or(0)
.unsigned_abs();
return AlphaOutcome::Failed { code };
}
AlphaOutcome::Applied { byte }
}
#[cfg(not(target_os = "windows"))]
fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
AlphaOutcome::Unsupported
}
#[cfg(test)]
mod tests {
use super::{alpha_byte, apply_window_alpha, AlphaOutcome, AlphaRejection, LWA_ALPHA};
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
#[test]
fn alpha_byte_quantizes_and_clamps() {
assert_eq!(alpha_byte(1.0), 255);
assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 四舍五入 128");
assert_eq!(
alpha_byte(WINDOW_ALPHA_MIN),
20,
"D36 下限 0.08×255=20.4 → 20(字节值不许是 0)"
);
// 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。
for bad in [0.0, -3.0, f64::NAN] {
assert_eq!(
alpha_byte(bad),
alpha_byte(WINDOW_ALPHA_MIN),
"{bad} 应取下限"
);
}
assert_eq!(
LWA_ALPHA, 0x2,
"0x2 就是 LWA_ALPHA;写成 0x1 会变成色键、等于没设 α"
);
}
#[test]
fn missing_or_opaque_alpha_never_touches_the_window() {
for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] {
assert_eq!(
apply_window_alpha(1234, none),
AlphaOutcome::Rejected(AlphaRejection::NoAlpha),
"{none:?} 应被拒绝(整窗穿透不许发生)"
);
}
// α = 1.0:两个平台都返回 Opaque —— 正好证明「不调 FFI」不是靠 Unsupported 混过去的。
assert_eq!(apply_window_alpha(1234, Some(1.0)), AlphaOutcome::Opaque);
// 合法 α 但没有句柄:拒绝,绝不下发。
assert_eq!(
apply_window_alpha(0, Some(0.5)),
AlphaOutcome::Rejected(AlphaRejection::NoWindow)
);
}
#[cfg(target_os = "windows")]
#[test]
fn invalid_handle_reports_a_win32_failure_instead_of_panicking() {
// 用 1 号句柄(永远无效)真调一次 API:验的是**返回值语义**,不是观感。
match apply_window_alpha(1, Some(0.5)) {
AlphaOutcome::Failed { code } => {
assert_eq!(code, 1400, "ERROR_INVALID_WINDOW_HANDLE")
}
other => panic!("无效句柄应报 Failed,实际 {other:?}"),
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn non_windows_stub_reports_unsupported() {
assert_eq!(apply_window_alpha(1, Some(0.5)), AlphaOutcome::Unsupported);
}
}