fix(app-iced): M116 r1 复审 —— 口径降级为「不承诺」、修掉伪造读数与两处错注释、圆角下发去重

四条 P1(全是「口径与证据不符」,行为未变):

1. 把「Win11 一定有系统圆角、边缘平滑」从事实降级:`corner.rs` 的平台矩阵改成
   「实测 / 解释 / 推断 / 未验」四列,并明写本机只测到「写入被接受 / 回读 E_INVALIDARG /
   无边框窗口没有 DWM 调整带」;设置页与 golden 的说明句改成「我们**请求**系统圆角,
   是否圆、边缘什么样由合成器决定」,不再承诺结果。
2. 删掉证据目录里那条伪造读数 `readback=2`:本机三种样式回读全是 `E_INVALIDARG`,
   该值物理上产不出来,也不可能由 `format_line` 产出。改成显式的「合成示例(非实测)」,
   并把每一栏标注成真实/示例来源。probe-table 的「没有一条是推断」标题一并拆开,
   结论 4/5 明确标为**解释**(并写明 Δframe 之差是样式位的同义反复,不能反推 DWM 决策)。
3. `keys.rs` 的「旧读新」语义按实际链路改写:1.4.0 读 1.5.0 文件走
   `SchemaCompat::check_url` → `ReadOnlyPreserveSchema` → `SchemaCompat::access` →
   `FileAccess::ReadOnly`,**整份配置只读**(值照读、`store.set` 报 `SettingError::ReadOnly`、
   `save` 跳过),不是「只是少一档」。
