From fa2c6560fd9c1a7f92d0f5ea269d6360090c933b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 14:28:36 +0800 Subject: [PATCH] =?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,不直接依赖