From 7406785325b3b5d9267bd8e29b38383d5ac85954 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 13:24:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(appearance):=20M91=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=A4=96=E8=A7=82=E4=B8=8B=E5=B1=82=E4=B8=89?= =?UTF-8?q?=E9=94=AE=20+=20win32=20=E6=95=B4=E7=AA=97=20=CE=B1=20FFI=20+?= =?UTF-8?q?=20unsafe=20=E8=90=BD=E7=82=B9=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 不再假绿或假红 --- .github/workflows/guards.yml | 43 ++++ crates/app-iced/src/lib.rs | 2 + crates/app-iced/src/platform/mod.rs | 114 +++++++++++ crates/app-iced/src/platform/win32/mod.rs | 10 + .../src/platform/win32/transparency.rs | 187 ++++++++++++++++++ .../{appearance.rs => appearance/mod.rs} | 65 ++++++ crates/core/src/setting/appearance/modes.rs | 152 ++++++++++++++ crates/core/src/setting/appearance/tests.rs | 175 ++++++++++++++++ crates/core/src/setting/keys.rs | 28 ++- crates/core/tests/setting/appearance_keys.rs | 2 +- crates/core/tests/setting/document.rs | 5 +- crates/core/tests/setting/isolation_keys.rs | 4 +- .../core/tests/setting/java_install_keys.rs | 2 +- crates/core/tests/setting/store.rs | 17 +- crates/presenter/src/prefs.rs | 114 ++++++++++- 15 files changed, 902 insertions(+), 18 deletions(-) create mode 100644 crates/app-iced/src/platform/mod.rs create mode 100644 crates/app-iced/src/platform/win32/mod.rs create mode 100644 crates/app-iced/src/platform/win32/transparency.rs rename crates/core/src/setting/{appearance.rs => appearance/mod.rs} (72%) create mode 100644 crates/core/src/setting/appearance/modes.rs create mode 100644 crates/core/src/setting/appearance/tests.rs diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml index eebb723..8d963e0 100644 --- a/.github/workflows/guards.yml +++ b/.github/workflows/guards.yml @@ -6,6 +6,7 @@ name: Guards # 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身 # 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) # 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 +# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91) on: push: @@ -149,6 +150,48 @@ jobs: fi echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" + # D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的 + # `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。 + # 判据必须是**集合相等**而不是「黑名单命中」: + # - 新增第二个带 allow(unsafe_code) 的文件 → 红(黑名单写法在这里会假绿); + # - 落点文件把 allow 删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); + # - 落点文件里多开别的 unsafe 能力 → 门禁看不见(这是本守门的已知边界, + # workspace 的 `unsafe_code = "deny"` 也只是按文件放行,逐行审计靠 code review)。 + unsafe-scope: + name: unsafe 落点守门(集合必须恰好一个文件) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 全仓 allow(unsafe_code) 的文件集合 == 唯一落点 + run: | + set -uo pipefail + EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' + # 只认「行首就是属性」的写法,并允许与其他 lint 混写: + # #[allow(unsafe_code)] #![allow(unsafe_code)] + # #[allow(dead_code, unsafe_code)] #[cfg_attr(windows, allow(unsafe_code))] + # 文档/注释里提到这个属性**不算**(crates/app/src/main.rs 的说明文字就提到过它, + # 用无锚定的 `grep -r allow(unsafe_code)` 会把它当越线)。 + ATTR='^[[:space:]]*#!?\[(.*,)?[[:space:]]*allow\([^]]*unsafe_code[^]]*\)\]' + actual=$(git ls-files '*.rs' | while IFS= read -r f; do + if grep -qE "$ATTR" "$f"; then printf '%s\n' "$f"; fi + done | LC_ALL=C sort) + echo "期望(恰好这一个):$EXPECTED" + echo "实际扫到的集合:" + if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi + if [ "$actual" != "$EXPECTED" ]; then + extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true) + if [ -n "$extra" ]; then + echo "::error::多出来的 unsafe 落点(D36 只允许一个):" + printf '%s\n' "$extra" | sed 's/^/ - /' + fi + if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then + echo "::error file=$EXPECTED::这个文件里没有 allow(unsafe_code) —— 落点被搬走/改名/删了 allow?守门路径已失效。" + fi + echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手 allow。" + exit 1 + fi + echo "OK: 全仓 allow(unsafe_code) 恰好只有 $EXPECTED" + file-size: name: 文件行数 ≤400 守门 runs-on: ubuntu-latest diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index 56e26b2..66f6ac8 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -12,6 +12,7 @@ //! | `app` / `shell` | Elm 三件套 + 自绘标题栏 / 导航轨 / 内容骨架 | 业务逻辑 | //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | +//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI —— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 | //! //! ## 消费纪律 //! @@ -25,6 +26,7 @@ //! `DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md`。 pub mod pages; +pub mod platform; pub mod shot; pub mod theme; diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs new file mode 100644 index 0000000..8243228 --- /dev/null +++ b/crates/app-iced/src/platform/mod.rs @@ -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 { + 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)); + } +} diff --git a/crates/app-iced/src/platform/win32/mod.rs b/crates/app-iced/src/platform/win32/mod.rs new file mode 100644 index 0000000..68e9b83 --- /dev/null +++ b/crates/app-iced/src/platform/win32/mod.rs @@ -0,0 +1,10 @@ +//! Win32 专属的窗口能力。 +//! +//! 目前只有一个 [`transparency`]:D36 定案的整窗 α 唯一实现落点。 +//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见: +//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。 +//! +//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现, +//! 所以调用点不需要 `#[cfg]`,行为(返回「本平台不支持」)可被测试断言。 + +pub mod transparency; diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs new file mode 100644 index 0000000..348b30e --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -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) -> 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); + } +} diff --git a/crates/core/src/setting/appearance.rs b/crates/core/src/setting/appearance/mod.rs similarity index 72% rename from crates/core/src/setting/appearance.rs rename to crates/core/src/setting/appearance/mod.rs index 704c3ac..03c8f15 100644 --- a/crates/core/src/setting/appearance.rs +++ b/crates/core/src/setting/appearance/mod.rs @@ -7,12 +7,31 @@ //! 设计约束(09 §111):一切高级特性**默认关闭**(出厂 Metro 纯色);全部走 D6 schema //! (AI 可写白名单内,可被 agent 调节)。因此默认值必须等于「什么都没开」的出厂态: //! `panel_alpha = 1.0`、`glass = false`、`bg_image_enabled = false`、`accent` 空串(= 出厂 accent)。 +//! +//! P4 追加的三个「下层键」(M91 / D34·D36·D37·D38,同样是**出厂即可见**的默认值): +//! +//! | 键 | 取值 | 出厂 | 本模块负责 / 不负责 | +//! |---|---|---|---| +//! | `appearance.window_opacity` | `0.08..=1.0` | `1.0` | 负责钳位;**不**调 FFI(落点在 `app-iced` 的 win32 层) | +//! | `appearance.titlebar_mode` | `auto` / `always` / `never` | `auto` | 只存值;`auto` 的判定属于 UI 侧(WCHROME-IMPL) | +//! | `appearance.palette_source` | `background-image` / `wallpaper` / `preset` | `preset` | 只存档位;三级编排在 `unlucky-design`(D38) | +//! +//! 非法枚举**不报错**但也**不静默**:[`diagnostics`] 给出「读到什么 → 生效什么」的可读说明。 use serde_json::Value; use super::document::SettingDocument; use super::store::SettingStore; +mod modes; + +pub use modes::{ + diagnostics, AppearanceDiagnostic, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES, +}; + +#[cfg(test)] +mod tests; + /// `appearance.bg_fill` 的允许值(09 §101:cover / contain / tile)。**顺序即 UI 分段控件的顺序**。 pub const BG_FILLS: &[&str] = &["cover", "contain", "tile"]; @@ -26,6 +45,13 @@ pub const BG_DIM_MIN: f64 = 0.0; /// `appearance.bg_dim` 上限:再高背景图基本看不见,等于关掉。 pub const BG_DIM_MAX: f64 = 0.95; +/// `appearance.window_opacity` 下限(D36 定案 0.08):这是**整窗** α 的硬底。 +/// +/// 为什么不是 0:α = 0 时窗口内容整体不可见 = 整窗穿透,用户会以为程序卡死/消失了。 +/// 0.08 是「几乎看不见但仍能点到」的最后一档,比它更低不给设置。面板 α([`PANEL_ALPHA_MIN`]) +/// 是另一回事,两者互不推导。 +pub const WINDOW_ALPHA_MIN: f64 = 0.08; + /// 背景图填充模式。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum BgFill { @@ -79,6 +105,15 @@ pub struct Appearance { pub glass: bool, /// 自定义强调色 `#RRGGBB`(空串 = 出厂 [`DEFAULT_ACCENT`])。 pub accent_color: String, + /// 整窗透明度 [`WINDOW_ALPHA_MIN`]~1.0(1.0 = 出厂完全不透明;D36)。 + /// + /// 只管取值:真正的窗口 α 由 `unlucky-app-iced` 的 win32 层调 + /// `SetLayeredWindowAttributes(LWA_ALPHA)` 落地,core 不碰 FFI。 + pub window_opacity: f64, + /// 标题栏三态(D37)。`auto` 怎么判定属于 UI 侧,core 只存用户的选择。 + pub titlebar_mode: TitlebarMode, + /// 配色来源档(D38 三级;默认 [`PaletteSource::Preset`] = 内置色卡)。 + pub palette_source: PaletteSource, } impl Default for Appearance { @@ -92,6 +127,9 @@ impl Default for Appearance { panel_alpha: 1.0, glass: false, accent_color: String::new(), + window_opacity: 1.0, + titlebar_mode: TitlebarMode::Auto, + palette_source: PaletteSource::Preset, } } } @@ -120,6 +158,18 @@ impl Appearance { accent_color: text("appearance.accent_color") .map(|value| normalize_hex(&value).unwrap_or_default()) .unwrap_or_default(), + window_opacity: number("appearance.window_opacity") + .map_or(default.window_opacity, |value| { + clamp(value, WINDOW_ALPHA_MIN, 1.0) + }), + titlebar_mode: text("appearance.titlebar_mode") + .map_or(default.titlebar_mode, |value| { + TitlebarMode::from_key_value(&value) + }), + palette_source: text("appearance.palette_source") + .map_or(default.palette_source, |value| { + PaletteSource::from_key_value(&value) + }), } } @@ -176,6 +226,21 @@ impl Appearance { "appearance.accent_color", Value::from(self.accent_color.as_str()), )?; + // 越界钳位而不是报错(D36):α 的下限是硬底,不许把窗口写到穿透; + // 上限 1.0 = 完全不透明。非法枚举(titlebar_mode / palette_source)由 + // `as_str()` 保证永远是合法字面量,不会写坏文件。 + store.set( + "appearance.window_opacity", + Value::from(clamp(self.window_opacity, WINDOW_ALPHA_MIN, 1.0)), + )?; + store.set( + "appearance.titlebar_mode", + Value::from(self.titlebar_mode.as_str()), + )?; + store.set( + "appearance.palette_source", + Value::from(self.palette_source.as_str()), + )?; Ok(()) } } diff --git a/crates/core/src/setting/appearance/modes.rs b/crates/core/src/setting/appearance/modes.rs new file mode 100644 index 0000000..43d9d15 --- /dev/null +++ b/crates/core/src/setting/appearance/modes.rs @@ -0,0 +1,152 @@ +//! 外观三键的取值表与「非法值回落」诊断(D36 / D37 / D38)。 +//! +//! 与 [`super`] 拆开只为单文件 ≤400 行的纪律:这里是纯字面量表 + 纯判据, +//! 不碰文档读写。对外路径仍是 `setting::appearance::*`([`super`] 逐个重导出)。 + +use serde_json::Value; + +use crate::setting::document::SettingDocument; + +/// `appearance.titlebar_mode` 的允许值(D37 三态)。**顺序即设置页分段控件的顺序**。 +pub const TITLEBAR_MODES: &[&str] = &["auto", "always", "never"]; + +/// 标题栏三态(D37)。 +/// +/// 只持有「用户想要哪种」,**不含** `auto` 的判定 —— 「非 Wayland → 显示 / 合成器名精确命中 +/// → 隐藏 / 旁证 / 兜底显示」那套顺序属于 UI 侧(WCHROME-IMPL),core 不探测环境。 +/// 特别地:判定不许用 `is_decorated()`,那条纪律与本文档无关,别在这里实现。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TitlebarMode { + /// 按平台/合成器自动决定(默认;由 UI 侧判定) + #[default] + Auto, + /// 永远显示自绘标题栏 + Always, + /// 永远不显示(合成器已给服务端装饰时) + Never, +} + +impl TitlebarMode { + /// 落盘取值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } + + /// 从落盘取值解析;未知值按 `auto` 兜底(手改坏配置也不崩, + /// 回落原因由 [`diagnostics`] 给出可读诊断)。 + #[must_use] + pub fn from_key_value(value: &str) -> Self { + match value { + "always" => Self::Always, + "never" => Self::Never, + _ => Self::Auto, + } + } +} + +/// `appearance.palette_source` 的允许值:D38 的三级配色来源。 +/// +/// **取值与 `unlucky_design::palette::Level::code()` 同名同值**(`background-image` / +/// `wallpaper` / `preset`)。这里抄字面量而不是引用 design:`core` 不许反过来依赖 UI 侧 +/// (分层纪律,D3),一致性由 `tests` 里的字面量断言固定 —— 与 `JDK_VENDORS` 的处理同一条路子。 +pub const PALETTE_SOURCES: &[&str] = &["background-image", "wallpaper", "preset"]; + +/// 配色来源档(D38:背景图取色 > 桌面壁纸取色 > 内置色卡)。 +/// +/// 这个键**不是**优先级链本身(链在 `design::palette::resolve` 里),而是「当前生效的档」 +/// 与「用户/调用方希望的最强来源」的登记位:默认 [`Self::Preset`] 对应莫奈取色关闭的出厂态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PaletteSource { + /// ① 启动器背景图取色 + BackgroundImage, + /// ② 桌面壁纸取色 + Wallpaper, + /// ③ 内置色卡(默认:出厂不取色) + #[default] + Preset, +} + +impl PaletteSource { + /// 落盘取值(与 design 侧 `Level::code()` 逐字一致)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::BackgroundImage => "background-image", + Self::Wallpaper => "wallpaper", + Self::Preset => "preset", + } + } + + /// 从落盘取值解析;未知值按 `preset`(内置色卡)兜底。 + #[must_use] + pub fn from_key_value(value: &str) -> Self { + match value { + "background-image" => Self::BackgroundImage, + "wallpaper" => Self::Wallpaper, + _ => Self::Preset, + } + } +} + +/// 一次「按默认档兜底」的诊断:哪个键、读到了什么、兜底成什么、一句给人看的话。 +/// +/// 存在的理由:非法枚举**不报错**(配置坏掉不该把启动器卡住),但也**不许静默**—— +/// 用户/日志要能看出「我写的 `always` 拼错了,现在按 auto 跑」。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppearanceDiagnostic { + /// 出问题的键。 + pub key: &'static str, + /// 文件里读到的原始文本。 + pub raw: String, + /// 实际生效的兜底档。 + pub fallback: &'static str, + /// 一句可读的说明(可直接进日志;UI 侧可再包一层文案)。 + pub message: String, +} + +/// 读出需要提示的回落(空 = 一切正常)。 +/// +/// 只覆盖**枚举型**键(`titlebar_mode` / `palette_source`)。数值键(`window_opacity`) +/// 越界是「钳位且不报错」的既定语义(D36 下限 0.08),不算诊断项。 +#[must_use] +pub fn diagnostics(doc: &SettingDocument) -> Vec { + let mut found = Vec::new(); + let text = |key: &str| { + doc.effective(key) + .and_then(Value::as_str) + .map(str::to_owned) + }; + + if let Some(raw) = text("appearance.titlebar_mode") { + if !TITLEBAR_MODES.contains(&raw.as_str()) { + let fallback = TitlebarMode::Auto.as_str(); + found.push(AppearanceDiagnostic { + key: "appearance.titlebar_mode", + message: format!( + "appearance.titlebar_mode 的值 {raw:?} 不在 {TITLEBAR_MODES:?} 之中,已按 {fallback} 处理" + ), + raw, + fallback, + }); + } + } + if let Some(raw) = text("appearance.palette_source") { + if !PALETTE_SOURCES.contains(&raw.as_str()) { + let fallback = PaletteSource::Preset.as_str(); + found.push(AppearanceDiagnostic { + key: "appearance.palette_source", + message: format!( + "appearance.palette_source 的值 {raw:?} 不在 {PALETTE_SOURCES:?} 之中,已按 {fallback} 处理" + ), + raw, + fallback, + }); + } + } + found +} diff --git a/crates/core/src/setting/appearance/tests.rs b/crates/core/src/setting/appearance/tests.rs new file mode 100644 index 0000000..c186d93 --- /dev/null +++ b/crates/core/src/setting/appearance/tests.rs @@ -0,0 +1,175 @@ +//! 外观三键(整窗透明度 / 标题栏三态 / 配色来源)的读侧契约。 +//! +//! 与 `mod.rs` 拆开只为单文件 ≤400 行的纪律(D36/D37/D38 的键仍是 +//! `setting::appearance::*`),测试属于同一个模块。 + +use serde_json::json; + +use super::{ + diagnostics, Appearance, PaletteSource, TitlebarMode, PALETTE_SOURCES, TITLEBAR_MODES, + WINDOW_ALPHA_MIN, +}; +use crate::setting::document::SettingDocument; +use crate::setting::keys::launcher_schema; +use crate::setting::schema::{SchemaId, SchemaRef, SchemaVersion}; +use crate::setting::store::SettingStore; + +fn doc(raw: serde_json::Value) -> SettingDocument { + SettingDocument::load(&launcher_schema(), &raw).0 +} + +#[test] +fn missing_keys_fall_back_to_factory_values() { + let document = doc(json!({})); + let appearance = Appearance::read(&document); + assert_eq!(appearance.window_opacity, 1.0, "D36:出厂完全不透明"); + assert_eq!( + appearance.titlebar_mode, + TitlebarMode::Auto, + "D37:默认 auto" + ); + assert_eq!( + appearance.palette_source, + PaletteSource::Preset, + "D38:默认内置色卡(莫奈取色关闭的出厂档)" + ); + assert!( + diagnostics(&document).is_empty(), + "缺键 = 取默认,不该产生回落诊断" + ); +} + +#[test] +fn window_opacity_is_clamped_to_the_d36_floor() { + let cases = [ + (0.0, WINDOW_ALPHA_MIN), + (0.02, WINDOW_ALPHA_MIN), + (WINDOW_ALPHA_MIN, WINDOW_ALPHA_MIN), + (0.5, 0.5), + (1.0, 1.0), + (4.0, 1.0), + ]; + for (raw, want) in cases { + let appearance = Appearance::read(&doc(json!({ "appearance": { "window_opacity": raw } }))); + assert!( + (appearance.window_opacity - want).abs() < 1e-9, + "window_opacity={raw} 应读出 {want},实际 {}", + appearance.window_opacity + ); + } +} + +#[test] +fn out_of_range_opacity_is_clamped_not_rejected() { + // 手改文件绕过 schema:load 只记一条 problem(不失败、不 panic),读侧再夹一次。 + let (document, problems) = SettingDocument::load( + &launcher_schema(), + &json!({ "appearance": { "window_opacity": 0.02 } }), + ); + assert!( + !problems.is_empty(), + "越界值必须留下痕迹(LoadProblem),否则「钳位」变成静默改用户的数" + ); + assert!((Appearance::read(&document).window_opacity - WINDOW_ALPHA_MIN).abs() < 1e-9); + + // 类型错(字符串)也不算错误:回落默认 1.0。 + let appearance = Appearance::read(&doc(json!({ "appearance": { "window_opacity": "half" } }))); + assert_eq!(appearance.window_opacity, 1.0); +} + +#[test] +fn illegal_enum_falls_back_and_leaves_a_readable_diagnostic() { + let document = doc(json!({ + "appearance": { "titlebar_mode": "allways", "palette_source": "desktop" } + })); + let appearance = Appearance::read(&document); + assert_eq!(appearance.titlebar_mode, TitlebarMode::Auto); + assert_eq!(appearance.palette_source, PaletteSource::Preset); + + let found = diagnostics(&document); + assert_eq!(found.len(), 2, "两个非法枚举各一条诊断:{found:?}"); + assert_eq!(found[0].key, "appearance.titlebar_mode"); + assert_eq!(found[0].raw, "allways"); + assert_eq!(found[0].fallback, "auto"); + assert!( + found[0].message.contains("allways") && found[0].message.contains("auto"), + "诊断要同时说清「读到了什么」和「生效的是什么」:{}", + found[0].message + ); + assert_eq!(found[1].key, "appearance.palette_source"); + assert_eq!(found[1].raw, "desktop"); + assert_eq!(found[1].fallback, "preset"); +} + +#[test] +fn value_tables_are_stable_and_match_design_level_codes() { + assert_eq!(TITLEBAR_MODES, ["auto", "always", "never"], "D37 三态"); + // 与 `unlucky_design::palette::Level::code()` 逐字一致;core 不依赖 design(分层纪律), + // 所以这条一致性只能靠字面量钉住——design 侧改码值就会在这里红。 + assert_eq!( + PALETTE_SOURCES, + ["background-image", "wallpaper", "preset"], + "D38 三级(与 design 的 Level::code() 同名)" + ); + for source in [ + PaletteSource::BackgroundImage, + PaletteSource::Wallpaper, + PaletteSource::Preset, + ] { + assert_eq!(PaletteSource::from_key_value(source.as_str()), source); + assert!(PALETTE_SOURCES.contains(&source.as_str())); + } + for mode in [ + TitlebarMode::Auto, + TitlebarMode::Always, + TitlebarMode::Never, + ] { + assert_eq!(TitlebarMode::from_key_value(mode.as_str()), mode); + assert!(TITLEBAR_MODES.contains(&mode.as_str())); + } +} + +#[test] +fn older_document_loads_and_write_back_keeps_existing_keys() { + let temp = std::env::temp_dir().join(format!( + "ull-appearance-base-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(temp.join("config")).expect("临时目录"); + let path = temp.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + + // 1.3.0 的老文件:没有任何新键,但已有外观键要原样保住。 + let old = json!({ + "$schema": SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 3, 0)).url(), + "appearance": { "theme_mode": "dark", "panel_alpha": 0.8, "glass": true } + }); + std::fs::write(&path, serde_json::to_vec_pretty(&old).expect("序列化")).expect("写老文件"); + + let mut store = SettingStore::load(&path, launcher_schema()); + let before = Appearance::read(store.doc()); + assert_eq!(before.theme_mode, "dark", "老文件的已有键照读"); + assert!((before.panel_alpha - 0.8).abs() < 1e-9); + assert!(before.glass); + + let mut appearance = before; + appearance.window_opacity = 0.9; + appearance.titlebar_mode = TitlebarMode::Always; + appearance.write(&mut store).expect("写新键"); + store.save().expect("落盘"); + + let reloaded = Appearance::from_store(&SettingStore::load(&path, launcher_schema())); + assert!((reloaded.window_opacity - 0.9).abs() < 1e-9); + assert_eq!(reloaded.titlebar_mode, TitlebarMode::Always); + assert_eq!( + reloaded.palette_source, + PaletteSource::Preset, + "未写的键仍是默认" + ); + assert_eq!(reloaded.theme_mode, "dark", "写回不许丢已有键"); + assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9); + assert!(reloaded.glass); + + std::fs::remove_dir_all(&temp).ok(); +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 0a4b0ee..ea89749 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -49,9 +49,12 @@ pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; /// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。 /// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` / /// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。 +/// `1.4.0` 登记 P4 的外观下层三键(`appearance.window_opacity` / `titlebar_mode` / +/// `palette_source`,M91 · D36/D37/D38):仍然只推 minor、不加迁移步骤——三者的出厂值 +/// (不透明 / auto / 内置色卡)正是「老文件没有这些键」时的等价物。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 3, 0) + SchemaVersion::new(1, 4, 0) } /// `config/launcher.json` 的 schema。 @@ -310,6 +313,29 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::audited(), "自定义强调色 #RRGGBB(空 = 出厂 #0078D4;accent 令牌全域生效)", )) + // M91(P4 / D36·D37·D38):「外观下层」三键——只登记取值与范围,判定与落地都在 UI 侧。 + // 分级同 D17 那批(audited/Medium):纯视觉、可一键复原,但要留审计痕迹。 + .with(SettingSpec::float( + "appearance.window_opacity", + 1.0, + (super::appearance::WINDOW_ALPHA_MIN, 1.0), + AiPolicy::audited(), + "整窗透明度 0.08~1.0(1.0 = 完全不透明;下限 0.08 是为了不把窗口写到穿透,D36)", + )) + .with(SettingSpec::enumeration( + "appearance.titlebar_mode", + "auto", + super::appearance::TITLEBAR_MODES, + AiPolicy::audited(), + "标题栏三态:auto(按合成器判定)/ always(总是自绘)/ never(合成器已装饰),默认 auto(D37)", + )) + .with(SettingSpec::enumeration( + "appearance.palette_source", + "preset", + super::appearance::PALETTE_SOURCES, + AiPolicy::audited(), + "配色来源档(D38 三级):background-image(背景图取色)/ wallpaper(桌面壁纸取色)/ preset(内置色卡,默认)", + )) // 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。 .with( SettingSpec::new( diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index e66fba0..15a935b 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -125,7 +125,7 @@ fn appearance_keys_are_all_ai_writable_and_registered() { #[test] fn schema_registration_shape_is_right() { - assert_eq!(launcher_version().minor, 3, "1.3.0 登记外观键"); + assert_eq!(launcher_version().minor, 4, "1.4.0 登记外观下层三键"); let schema = launcher_schema(); assert_eq!( schema.spec("appearance.bg_fill").unwrap().kind, diff --git a/crates/core/tests/setting/document.rs b/crates/core/tests/setting/document.rs index 6a0b391..c210dc3 100644 --- a/crates/core/tests/setting/document.rs +++ b/crates/core/tests/setting/document.rs @@ -145,8 +145,9 @@ fn schema_version_mirror_mismatch_is_reported_but_schema_wins() { #[test] fn newer_patch_preserves_original_schema_string() { - // 同 minor、更高 patch(launcher schema 当前 1.3.0)。 - let newer = SchemaVersion::new(1, 3, 7); + // 同 minor、更高 patch(**相对构造**:版本号涨了这条也不会假绿)。 + let current = launcher_version(); + let newer = SchemaVersion::new(current.major, current.minor, current.patch + 1); let url = schema_url(newer); let (doc, _) = loaded(json!({ SCHEMA_KEY: url.clone() })); assert_eq!( diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index 5efe2d2..46636e1 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -128,8 +128,8 @@ fn overrides_key_is_an_enum_whitelist() { #[test] fn schema_versions_register_the_new_keys_without_migration() { - // 1.3.0 登记 D17 的「外观(高级)」键(1.2.0 是 D23 的 JDK 落点/优先级键)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0)); + // 1.4.0 登记 M91 的外观下层三键(1.3.0 是 D17 的「外观(高级)」键,1.2.0 是 D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 4, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index f5d6cfb..a732379 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -66,7 +66,7 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() { #[test] fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { - assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0)); + assert_eq!(launcher_version(), SchemaVersion::new(1, 4, 0)); assert_eq!( launcher_version().major, 1, diff --git a/crates/core/tests/setting/store.rs b/crates/core/tests/setting/store.rs index f4f312b..19f8974 100644 --- a/crates/core/tests/setting/store.rs +++ b/crates/core/tests/setting/store.rs @@ -2,7 +2,7 @@ use serde_json::{json, Value}; use unlucky_core::setting::document::LoadProblem; -use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::keys::{launcher_schema, launcher_version}; use unlucky_core::setting::schema::{ FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY, }; @@ -124,8 +124,9 @@ fn corrupt_file_is_backed_up_before_overwrite() { #[test] fn newer_minor_is_read_only_and_keeps_its_schema() { let temp = TempDir::new("store-readonly"); - // 比当前 schema(1.3.0)更高的 minor:只读且保留原 $schema。 - let url = schema_url(SchemaVersion::new(1, 4, 0)); + // 比当前 schema 高一个 minor(**相对构造**:版本号涨了这条也不会假绿):只读且保留原 $schema。 + let current = launcher_version(); + let url = schema_url(SchemaVersion::new(current.major, current.minor + 1, 0)); temp.write( "config/launcher.json", &json!({ SCHEMA_KEY: url, "download": { "concurrency": 32 } }).to_string(), @@ -148,12 +149,10 @@ fn newer_minor_is_read_only_and_keeps_its_schema() { store.save_forced().expect("用户确认强写"); assert_eq!(store.access(), FileAccess::ReadWrite); let raw = read_json(&temp, "config/launcher.json"); - assert!( - raw[SCHEMA_KEY] - .as_str() - .expect("$schema") - .ends_with("launcher/1.4.0"), - "更 minor 的文件强写后也要保留原 $schema" + assert_eq!( + raw[SCHEMA_KEY].as_str().expect("$schema"), + url, + "更 minor 的文件强写后也要保留原 $schema(与构造出来的那个 url 逐字相同)" ); } diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index 683aee4..e10942d 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -8,6 +8,12 @@ /// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。 pub use unlucky_core::setting::appearance::BgFill; +/// 外观下层的三张表/常量(D36·D37·D38):复用 core 的定义,一处真相。 +/// +/// [`WINDOW_ALPHA_MIN`] 还要给 `unlucky-app-iced` 的 win32 α 层用(它只依赖本 crate,不直接依赖 +/// core):窗口 α 的硬底必须与设置侧同源,两边各写一个 `0.08` 迟早会漂。 +pub use unlucky_core::setting::appearance::{PaletteSource, TitlebarMode, WINDOW_ALPHA_MIN}; + use crate::dto::VersionCategory; /// 预设强调色板(09 §104)。**必须**与 UI 侧设置页「外观」分组的色板控件同序同值: @@ -162,6 +168,51 @@ impl AppearancePrefs { } } +/// 外观下层的**只读**快照:整窗 α / 标题栏三态 / 配色来源(D36·D37·D38)。 +/// +/// 不并进 [`AppearancePrefs`] 的两个理由:那个结构体由 `settings_bridge` 逐字段字面量 +/// 构造(加字段就得改那边),且这三个键只读不写——落盘仍走 core 的 `Appearance::write`。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AppearanceBase { + /// 整窗透明度 [`WINDOW_ALPHA_MIN`]~1.0(1.0 = 完全不透明) + pub window_opacity: f32, + /// 标题栏三态(`auto` 的具体判定属于 UI 侧,本 crate 只给值) + pub titlebar_mode: TitlebarMode, + /// 配色来源档(三级编排在 `unlucky-design`,本 crate 只给值) + pub palette_source: PaletteSource, +} + +impl AppearanceBase { + /// 出厂态:不透明 / `auto` / 内置色卡。 + #[must_use] + pub fn factory() -> Self { + Self { + window_opacity: 1.0, + titlebar_mode: TitlebarMode::Auto, + palette_source: PaletteSource::Preset, + } + } + + /// 从配置文档读一份快照:缺键 → 出厂默认、越界 → 钳位、非法枚举 → 回落。 + /// + /// 三条语义都在 core 里落地(本函数只搬运,不重新判一遍)。 + #[must_use] + pub fn from_document(doc: &unlucky_core::setting::document::SettingDocument) -> Self { + let appearance = unlucky_core::setting::appearance::Appearance::read(doc); + Self { + window_opacity: appearance.window_opacity as f32, + titlebar_mode: appearance.titlebar_mode, + palette_source: appearance.palette_source, + } + } + + /// 整窗 α 是否需要真的落到窗口上(1.0 = 不透明,调 FFI 纯属多余)。 + #[must_use] + pub fn is_transparent(&self) -> bool { + self.window_opacity < 1.0 + } +} + /// 需要跨会话记忆的 UI 偏好。 /// /// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。 @@ -226,9 +277,15 @@ pub mod keys { pub const ACCENT_COLOR: &str = "appearance.accent_color"; /// 下载页分类 pub const DOWNLOAD_CATEGORY: &str = "ui.download.category"; + /// 整窗透明度(D36;下限见 [`super::WINDOW_ALPHA_MIN`]) + pub const WINDOW_OPACITY: &str = "appearance.window_opacity"; + /// 标题栏三态(D37) + pub const TITLEBAR_MODE: &str = "appearance.titlebar_mode"; + /// 配色来源档(D38) + pub const PALETTE_SOURCE: &str = "appearance.palette_source"; /// 本模块登记的全部键(自检/迁移用)。 - pub const ALL: [&str; 13] = [ + pub const ALL: [&str; 16] = [ NAV_COLLAPSED, AI_COLLAPSED, TILE_SIZE, @@ -242,12 +299,18 @@ pub mod keys { GLASS, ACCENT_COLOR, DOWNLOAD_CATEGORY, + WINDOW_OPACITY, + TITLEBAR_MODE, + PALETTE_SOURCE, ]; } #[cfg(test)] mod tests { - use super::{keys, InstanceView, ThemeMode, TileSize, UiPrefs}; + use super::{ + keys, AppearanceBase, InstanceView, PaletteSource, ThemeMode, TileSize, TitlebarMode, + UiPrefs, WINDOW_ALPHA_MIN, + }; #[test] fn defaults_match_d16() { @@ -285,4 +348,51 @@ mod tests { keys.dedup(); assert_eq!(keys.len(), total); } + + fn base_of(raw: serde_json::Value) -> AppearanceBase { + let schema = unlucky_core::setting::keys::launcher_schema(); + let (doc, _) = unlucky_core::setting::document::SettingDocument::load(&schema, &raw); + AppearanceBase::from_document(&doc) + } + + #[test] + fn appearance_base_missing_keys_use_factory() { + let base = base_of(serde_json::json!({})); + assert_eq!( + base, + AppearanceBase::factory(), + "缺键 → 出厂态(不透明 / auto / preset)" + ); + assert!(!base.is_transparent(), "出厂不透明 → 不下发窗口 α"); + } + + #[test] + fn appearance_base_clamps_out_of_range_opacity() { + for raw in [0.0, 0.02, -1.0] { + let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } })); + assert_eq!( + base.window_opacity, WINDOW_ALPHA_MIN as f32, + "raw={raw} 应夹到下限" + ); + } + for (raw, want) in [(0.6, 0.6), (3.0, 1.0)] { + let base = base_of(serde_json::json!({ "appearance": { "window_opacity": raw } })); + assert_eq!(base.window_opacity, want, "raw={raw}"); + } + } + + #[test] + fn appearance_base_illegal_enums_fall_back_and_legal_ones_read_through() { + let bad = base_of(serde_json::json!({ + "appearance": { "titlebar_mode": "allways", "palette_source": "desktop" } + })); + assert_eq!(bad.titlebar_mode, TitlebarMode::Auto, "非法 → auto"); + assert_eq!(bad.palette_source, PaletteSource::Preset, "非法 → preset"); + // 合法值必须照读:否则「回落」测试会因为实现恒回默认而假绿。 + let good = base_of(serde_json::json!({ + "appearance": { "titlebar_mode": "never", "palette_source": "wallpaper" } + })); + assert_eq!(good.titlebar_mode, TitlebarMode::Never); + assert_eq!(good.palette_source, PaletteSource::Wallpaper); + } } From fa2c6560fd9c1a7f92d0f5ea269d6360090c933b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 14:28:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(appearance):=20M91=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=9C=9F=E4=BA=A4=E5=8F=89=E6=96=AD=E8=A8=80=20+?= =?UTF-8?q?=20unsafe=20=E9=97=A8=E7=A6=81=E8=B7=A8=E8=A1=8C=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=20+=20=CE=B1=20=E4=B8=8A=E5=8F=96=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1「与 design 码值一致」的守门此前不存在 - 新增 crates/app-iced/src/platform/mod.rs::palette_source_codes_match_design_level_codes: 三个格子对照 PaletteSource::as_str() 与 unlucky_design::palette::Level::code(), 并覆盖反方向(from_key_value(level.code()) 必须还原同一档)与「码值在允许值表里」 - core 侧那条测试改名为 value_tables_are_self_consistent,注释改成事实: core 不依赖 design,只能钉自己的字面量;一致性由 app-iced 的那条对照断言守 - 实测:把 design 的 Level::Wallpaper 码值改成 "desktop" → 新断言红(design 自己 178 个测试仍全绿) P2-2 unsafe-scope 门禁:行级正则 → 跨行扫描 - 去注释 + 按方括号闭合把续行并成逻辑行,再逐个属性组判断 allow(...unsafe_code) - 抓得住原来两种绕过:多行属性 #![allow(\n unsafe_code\n)]、同行两属性 #[inline] #[allow(unsafe_code)] - 门禁注释补全已知边界(不再声称「依赖 fmt job 兜底」——已不依赖) O-1 α 量化方向:round → ceil - 0.08×255=20.4,round 得 20 → 实际下发 20/255=0.0784,低于 D36 名义下限 - ceil 得 21 → 0.0824;新增 applied_alpha_is_never_below_the_floor 断言 下发字节/255 ≥ 0.08 O-7 契约测试补三个新键 - appearance_keys_are_all_ai_writable_and_registered 的 audited 清单加 window_opacity / titlebar_mode / palette_source --- .github/workflows/guards.yml | 46 +++++++++++++++---- crates/app-iced/src/platform/mod.rs | 32 +++++++++++++ .../src/platform/win32/transparency.rs | 32 +++++++++++-- crates/core/src/setting/appearance/tests.rs | 9 ++-- crates/core/tests/setting/appearance_keys.rs | 4 ++ crates/presenter/src/prefs.rs | 1 + 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml index 8d963e0..c10a1d0 100644 --- a/.github/workflows/guards.yml +++ b/.github/workflows/guards.yml @@ -156,7 +156,9 @@ jobs: # - 新增第二个带 allow(unsafe_code) 的文件 → 红(黑名单写法在这里会假绿); # - 落点文件把 allow 删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 落点文件里多开别的 unsafe 能力 → 门禁看不见(这是本守门的已知边界, - # workspace 的 `unsafe_code = "deny"` 也只是按文件放行,逐行审计靠 code review)。 + # workspace 的 `unsafe_code = "deny"` 也只是按文件放行,逐行审计靠 code review); + # - 多行属性 / 同行合并属性:**已由跨行扫描覆盖**(r2 加固前是行级正则,那两种写法能过闸, + # 当时唯一的缓解是 ci.yml 里独立的 fmt job——rustfmt 会把它们归成一行。现在不再依赖 fmt)。 unsafe-scope: name: unsafe 落点守门(集合必须恰好一个文件) runs-on: ubuntu-latest @@ -166,14 +168,42 @@ jobs: run: | set -uo pipefail EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' - # 只认「行首就是属性」的写法,并允许与其他 lint 混写: - # #[allow(unsafe_code)] #![allow(unsafe_code)] - # #[allow(dead_code, unsafe_code)] #[cfg_attr(windows, allow(unsafe_code))] - # 文档/注释里提到这个属性**不算**(crates/app/src/main.rs 的说明文字就提到过它, - # 用无锚定的 `grep -r allow(unsafe_code)` 会把它当越线)。 - ATTR='^[[:space:]]*#!?\[(.*,)?[[:space:]]*allow\([^]]*unsafe_code[^]]*\)\]' + # 扫描口径(M91 r2 加固):**跨行**扫描属性,不再用行级正则。 + # 旧版是单行正则,实测有两种绕过能过闸:多行属性 `#![allow(\n unsafe_code\n)]`、 + # 以及同行两个属性 `#[inline] #[allow(unsafe_code)]`——当时只能靠 ci.yml 的独立 + # fmt job 兜底(rustfmt 会把这两种写法归成一行,所以 fmt 红会间接拦住它们)。 + # 现在不依赖 fmt 了:注释先去掉,再把「方括号未闭合」的续行并成逻辑行, + # 最后在逻辑行里逐个属性组 `#[...]` / `#![...]` 判断是否含 `allow(...unsafe_code...)`。 + # + # 仍然抓不住的(已知边界,别把它当全覆盖): + # - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有 allow」,不看 unsafe 块有几个); + # - 宏/`include!` 生成出来的 allow(扫描的是源码文本,不是展开后的 HIR); + # - 字符串字面量里写着属性文本的行(会被当代码扫;本仓库不存在这种行); + # - 注释里提到的属性**刻意不算**(`crates/app/src/main.rs` 的说明文字就提到过它)。 + scan() { + awk ' + BEGIN { buf = ""; depth = 0; found = 0 } + { + line = $0 + if (line ~ /^[[:space:]]*\/\//) next + sub(/\/\/.*$/, "", line) + buf = (buf == "" ? line : buf " " line) + opens = gsub(/\[/, "[", line) + closes = gsub(/\]/, "]", line) + depth += opens - closes + if (depth > 0) next + s = buf; buf = ""; depth = 0 + while (match(s, /#!?\[[^]]*\]/)) { + attr = substr(s, RSTART, RLENGTH) + if (attr ~ /allow\([^]]*unsafe_code/) found = 1 + s = substr(s, RSTART + RLENGTH) + } + } + END { exit(found ? 0 : 1) } + ' "$1" + } actual=$(git ls-files '*.rs' | while IFS= read -r f; do - if grep -qE "$ATTR" "$f"; then printf '%s\n' "$f"; fi + if scan "$f"; then printf '%s\n' "$f"; fi done | LC_ALL=C sort) echo "期望(恰好这一个):$EXPECTED" echo "实际扫到的集合:" diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index 8243228..f8baeb2 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -111,4 +111,36 @@ mod tests { 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())); + } + } } diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 348b30e..1ada558 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -63,6 +63,10 @@ pub enum AlphaRejection { /// /// 输入先夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`(NaN 取下限):调用方即使漏了 /// [`super::super::effective_window_alpha`] 的过滤,也不会把窗口写成穿透。 +/// +/// **向上取整**(`ceil`)是刻意的:`bAlpha/255` 才是实际下发的 α,用 `round` 会让 +/// `0.08×255 = 20.4 → 20`,实际下发 `20/255 = 0.0784`,反而**低于** D36 的下限。 +/// 因此下发值可能比请求值略高(最多 1/255 ≈ 0.004),但绝不会低于下限。 #[must_use] pub fn alpha_byte(alpha: f64) -> u8 { let clamped = if alpha.is_nan() { @@ -70,7 +74,7 @@ pub fn alpha_byte(alpha: f64) -> u8 { } else { alpha.clamp(WINDOW_ALPHA_MIN, 1.0) }; - (clamped * 255.0).round() as u8 + (clamped * 255.0).ceil() as u8 } /// 把整窗 α 下发到窗口。 @@ -127,13 +131,13 @@ mod tests { use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; #[test] - fn alpha_byte_quantizes_and_clamps() { + fn alpha_byte_quantizes_upward_never_below_the_floor() { assert_eq!(alpha_byte(1.0), 255); - assert_eq!(alpha_byte(0.5), 128, "0.5×255=127.5 → 四舍五入 128"); + 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)" + 21, + "0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限" ); // 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。 for bad in [0.0, -3.0, f64::NAN] { @@ -149,6 +153,24 @@ mod tests { ); } + #[test] + fn applied_alpha_is_never_below_the_floor() { + // 真正生效的 α 是 `bAlpha / 255`,不是传进来的 f64。这条把「改用 round」的行为钉死: + // round 下 0.08 → 20 → 20/255 = 0.0784 < 0.08 → 这里红。 + for requested in [WINDOW_ALPHA_MIN, 0.081, 0.1, 0.33, 0.5, 0.77, 0.999, 1.0] { + let byte = alpha_byte(requested); + let applied = f64::from(byte) / 255.0; + assert!( + applied >= WINDOW_ALPHA_MIN - 1e-12, + "requested={requested} → byte={byte} → 实际 α={applied},低于下限 {WINDOW_ALPHA_MIN}" + ); + assert!( + applied <= 1.0, + "requested={requested} → 实际 α={applied} 超过 1.0" + ); + } + } + #[test] fn missing_or_opaque_alpha_never_touches_the_window() { for none in [None, Some(0.0), Some(-0.5), Some(f64::NAN)] { diff --git a/crates/core/src/setting/appearance/tests.rs b/crates/core/src/setting/appearance/tests.rs index c186d93..f1930ba 100644 --- a/crates/core/src/setting/appearance/tests.rs +++ b/crates/core/src/setting/appearance/tests.rs @@ -102,10 +102,13 @@ fn illegal_enum_falls_back_and_leaves_a_readable_diagnostic() { } #[test] -fn value_tables_are_stable_and_match_design_level_codes() { +fn value_tables_are_self_consistent() { assert_eq!(TITLEBAR_MODES, ["auto", "always", "never"], "D37 三态"); - // 与 `unlucky_design::palette::Level::code()` 逐字一致;core 不依赖 design(分层纪律), - // 所以这条一致性只能靠字面量钉住——design 侧改码值就会在这里红。 + // 这里只钉 **core 自己的字面量**(改这三个字符串要过这一关)。 + // 与 `unlucky_design::palette::Level::code()` 的一致性 core 守不了——core 不依赖 design + // (分层纪律),本 crate 的测试永远看不到 design 的代码。那条一致性由 + // `crates/app-iced/src/platform/mod.rs::palette_source_codes_match_design_level_codes` + // 守(那个 crate 同时依赖 design 与 presenter,是唯一能对照的地方)。 assert_eq!( PALETTE_SOURCES, ["background-image", "wallpaper", "preset"], diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index 15a935b..80db21e 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -96,6 +96,10 @@ fn appearance_keys_are_all_ai_writable_and_registered() { "appearance.panel_alpha", "appearance.glass", "appearance.accent_color", + // M91(D36/D37/D38)的外观下层三键,同样 audited。 + "appearance.window_opacity", + "appearance.titlebar_mode", + "appearance.palette_source", ]; for key in audited_keys { let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记")); diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index e10942d..562dfb5 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -8,6 +8,7 @@ /// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。 pub use unlucky_core::setting::appearance::BgFill; +pub use unlucky_core::setting::appearance::PALETTE_SOURCES; /// 外观下层的三张表/常量(D36·D37·D38):复用 core 的定义,一处真相。 /// /// [`WINDOW_ALPHA_MIN`] 还要给 `unlucky-app-iced` 的 win32 α 层用(它只依赖本 crate,不直接依赖 From 03ce813c32d459d5d8ca94f95632421397369534 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 15:16:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(ci):=20M91=20r3=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?unsafe-scope=20=E9=97=A8=E7=A6=81=E6=8D=A2=E6=88=90=E8=AF=8D?= =?UTF-8?q?=E6=B3=95=E5=99=A8=EF=BC=88=E6=8B=AC=E5=8F=B7=E5=AD=97=E9=9D=A2?= =?UTF-8?q?=E9=87=8F/expect/=E5=9D=97=E6=B3=A8=E9=87=8A=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E5=81=87=E7=BB=BF=E6=A0=B9=E6=B2=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 必修 1:不配对括号字面量把 r2 的「全文净深度合并」带偏,文件尾部永久不可见 - r2 用「方括号净深度」决定续行合并,但 ['[', '(']、"[2"、starts_with("[2") 这类字面量 会让深度永久偏离 0(实测 601 文件里 10 个扫到 EOF 仍非 0),于是该文件后面的一切都被跳过: 在 gate.rs 尾部追加作用域 allow,r2 门禁 exit 0(假绿) - 换成逐字符词法器:普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 / 字符字面量, 只在普通态认 `#` 或 `#!` 紧跟 `[` 的属性起点,只在这一个属性内部累计到 `]` 配平; 数组字面量跨行不再参与合并 必修 2:`#![expect(unsafe_code)]` 既绕过门禁也绕过 workspace 的 deny - 判据从 allow 放宽到 (allow|expect):expect 同样放行 unsafe(rustc -D unsafe_code 实测通过) 必修 3:块注释里的属性文本被当成第二个落点(与「注释刻意不算」的承诺相反) - 词法器带块注释状态机(含嵌套),行注释/块注释/字符串里的属性文本都不算 顺带 - 已知边界对齐事实:宏体里直接写出的属性**会**被扫到;真正漏的是「宏在别处定义、这里只调用」; 补两条:注释/字符串里的属性文本刻意不算、manifest 侧 [lints.rust] unsafe_code 不在扫描面 - 候选文件用 git grep -l unsafe_code 预筛(标识符不会被空白拆开,不会漏),整个 job 约 1~2 秒 --- .github/workflows/guards.yml | 154 ++++++++++++++++++++++++++--------- 1 file changed, 114 insertions(+), 40 deletions(-) diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml index c10a1d0..af6f476 100644 --- a/.github/workflows/guards.yml +++ b/.github/workflows/guards.yml @@ -153,56 +153,130 @@ jobs: # D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的 # `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。 # 判据必须是**集合相等**而不是「黑名单命中」: - # - 新增第二个带 allow(unsafe_code) 的文件 → 红(黑名单写法在这里会假绿); - # - 落点文件把 allow 删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); - # - 落点文件里多开别的 unsafe 能力 → 门禁看不见(这是本守门的已知边界, - # workspace 的 `unsafe_code = "deny"` 也只是按文件放行,逐行审计靠 code review); - # - 多行属性 / 同行合并属性:**已由跨行扫描覆盖**(r2 加固前是行级正则,那两种写法能过闸, - # 当时唯一的缓解是 ci.yml 里独立的 fmt job——rustfmt 会把它们归成一行。现在不再依赖 fmt)。 + # - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); + # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); + # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 + # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 unsafe-scope: name: unsafe 落点守门(集合必须恰好一个文件) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: 全仓 allow(unsafe_code) 的文件集合 == 唯一落点 + - name: 全仓显式放行 unsafe 的文件集合 == 唯一落点 run: | set -uo pipefail EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' - # 扫描口径(M91 r2 加固):**跨行**扫描属性,不再用行级正则。 - # 旧版是单行正则,实测有两种绕过能过闸:多行属性 `#![allow(\n unsafe_code\n)]`、 - # 以及同行两个属性 `#[inline] #[allow(unsafe_code)]`——当时只能靠 ci.yml 的独立 - # fmt job 兜底(rustfmt 会把这两种写法归成一行,所以 fmt 红会间接拦住它们)。 - # 现在不依赖 fmt 了:注释先去掉,再把「方括号未闭合」的续行并成逻辑行, - # 最后在逻辑行里逐个属性组 `#[...]` / `#![...]` 判断是否含 `allow(...unsafe_code...)`。 + # 扫描口径(M91 r3):一个小词法器,而不是正则。 # - # 仍然抓不住的(已知边界,别把它当全覆盖): - # - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有 allow」,不看 unsafe 块有几个); - # - 宏/`include!` 生成出来的 allow(扫描的是源码文本,不是展开后的 HIR); - # - 字符串字面量里写着属性文本的行(会被当代码扫;本仓库不存在这种行); - # - 注释里提到的属性**刻意不算**(`crates/app/src/main.rs` 的说明文字就提到过它)。 + # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 + # `['[', '(']`、`"[2"`、`starts_with("[2")` 这类**不配对括号字面量**会让深度永久跑偏, + # 于是该文件后面的一切(包括一个真的 `#[allow(unsafe_code)]`)都再也不会被扫到—— + # 实测本仓 601 个文件里有 10 个扫到 EOF 仍非 0,`gate.rs` 就是其中之一(r3 复审实证: + # 在它尾部加作用域 allow,r2 门禁 exit 0 假绿)。 + # 现在的做法:逐字符状态机(普通 / 行注释 / 块注释(可嵌套)/ 字符串 / raw 字符串 / + # 字符字面量),只在「普通态」里认 `#` 或 `#!` 紧跟 `[` 的属性起点,然后只在这一个 + # 属性内部累计到它的 `]` 配平——数组字面量跨行不再参与合并,也就不会带偏。 + # + # 放行的两种拼法都算:`allow(...)` 与 `expect(...)`。`expect(unsafe_code)` 同样会 + # 放行 unsafe 代码(rustc 实测:`#![expect(unsafe_code)]` + `pub unsafe fn f() {}` 在 + # `-D unsafe_code` 下编译通过),所以它必须被同一条判据抓住,不能只认 allow。 + # + # 仍然抓不住的(已知边界,别当全覆盖): + # - 落点文件**内部**多开别的 unsafe 能力(本门禁只看「有没有放行属性」,不看 unsafe 块个数); + # - **在别处定义、这里只调用**的宏展开出来的 allow:宏体里直接写出的属性在源码文本里, + # 会被扫到;但「宏定义在别的文件、调用点展开出 allow」时,本文件源码里没有那个属性; + # - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行; + # 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码); + # - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml; scan() { - awk ' - BEGIN { buf = ""; depth = 0; found = 0 } - { - line = $0 - if (line ~ /^[[:space:]]*\/\//) next - sub(/\/\/.*$/, "", line) - buf = (buf == "" ? line : buf " " line) - opens = gsub(/\[/, "[", line) - closes = gsub(/\]/, "]", line) - depth += opens - closes - if (depth > 0) next - s = buf; buf = ""; depth = 0 - while (match(s, /#!?\[[^]]*\]/)) { - attr = substr(s, RSTART, RLENGTH) - if (attr ~ /allow\([^]]*unsafe_code/) found = 1 - s = substr(s, RSTART + RLENGTH) + awk -v path="$1" ' + function lex( s, line, n, i, c, nx, state, depth, hashes, j, ok, k, ch, ch2, adepth, attr, q) { + q = "\047" + s = "" + while ((getline line < path) > 0) s = s line "\n" + close(path) + n = length(s) + state = 0; depth = 0; hashes = 0; found = 0 + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (state == 1) { if (c == "\n") state = 0; continue } + if (state == 2) { + if (c == "/" && substr(s, i + 1, 1) == "*") { depth++; i++; continue } + if (c == "*" && substr(s, i + 1, 1) == "/") { depth--; i++; if (depth <= 0) state = 0; continue } + continue + } + if (state == 3) { + if (c == "\\") { i++; continue } + if (c == "\"" || c == "\n") state = 0 + continue + } + if (state == 4) { + if (c == "\"") { + ok = 1 + for (j = 1; j <= hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0 + if (ok) { state = 0; i += hashes } + } + continue + } + if (state == 5) { + if (c == "\\") { i++; continue } + if (c == q || c == "\n") state = 0 + continue + } + if (c == "/" && substr(s, i + 1, 1) == "/") { state = 1; i++; continue } + if (c == "/" && substr(s, i + 1, 1) == "*") { state = 2; depth = 1; i++; continue } + if (c == "\"") { state = 3; continue } + if (c == q) { + nx = substr(s, i + 1, 1) + if (nx == "\\" || substr(s, i + 2, 1) == q) state = 5 + continue + } + if (c == "r" || c == "b") { + j = i + 1 + if (c == "b") { + if (substr(s, j, 1) == "\"") { state = 3; i = j; continue } + if (substr(s, j, 1) == q) { state = 5; i = j; continue } + if (substr(s, j, 1) != "r") continue + j++ + } + hashes = 0 + while (substr(s, j, 1) == "#") { hashes++; j++ } + if (substr(s, j, 1) == "\"") { state = 4; i = j; continue } + continue + } + if (c == "#") { + j = i + 1 + if (substr(s, j, 1) == "!") j++ + if (substr(s, j, 1) != "[") continue + attr = ""; adepth = 0; k = j + while (k <= n) { + ch = substr(s, k, 1) + if (ch == "\"") { + k++ + while (k <= n) { + ch2 = substr(s, k, 1) + if (ch2 == "\\") { k += 2; continue } + if (ch2 == "\"") break + k++ + } + } else if (ch == "[") { adepth++ } + else if (ch == "]") { adepth--; if (adepth == 0) break } + attr = attr ch + k++ + } + if (attr ~ /(allow|expect)\([^]]*unsafe_code/) found = 1 + i = k + continue + } } + return found } - END { exit(found ? 0 : 1) } - ' "$1" + BEGIN { exit(lex() ? 0 : 1) } + ' } - actual=$(git ls-files '*.rs' | while IFS= read -r f; do + # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开, + # 所以这个预筛不会漏)。601 个文件 → 个位数个候选,整个 job 约 2 秒。 + actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do if scan "$f"; then printf '%s\n' "$f"; fi done | LC_ALL=C sort) echo "期望(恰好这一个):$EXPECTED" @@ -215,12 +289,12 @@ jobs: printf '%s\n' "$extra" | sed 's/^/ - /' fi if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then - echo "::error file=$EXPECTED::这个文件里没有 allow(unsafe_code) —— 落点被搬走/改名/删了 allow?守门路径已失效。" + echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。" fi - echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手 allow。" + echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。" exit 1 fi - echo "OK: 全仓 allow(unsafe_code) 恰好只有 $EXPECTED" + echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED" file-size: name: 文件行数 ≤400 守门 From 5d58301e2e7f87d971e55c8836571d9e9356bd75 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 15:33:48 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ci):=20M91=20r4=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=E5=86=85=E5=A4=8D=E7=94=A8=E4=B8=BB=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=EF=BC=88=E7=BB=88=E7=BB=93=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E5=86=85=20raw=20string=20/=20=E5=AD=97=E7=AC=A6=E5=AD=97?= =?UTF-8?q?=E9=9D=A2=E9=87=8F=E7=9B=B2=E5=8C=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:属性外的状态机(普通/行注释/块注释/字符串/raw/字符)只覆盖属性外;属性内另写了一套 「遇 " 跳到下一个 "」,于是属性内的 raw string 与字符字面量完全不认识: - 假绿:`#[doc = r#"a "b"#]` 之后跟一个真 `#[allow(unsafe_code)]` → exit 0(r2 能抓到,是新盲区) - 误红:`#[doc = r#"x" allow(unsafe_code) is banned "y"#]`(纯文档)→ 被报「多出来的 unsafe 落点」 修法(结构性,不是补分支):把逐字符分派抽成 step(),主循环与属性解析**共用同一个函数**; 属性解析只多保留一层「[ ] 深度决定何时离开属性」。属性内的字符串/raw/字符字面量因此与 属性外同一套识别,两处永不发叉。 - g_plain 标记「该位置是否处于普通态代码」:注释与字符串内容既不参与属性起点判定, 也不参与属性内的 [ ] 计数与文本匹配(所以纯文档里的 allow 文本不会误报) - 回归用例写进 yml 注释:四个形态(含 CRLF)+ 反方向纯文档例,改词法器前先跑 - 已验:gawk 5.4.0 / gawk --posix / mawk 1.3.4(debian:bookworm-slim)三种实现对四个 回归用例与全仓 601 个文件给出完全一致的结果(恰好一个落点) --- .github/workflows/guards.yml | 165 ++++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 71 deletions(-) diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml index af6f476..d75a289 100644 --- a/.github/workflows/guards.yml +++ b/.github/workflows/guards.yml @@ -188,90 +188,113 @@ jobs: # - 注释(行注释/块注释)与字符串(普通/raw)里的属性文本**刻意不算**:它们不构成放行; # 代价是「真把 allow 写进字符串」这种写法不会被当作落点(它也确实不放行任何代码); # - manifest 侧 `[lints.rust] unsafe_code = "allow"`:本门禁只扫 `*.rs`,看不见 Cargo.toml; + # + # r4 回归用例(属性内的 raw string / 字符字面量曾经是盲区;现在两处共用 step(), + # 它们不再是边界,但下面五条必须一直保持红/绿正确 —— 改这段词法器前先跑它们): + # 红 #[doc = r#"a "b"#] 后跟真 allow + # 红 #[cfg_attr(docsrs, doc = r##"a " b "##)] 后跟真 allow + # 红 #[cfg_attr(docsrs, my_macro(39 as char))] 一类含字符字面量/转义的属性后跟真 allow + # 具体形态:#![cfg_attr(docsrs, my_macro('\''))] 后跟真 allow + # 红 以上三者的 CRLF 版本 + # 绿 #[doc = r#"x" allow(unsafe_code) is banned "y"#](纯文档,不得误报) scan() { awk -v path="$1" ' - function lex( s, line, n, i, c, nx, state, depth, hashes, j, ok, k, ch, ch2, adepth, attr, q) { - q = "\047" + # ---- 唯一的状态机:主循环与属性内**共用**同一个 step() ---- + # 为什么必须复用:r2 是「属性外一套状态机、属性内另写一套」,两套各自演进, + # r3 的属性内就只剩「遇 " 跳到下一个 "」,于是 raw string / 字符字面量在属性内 + # 完全不认识 —— `#[doc = r#"a "b"#]` 之后的一切对门禁失明(假绿), + # `#[doc = r#"x" allow(unsafe_code) "y"#]` 这种纯文档被误报(误红)。 + # 现在两处只认这一个 step(),属性解析只多一层「[ ] 深度决定何时离开属性」。 + # + # 状态:0 普通 / 1 行注释 / 2 块注释(可嵌套)/ 3 字符串 / 4 raw 字符串 / 5 字符字面量。 + # 返回值 = 本次消费到的位置(`//`、`/*`、`r#"`、raw 收尾会一次吃掉多个字符); + # g_plain=1 表示该位置处在「普通态代码」里(注释/字符串内容一律 0, + # 所以它们既不会被当成属性、也不会参与属性内的 [ ] 计数与文本匹配)。 + function step(s, n, i, c, nx, j, h, ok) { + c = substr(s, i, 1) + g_plain = 0 + if (g_state == 1) { if (c == "\n") g_state = 0; return i } + if (g_state == 2) { + if (c == "/" && substr(s, i + 1, 1) == "*") { g_depth++; return i + 1 } + if (c == "*" && substr(s, i + 1, 1) == "/") { g_depth--; if (g_depth <= 0) g_state = 0; return i + 1 } + return i + } + if (g_state == 3) { + if (c == "\\") return i + 1 + if (c == "\"" || c == "\n") g_state = 0 + return i + } + if (g_state == 4) { + if (c == "\"") { + ok = 1 + for (j = 1; j <= g_hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0 + if (ok) { g_state = 0; return i + g_hashes } + } + return i + } + if (g_state == 5) { + if (c == "\\") return i + 1 + if (c == g_q || c == "\n") g_state = 0 + return i + } + if (c == "/" && substr(s, i + 1, 1) == "/") { g_state = 1; return i + 1 } + if (c == "/" && substr(s, i + 1, 1) == "*") { g_state = 2; g_depth = 1; return i + 1 } + if (c == "\"") { g_state = 3; return i } + if (c == g_q) { + nx = substr(s, i + 1, 1) + if (nx == "\\" || substr(s, i + 2, 1) == g_q) { g_state = 5; return i } + g_plain = 1 + return i + } + if (c == "r" || c == "b") { + j = i + 1 + if (c == "b") { + if (substr(s, j, 1) == "\"") { g_state = 3; return j } + if (substr(s, j, 1) == g_q) { g_state = 5; return j } + if (substr(s, j, 1) != "r") { g_plain = 1; return i } + j++ + } + h = 0 + while (substr(s, j, 1) == "#") { h++; j++ } + if (substr(s, j, 1) == "\"") { g_hashes = h; g_state = 4; return j } + g_plain = 1 + return i + } + g_plain = 1 + return i + } + function lex( s, line, n, i, j, k, ch, adepth, attr) { s = "" while ((getline line < path) > 0) s = s line "\n" close(path) n = length(s) - state = 0; depth = 0; hashes = 0; found = 0 + g_state = 0; g_depth = 0; g_hashes = 0; g_found = 0 for (i = 1; i <= n; i++) { - c = substr(s, i, 1) - if (state == 1) { if (c == "\n") state = 0; continue } - if (state == 2) { - if (c == "/" && substr(s, i + 1, 1) == "*") { depth++; i++; continue } - if (c == "*" && substr(s, i + 1, 1) == "/") { depth--; i++; if (depth <= 0) state = 0; continue } - continue - } - if (state == 3) { - if (c == "\\") { i++; continue } - if (c == "\"" || c == "\n") state = 0 - continue - } - if (state == 4) { - if (c == "\"") { - ok = 1 - for (j = 1; j <= hashes; j++) if (substr(s, i + j, 1) != "#") ok = 0 - if (ok) { state = 0; i += hashes } - } - continue - } - if (state == 5) { - if (c == "\\") { i++; continue } - if (c == q || c == "\n") state = 0 - continue - } - if (c == "/" && substr(s, i + 1, 1) == "/") { state = 1; i++; continue } - if (c == "/" && substr(s, i + 1, 1) == "*") { state = 2; depth = 1; i++; continue } - if (c == "\"") { state = 3; continue } - if (c == q) { - nx = substr(s, i + 1, 1) - if (nx == "\\" || substr(s, i + 2, 1) == q) state = 5 - continue - } - if (c == "r" || c == "b") { - j = i + 1 - if (c == "b") { - if (substr(s, j, 1) == "\"") { state = 3; i = j; continue } - if (substr(s, j, 1) == q) { state = 5; i = j; continue } - if (substr(s, j, 1) != "r") continue - j++ - } - hashes = 0 - while (substr(s, j, 1) == "#") { hashes++; j++ } - if (substr(s, j, 1) == "\"") { state = 4; i = j; continue } - continue - } - if (c == "#") { - j = i + 1 - if (substr(s, j, 1) == "!") j++ - if (substr(s, j, 1) != "[") continue - attr = ""; adepth = 0; k = j - while (k <= n) { + i = step(s, n, i) + if (!g_plain || substr(s, i, 1) != "#") continue + j = i + 1 + if (substr(s, j, 1) == "!") j++ + if (substr(s, j, 1) != "[") continue + # 属性:**复用 step()**,只多一层「[ ] 深度决定何时离开属性」。 + # 属性里的字符串 / raw 字符串 / 字符字面量因此与属性外同一套识别。 + attr = ""; adepth = 0; k = j + while (k <= n) { + k = step(s, n, k) + if (g_plain) { ch = substr(s, k, 1) - if (ch == "\"") { - k++ - while (k <= n) { - ch2 = substr(s, k, 1) - if (ch2 == "\\") { k += 2; continue } - if (ch2 == "\"") break - k++ - } - } else if (ch == "[") { adepth++ } - else if (ch == "]") { adepth--; if (adepth == 0) break } + if (ch == "[") adepth++ + else if (ch == "]") adepth-- attr = attr ch - k++ + if (adepth <= 0) break } - if (attr ~ /(allow|expect)\([^]]*unsafe_code/) found = 1 - i = k - continue + k++ } + if (attr ~ /(allow|expect)\([^]]*unsafe_code/) g_found = 1 + i = k } - return found + return g_found } - BEGIN { exit(lex() ? 0 : 1) } + BEGIN { g_q = "\047"; g_found = 0; exit(lex() ? 0 : 1) } ' } # 只对「文本里出现过 unsafe_code」的文件跑词法器(标识符不可能被空白拆开,