4. 两处错注释订正:
   - `hresult_ok` 的理由改成 `S_FALSE` 的通用语义,并明写 `0x7FF8FFA9` **不是** DWM 的返回值
     (本机实测是 `E_INVALIDARG 0x80070057`),那是我早期用 `unsigned_abs()` 打印 HRESULT 的产物;
     对应单测也把该常量标为「合成值,只用来钉规则」。
   - `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 的文档改成「本机三样式三态实测全 1,不能当判据,
     仅作诊断读数」。

两条裁定:
- CPU 档在 Win11 仍走 DWM(tower 裁定保留):理由写进 `plan_corner` 文档——D36 约束的是我们自己的
  渲染路径,DWM 圆角是合成器侧的,不增加我方负担;设置页说明同步加了「软件渲染也一样」。
- 「圆角窗口上拖边缩放仍命中」实测未做(RDP 断开、无指针注入手段):mission 里以 note 记为 dropped
  (store 的 task_drop 对已完成任务不生效,工具层面无法取消勾选,已在 note 里写清)。

六条 P2:
1. probe-table 拆「实测 / 解释 / 未验」并删掉绝对化标题。
2. CPU 档裁定见上(任务书由 tower 侧调整)。
3. 拖边缩放任务记账更正见上。
4. **圆角下发去重**:新增 `corner::{CornerApplied, needs_redispatch}`(指纹 = 策略 / 来源 /
   原因码 / 有没有句柄,不含只有真下发才拿得到的读数),`App::apply_window_corner` 改成
   `&mut self` 并在判定未变时**直接返回**(不动窗口、不打日志);`App` 新增 `corner_applied` 字段。
   新增测试 `the_same_policy_is_dispatched_only_once`:连续 20 次同策略只下发/记 1 次,
   换档与「首次拿到句柄」必须重下。
5. `dwm_get_rect` 的 `hr == 0` 改成 `hresult_ok(hr)`,「判据只有一处」名副其实。
6. 报告口径修正(诊断已产出、UI 接线待做——`SettingsBridge` 未暴露 doc,接线要改 presenter 的
   `settings_bridge.rs`,不在本 mission scope),本轮不动代码。

门禁:fmt --check exit 0;clippy(5 个 crate)--all-targets -D warnings 零 warning;
lib 测试 app-iced 419(+1 去重测试)/ core 266 / design 163 / i18n 24 / presenter 129 全绿;
改动文件仍全部 ≤400 行(最大 keys.rs 398)。
This commit is contained in:
AskaEth committed 2026-11-05 06:35:17 +08:00
1 parent 25d860b2b3
commit 41746d1612
10 files changed
+184 -40

No files matched your search

+6
View File
@@ -156,6 +156,11 @@ pub struct App {
pub(super) account: pages::account::Page,
/// 皮肤/头像解码缓存(M114:同一张皮肤只解一次)。
pub(super) avatars: pages::account::avatar::Cache,
/// 上一次**真下发过**的圆角判定指纹(M116 r1 复审 P2-4:判定没变就不重下、不重记日志)。
///
/// `None` = 还没下发过(首屏 `boot` 那一次)。指纹类型见
/// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
}
impl App {
@@ -266,6 +271,7 @@ impl App {
width,
),
avatars: pages::account::avatar::Cache::default(),
corner_applied: None,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
+17 -2
View File
@@ -23,6 +23,7 @@
//! (M116 取消了 8/12/16 固定档)。
use crate::platform;
use crate::platform::win32::corner::{self, CornerApplied};
use crate::platform::win32::transparency::{apply_corner, style_summary, CornerOutcome};
use super::super::state::App;
@@ -30,14 +31,28 @@ use super::super::state::App;
impl App {
/// 把当前圆角策略下发到窗口,并打一行可分辨的日志。
///
/// ## 去重(M116 r1 复审 P2-4)
///
/// 设置页的滑条(整窗 α / 面板 α / 暗化)每动一下都会走一次外观变更,而<b>圆角与那些键无关</b>。
/// 判定完全没变时这里**直接返回**:不动窗口、不打日志。判据是
/// [`corner::needs_redispatch`](纯函数,有「同一策略 N 次只记 1 行」的测试),
/// 指纹只含「策略 / 来源 / 原因码 / 有没有句柄」,不含只有真下发才拿得到的读数。
///
/// 没有句柄时**也打日志**(`window=none`):不能让「没生效」只体现为屏幕上没变化。
pub(crate) fn apply_window_corner(&self, context: &str) {
/// 句柄从无到有会让指纹变化 ⇒ 首次拿到 HWND 时必然重下一次。
pub(crate) fn apply_window_corner(&mut self, context: &str) {
let policy = self.appearance.window_corner();
let plan = platform::plan_corner(
policy,
platform::current_windows_version(),
self.appearance.backend,
);
let next = CornerApplied::new(policy, &plan, self.hwnd.is_some());
if !corner::needs_redispatch(self.corner_applied.as_ref(), &next) {
return;
}
self.corner_applied = Some(next);
// 行的形状只有一处(`corner::format_line`),因为那里能在没有窗口的环境里被单测钉住。
// 「我们被 DWM 排除」的第一嫌疑是无边框(`WS_POPUP`)与整窗 α(`WS_EX_LAYERED`),
// 所以 `frame=` / `layered=` 必须和圆角结论在同一行——否则以后只能猜是哪一条。
@@ -50,7 +65,7 @@ impl App {
};
eprintln!(
"{}",
platform::win32::corner::format_line(context, &plan, policy, &outcome, &style)
corner::format_line(context, &plan, policy, &outcome, &style)
);
}
}
+73 -15
View File
@@ -9,14 +9,21 @@
//!
//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。
//!
//! ## 平台矩阵(真值表的出处)
//! ## 平台矩阵(哪些是实测、哪些是推断)
//!
//! | 平台 | 系统圆角 | 我们做什么 |
//! | 平台 | 我们做什么 | 依据级别 |
//! |---|---|---|
//! | Win11 / Server 2025(build ≥ 22000) | 有(DWM 合成器画,抗锯齿) | `DWMWCP_ROUND` |
//! | Win10 及更早(build < 22000) | 无(该属性不存在) | 直角;不做 `SetWindowRgn` 毛边默认 |
//! | macOS | 有(`layer.cornerRadius`,系统/合成器决定) | **不主动加**,交给系统 |
//! | Linux X11 / Wayland | 看合成器(客户端管不了外框) | **不承诺**,直角即如实结果 |
//! | Win11 / Server 2025(build ≥ 22000) | 下发 `DWMWCP_ROUND`(**请求**系统圆角) | 写入被接受 = **本机 API 实测**;「圆没圆 / 边缘什么样」= **未验**(无像素、回读不可用) |
//! | Win10 及更早(build < 22000) | 直角;不做 `SetWindowRgn` 毛边默认 | 「该属性不存在」= **外部文档证据**;本机没有 Win10 |
//! | macOS | 不主动加,交给系统 | **推断**(未真机验) |
//! | Linux X11 / Wayland | 不承诺,直角即如实结果 | **推断**(未真机验) |
//!
//! ⚠️ **「Win11 上我们的窗口一定有圆角、边缘平滑」这句话本分支的证据不支持**,别写回去。
//! 本机(Server 2025 build 26100、RDP 会话断开)只测到三件事:
//! ① `DwmSetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 被接受;
//! ② `DwmGetWindowAttribute` 同一属性返回 `E_INVALIDARG`(本会话不支持回读,与窗口形状无关);
//! ③ 无边框窗口没有 DWM 调整带(`EXTENDED_FRAME_BOUNDS − GetWindowRect` = `+0,+0`,有边框是 `+0,−7`)。
//! 「圆角长什么样」归真机验收,证据目录见 `DevDocs/artifacts/m116/`。
//!
//! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。
//! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。
@@ -29,10 +36,10 @@ use crate::Backend;
///
/// 只有两种来源,且**互相排斥**:
///
/// | 来源 | 谁画圆角 | 抗锯齿 | 我们做什么 |
/// |---|---|---|---|
/// | [`CornerSource::Dwm`] | DWM 合成器 | 有(系统画) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` |
/// | [`CornerSource::Square`] | 没人 | — | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 |
/// | 来源 | 谁画圆角 | 我们做什么 |
/// |---|---|---|
/// | [`CornerSource::Dwm`] | 系统合成器(是否真画、半径多少都不由我们定,也读不到) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` |
/// | [`CornerSource::Square`] | 没人 | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 |
///
/// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定):
/// 窗口是 `decorations=false` + 自绘标题栏 + 整窗 `LWA_ALPHA`,自绘圆角意味着把内容层裁成圆角,
@@ -136,11 +143,14 @@ impl CornerPlan {
/// | `System` | `< 22000` | CPU | `Square` | `CpuTier` |
/// | `System` | `>= 22000` | 任意 | `Dwm` | `None` |
///
/// 为什么 CPU 档在 Win11 上**照样**用 DWM 圆角:D36 限制的是**我们**做的合成技巧(面板 α /
/// 玻璃 / 模糊),而 DWM 圆角是系统合成器自己的行为,不消耗我们的渲染预算,也不违反
/// 「CPU 档不碰透明叠加」——把 Win11 的圆角在 CPU 档砍掉等于凭空造一个没人要的降级。
/// 反过来在非 DWM 平台上,圆角只剩「自绘裁切 / `SetWindowRgn` 毛边」这两条路,那才是 D36
/// 要拦的东西,所以那一格落 `cpu-tier` 而不是 `os-build`(同一个直角,**原因不同**)。
/// 为什么 CPU 档在 Win11 上**照样**用 DWM 圆角(**tower 2026-11-03 裁定,按此执行**):
/// D36 的 CPU 档约束针对的是**我们自己的渲染路径**(面板 α / 玻璃 / 模糊要关、不许拿透明叠加
/// 去换效果),而 DWM 圆角是**合成器侧**的效果,不增加我们的 GPU/CPU 绘制负担。所以
/// 「CPU 档关掉 Win11 圆角」不是 D36 的要求,而是凭空造一个没人要的降级。
///
/// 反过来在非 DWM 平台上,圆角只剩「自绘裁切 / `SetWindowRgn` 毛边」这两条**我们自己的**
/// 合成技巧,那才是 D36 要拦的东西 ⇒ 那一格落 `cpu-tier` 而不是 `os-build`
/// (同一个直角,**原因不同**,日志里可分辨)。
#[must_use]
pub fn plan_corner(
policy: WindowCorner,
@@ -196,5 +206,53 @@ pub fn format_line(
)
}
/// 一次「已经下发过」的**指纹**:只由判定本身与「当时有没有窗口」组成,不含读数。
///
/// 为什么需要它:设置页的滑条(整窗 α / 面板 α / 暗化)每动一下就触发一次外部外观变更,
/// 而圆角与那些键**无关**。没有去重时,拖一次滑条就会刷出一串
/// `[corner] change …`,把「分辨有没有生效」的那条日志淹掉(M116 r1 复审 P2-4)。
///
/// 指纹**刻意不含 `pref=` / `readback=` 这类读数**:那些只有真下发才知道,拿它们参与比较
/// 会变成「自己比较自己」。判定没变就不重下、也不重记——这也是「同一策略 N 次只记 1 行」的判据。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CornerApplied {
/// 当时的策略(`system` / `square`)。
pub policy: WindowCorner,
/// 判定出来的来源。
pub source: CornerSource,
/// 直角时的原因码(`None` = 走 DWM)。
pub excluded: Option<CornerExclusion>,
/// 下发时**有没有窗口句柄**:`false → true` 必须重下(首次拿到 HWND 时才真能设属性)。
pub has_window: bool,
}
impl CornerApplied {
/// 由判定与「有没有句柄」构造指纹。
#[must_use]
pub const fn new(policy: WindowCorner, plan: &CornerPlan, has_window: bool) -> Self {
Self {
policy,
source: plan.source,
excluded: plan.excluded,
has_window,
}
}
}
/// 这一次要不要真的下发 / 记日志(纯函数)。
///
/// `false` = 判定与上次完全一致(策略、来源、原因码、有没有句柄都没变)⇒ **不动窗口、不打日志**。
/// `true` 的四种情形:首次、换档、系统版本变化导致判定变化、首次拿到句柄。
///
/// 注意:判定不变而上次**下发失败**时这里也返回 `false`——失败原因(`pref=none hresult=…`)
/// 已经在那一行日志里了,重复重试只会刷屏;真要重试应该由「换档 / 重启」触发,不是每帧。
#[must_use]
pub fn needs_redispatch(previous: Option<&CornerApplied>, next: &CornerApplied) -> bool {
match previous {
None => true,
Some(previous) => previous != next,
}
}
#[cfg(test)]
mod tests;
@@ -216,3 +216,51 @@ fn the_log_line_shape_is_pinned_with_the_measured_readings() {
"{unknown}"
);
}
/// **同一策略 N 次只下发 / 只记 1 行**(M116 r1 复审 P2-4 的判据)。
///
/// 为什么要有这条:设置页的整窗 α / 面板 α / 暗化滑条每动一下都会触发一次外观变更,
/// 而圆角与它们无关。没有去重时拖一次滑条会刷出一串 `[corner] change …`,
/// 把「分辨有没有生效」的那唯一一行日志淹掉。
///
/// 抓得住:把去重删掉(`needs_redispatch` 恒 `true` 时这里会数到 N)、或指纹里漏掉某一维
/// (换档 / 首次拿到句柄就不重下了)。
/// 抓不住:真进程里到底打了几行(`eprintln!` 在 `App` 里,本测试只钉判定)。
#[test]
fn the_same_policy_is_dispatched_only_once() {
use super::{needs_redispatch, plan_corner, CornerApplied};
use crate::Backend;
use unlucky_presenter::prefs::{WindowCorner, WindowsVersion};
let os = WindowsVersion::new(10, 0, 26_100);
let plan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu);
// 模拟「滑条被拖动 20 次」:每次都是同一个策略、同一个系统、句柄一直在。
let mut last: Option<CornerApplied> = None;
let mut dispatches = 0;
for _ in 0..20 {
let next = CornerApplied::new(WindowCorner::System, &plan, true);
if needs_redispatch(last.as_ref(), &next) {
dispatches += 1;
last = Some(next);
}
}
assert_eq!(dispatches, 1, "同一策略连按 20 次只该下发/记 1 次");
// 换档必须重下。
let square = plan_corner(WindowCorner::Square, Some(os), Backend::Gpu);
assert!(needs_redispatch(
last.as_ref(),
&CornerApplied::new(WindowCorner::Square, &square, true)
));
// 句柄从无到有必须重下(首次拿到 HWND 时才真能设属性)。
let mut no_window: Option<CornerApplied> = None;
let first = CornerApplied::new(WindowCorner::System, &plan, false);
assert!(needs_redispatch(no_window.as_ref(), &first));
no_window = Some(first);
assert!(needs_redispatch(
no_window.as_ref(),
&CornerApplied::new(WindowCorner::System, &plan, true)
));
}
@@ -240,9 +240,13 @@ pub(super) fn dwm_set_u32(hwnd: isize, attribute: u32, value: u32) -> i32 {
/// `HRESULT` 的成功判据:**看严重性位**(bit 31),不是「等于 0」。
///
/// Win32 里成功码不只有 `S_OK`(0):`S_FALSE`(1) 与各种 `S_*` / 信息级码都是成功。
/// M116 实测:本机 DWM 回读偏好返回 `0x7FF8FFA9`(正数),`hr == 0` 判据会把它读成失败,
/// 于是「属性接受了吗」变成谜。本函数把这条判据收成一处,测试钉住正负两侧。
/// Win32 里成功码不只有 `S_OK`(0):`S_FALSE`(1) 与各种 `S_*` / 信息级码都是成功,所以判据必须
/// 是 `hr >= 0`。这条判据与 M116 探针的具体读数无关——它只是 `HRESULT` 的通用语义。
///
/// ⚠️ **别把 `0x7FF8FFA9` 当成 DWM 的返回值**(本分支一度这么写过,已订正):本机
/// `DwmGetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)` 三种窗口样式实测**全部**返回
/// `E_INVALIDARG`(`0x8007_0057`,负数);`0x7FF8FFA9` 是我早期用 `i32::unsigned_abs()` 打印
/// HRESULT 时的产物(把负数的补码取了绝对值),它既不是 `S_OK` 也不是任何 DWM 信息码。
#[must_use]
pub const fn hresult_ok(hr: i32) -> bool {
hr >= 0
@@ -287,7 +291,8 @@ pub(super) fn dwm_get_rect(hwnd: isize, attribute: u32) -> Option<WinRect> {
u32::try_from(std::mem::size_of::<WinRect>()).unwrap_or(16),
)
};
(hr == 0).then_some(rect)
// 与 `dwm_get_u32_hr` 同一条判据:`HRESULT` 看严重性位,不是「等于 0」(judgment 只留一处)。
hresult_ok(hr).then_some(rect)
}
/// `GetWindowRect`:窗口矩形(屏幕坐标)。失败返回 `None`。
@@ -26,8 +26,10 @@ pub const DWMWCP_DONOTROUND: u32 = 1;
pub const DWMWCP_ROUND: u32 = 2;
/// `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS`:可见边框厚度(像素,Win11 起)。
///
/// 这是本机**唯一**能拿到的「DWM 到底把这个窗口当不当有框窗口」的数值信号:
/// 圆角窗口带有一条 1px(按 DPI 缩放)的可见边框,直角窗口为 0。
/// ⚠️ **本机实测它不能当「圆不圆」的判据**(我最初以为能,被探针否掉了):
/// 三种窗口样式 × 三种状态(默认 / `DWMWCP_ROUND` / `DWMWCP_DONOTROUND`)读出来**全是 `1`**,
/// 没有任何区分度。保留它只作诊断读数(万一将来在别的机器上有变化),不参与判定。
/// 证据:`DevDocs/artifacts/m116/probe-table.md` 的 `bdef` / `brnd` / `bsq` 三列。
pub const DWMWA_VISIBLE_FRAME_BORDER_THICKNESS: u32 = 37;
/// `DWMWA_EXTENDED_FRAME_BOUNDS`:DWM 认为的窗口边界(**含**不可见的调整带)。
pub const DWMWA_EXTENDED_FRAME_BOUNDS: u32 = 9;
@@ -161,7 +163,7 @@ pub fn corner_preference_hr(hwnd: isize) -> (Option<u32>, u32) {
}
}
/// 读可见边框厚度(像素)。这是本机唯一能拿到的「DWM 把窗口当不当有框窗口」的数值信号。
/// 读可见边框厚度(像素)。**诊断用,不可作判据**(本机三样式三态实测全是 1,见常量文档)。
#[must_use]
pub fn visible_border_thickness(hwnd: isize) -> Option<u32> {
#[cfg(target_os = "windows")]
@@ -123,22 +123,29 @@ fn m116_corner_controlled_experiment() {
}
}
/// **M116 实测踩到的判据错误**:`DwmGetWindowAttribute` 在本机(RDP 断连)回读偏好返回
/// `0x7FF8FFA9`——**正数**(严重性位为 0 = 成功),但 `hr == 0` 的写法会把它读成失败,
/// 于是「属性到底设上没有」变成猜谜。
/// **成功判据的语义**:`HRESULT` 看严重性位(bit 31),不是「等于 0」——`S_FALSE`(1) 也算成功。
///
/// 抓得住:把成功判据写回 `hr == 0`(那会让本机永远报「读不回来」)。
/// 抓不住:DWM 具体返回哪个信息码(那是运行期读数)。
/// ⚠️ 这条测试**不是**「本机 DWM 会返回某个正数」的证据:本机
/// `DwmGetWindowAttribute` 三种窗口样式实测全 `E_INVALIDARG (0x80070057)`(失败)。
/// 下面那个 `0x7FF8_FFA9` 是**合成值**,只用来钉住「非零但严重性位为 0 ⇒ 成功」这条规则,
/// 以及钉住「`unsigned_abs()` 不能用来打印 HRESULT」这个打印坑。
///
/// 抓得住:把成功判据写回 `hr == 0`;把打印改回 `unsigned_abs()`。
/// 抓不住:DWM 在别的机器上返回什么(那是运行期读数)。
#[test]
fn hresult_success_follows_the_severity_bit_not_equality_with_zero() {
use super::hresult_ok;
assert!(hresult_ok(0), "S_OK");
assert!(hresult_ok(1), "S_FALSE 也是成功");
// 失败码:严重性位为 1(i32 负数)。
assert!(
hresult_ok(0x7FF8_FFA9),
"合成值:非零但严重性位为 0 ⇒ 按 HRESULT 语义是成功(不是 DWM 的实测返回值)"
);
// 失败码:严重性位为 1(i32 负数)。**本机探针实际拿到的就是这个**。
assert!(!hresult_ok(0x8007_0057_u32 as i32), "E_INVALIDARG");
assert!(!hresult_ok(-1));
// **打印规则**:失败码必须印成 `hr as u32` 的原始位。
// `unsigned_abs()` 会把 `0x8007_0057` 印成 `0x7FF8_FFA9`(M116 实测踩过),
// `unsigned_abs()` 会把 `0x8007_0057` 印成 `0x7FF8_FFA9`(M116 踩过,一次误读的源头),
// 于是「E_INVALIDARG」读起来像「一个信息级成功码」,整个证据链就毁了。
let e_invalidarg = 0x8007_0057_u32 as i32;
assert_eq!(e_invalidarg as u32, 0x8007_0057);