Unlucky Launcher v0.2.1
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
This commit is contained in:
commit
5046f96f7e
1092 files changed
+211068
No files matched your search
@@ -0,0 +1,301 @@
|
||||
//! 系统语言的**真值源**(D28):Windows 走 `reg.exe` 读注册表——**零 `unsafe`、零 FFI、零新依赖**。
|
||||
//!
|
||||
//! ## 为什么是子进程而不是 FFI
|
||||
//!
|
||||
//! 先例是 M73 的壁纸探测(`crates/core/src/platform/wallpaper/windows.rs`):同样是「Windows 上
|
||||
//! 只有注册表知道答案」的问题,它用 `reg.exe query` 解决,`unsafe` 一个没加。本 crate 的 `unsafe`
|
||||
//! 只允许出现在 [`crate::platform::win32`](D36 的纪律),所以这里照抄那条路。
|
||||
//!
|
||||
//! 为什么不 `GetUserDefaultLocaleName`:它要 FFI,而它对**本用途**并不比注册表准——Windows 的
|
||||
//! 「区域格式」与「显示语言」本来就是两个设置,两者都有各自的说法(见下面的诚实边界)。
|
||||
//!
|
||||
//! ## 读什么、按什么顺序
|
||||
//!
|
||||
//! 1. `HKCU\Control Panel\International` 的 `LocaleName`——Win7+ 都有,值就是 `zh-CN` 这种标签,
|
||||
//! [`unlucky_i18n::Lang::from_locale`] 直接吃得下;
|
||||
//! 2. 读不到 `LocaleName` 时退到同一个键的 `Locale`(8 位十六进制 LCID,如 `00000804`),
|
||||
//! 用一张**很短的**表翻成标签;表里没有的返回 `None`(宁可英文,也不猜错语种)。
|
||||
//!
|
||||
//! 优先级链在 [`unlucky_i18n::lang::resolve`]:**`ui.language` 显式值 > 本模块 > 环境变量 > 英文**。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:`reg query` 输出的解析(含 CRLF、空值、值名大小写)、`LocaleName` 缺失时退 LCID
|
||||
//! 的分支、命令失败/缺失时的 `None`;
|
||||
//! - **抓不住**:①`LocaleName` 是**区域格式**,用户完全可能在设置里把「显示语言」改成英文而区域
|
||||
//! 仍是 `zh-CN`(或反过来)——真要读显示语言得 `GetUserDefaultUILanguage`(FFI)或多语言
|
||||
//! 用户界面注册表(`...\Control Panel\Desktop` 的 `PreferredUILanguages`,本 mission 未做);
|
||||
//! ②策略禁用 `reg.exe` / Wine 环境 → `None` → 落到环境变量 → 英文;
|
||||
//! ③代码页(`chcp`)与这里无关,注册表值不受它影响。
|
||||
|
||||
use unlucky_i18n::lang::LocaleSource;
|
||||
|
||||
/// 语言相关的注册表键。
|
||||
const INTERNATIONAL_KEY: &str = r"HKCU\Control Panel\International";
|
||||
|
||||
/// 首选值名:值是 BCP-47 风格标签(`zh-CN`)。
|
||||
const LOCALE_NAME_VALUE: &str = "LocaleName";
|
||||
|
||||
/// 兜底值名:值是 8 位十六进制 LCID(`00000804`)。
|
||||
const LOCALE_VALUE: &str = "Locale";
|
||||
|
||||
/// 常见 LCID → 语言的短表(**故意短**:不认识的返回 `None`,让链路落到英文)。
|
||||
///
|
||||
/// 覆盖面:中文四地 + 英语三地 + 几个大语种。这张表只在 `LocaleName` 缺失时用到,
|
||||
/// 而 `LocaleName` 从 Win7 起就有,所以它实际上是「老系统 / 被裁剪的系统」专用。
|
||||
const LCID_TAGS: &[(&str, &str)] = &[
|
||||
("00000804", "zh-CN"),
|
||||
("00000404", "zh-TW"),
|
||||
("00000c04", "zh-HK"),
|
||||
("00001004", "zh-SG"),
|
||||
("00000409", "en-US"),
|
||||
("00000809", "en-GB"),
|
||||
("00000c09", "en-AU"),
|
||||
("00000411", "ja-JP"),
|
||||
("00000412", "ko-KR"),
|
||||
("00000407", "de-DE"),
|
||||
("0000040c", "fr-FR"),
|
||||
("00000410", "it-IT"),
|
||||
("00000c0a", "es-ES"),
|
||||
("00000416", "pt-BR"),
|
||||
("00000419", "ru-RU"),
|
||||
("00000415", "pl-PL"),
|
||||
("0000041f", "tr-TR"),
|
||||
("00000406", "da-DK"),
|
||||
("0000040b", "fi-FI"),
|
||||
("00000413", "nl-NL"),
|
||||
];
|
||||
|
||||
/// 跑外部命令的接口(注入点:单测不 spawn 进程)。
|
||||
pub trait RegIo {
|
||||
/// 跑一条命令取 stdout;命令缺失 / 非零退出 / 空输出都返回 `None`。
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
|
||||
}
|
||||
|
||||
/// 真实实现:`reg.exe` 子进程(非 Windows 上恒 `None`)。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HostReg;
|
||||
|
||||
impl RegIo for HostReg {
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
if !cfg!(windows) {
|
||||
// 其它平台的「系统语言」真值源不是注册表;`resolve` 会继续往环境变量走。
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
// `Command::output` 对非零退出同样返回 `Ok`,退出码要单独判(`reg.exe` 查不到值就是非零)。
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统语言源(实现 [`LocaleSource`],日志里自称 `registry`)。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SystemLocale;
|
||||
|
||||
impl LocaleSource for SystemLocale {
|
||||
fn locale(&self) -> Option<String> {
|
||||
detect_with(&HostReg)
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"registry"
|
||||
}
|
||||
}
|
||||
|
||||
/// 用给定 IO 读一次系统语言(`LocaleName` 优先,退 `Locale` 的 LCID)。
|
||||
pub fn detect_with(io: &dyn RegIo) -> Option<String> {
|
||||
let by_name = io
|
||||
.run(
|
||||
"reg.exe",
|
||||
&["query", INTERNATIONAL_KEY, "/v", LOCALE_NAME_VALUE],
|
||||
)
|
||||
.and_then(|text| parse_reg_sz(&text, LOCALE_NAME_VALUE))
|
||||
.filter(|value| looks_like_tag(value));
|
||||
if by_name.is_some() {
|
||||
return by_name;
|
||||
}
|
||||
|
||||
io.run("reg.exe", &["query", INTERNATIONAL_KEY, "/v", LOCALE_VALUE])
|
||||
.and_then(|text| parse_reg_sz(&text, LOCALE_VALUE))
|
||||
.and_then(|lcid| lcid_tag(&lcid))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// 取 `reg query <键> /v <值名>` 输出里某个 `REG_SZ` 值的原文。
|
||||
///
|
||||
/// 真机输出形如(本机 Windows 11 24H2 实测,CRLF):
|
||||
///
|
||||
/// ```text
|
||||
/// HKEY_CURRENT_USER\Control Panel\International
|
||||
/// LocaleName REG_SZ zh-CN
|
||||
/// ```
|
||||
///
|
||||
/// 值名大小写不敏感(`reg.exe` 原样回显查询时给的名字);值可能含空格,所以「值名 + 类型」
|
||||
/// 之后整段取原文,不 `split_whitespace` 再拼(会吃掉连续空格)。
|
||||
pub fn parse_reg_sz(text: &str, value_name: &str) -> Option<String> {
|
||||
text.lines().find_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let name_end = trimmed.find(char::is_whitespace)?;
|
||||
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
|
||||
return None;
|
||||
}
|
||||
let after_name = trimmed[name_end..].trim_start();
|
||||
let kind_end = after_name.find(char::is_whitespace)?;
|
||||
let value = after_name[kind_end..].trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// LCID(十六进制原文,如 `00000804`)→ 标签(大小写不敏感)。
|
||||
pub fn lcid_tag(lcid: &str) -> Option<&'static str> {
|
||||
LCID_TAGS
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(lcid.trim()))
|
||||
.map(|(_, tag)| *tag)
|
||||
}
|
||||
|
||||
/// 像不像一个语言标签(`zh-CN` / `en` / `zh-Hans-CN`)。
|
||||
///
|
||||
/// 只拦明显不是标签的东西(空值、带空格、带路径分隔符)——`reg.exe` 在某些策略下会回
|
||||
/// `REG_SZ` 空串或一长串本地化文本,那些不该被当成语言喂给 `from_locale`。
|
||||
fn looks_like_tag(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 35
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 本机 Windows 11 24H2 实测抓下来的两段原文(含 `\r\n`)。
|
||||
const REAL_LOCALE_NAME: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n LocaleName REG_SZ zh-CN\r\n\r\n";
|
||||
const REAL_LOCALE: &str = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n Locale REG_SZ 00000804\r\n\r\n";
|
||||
|
||||
struct FakeReg {
|
||||
name: Option<&'static str>,
|
||||
locale: Option<&'static str>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl FakeReg {
|
||||
fn both(name: Option<&'static str>, locale: Option<&'static str>) -> Self {
|
||||
FakeReg {
|
||||
name,
|
||||
locale,
|
||||
fail: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RegIo for FakeReg {
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
assert_eq!(program, "reg.exe", "程序名写错就不是注册表读了");
|
||||
assert_eq!(args[0], "query");
|
||||
assert_eq!(args[1], INTERNATIONAL_KEY);
|
||||
assert_eq!(args[2], "/v");
|
||||
if self.fail {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = match args[3] {
|
||||
LOCALE_NAME_VALUE => self.name?,
|
||||
LOCALE_VALUE => self.locale?,
|
||||
other => panic!("没料到的值名:{other}"),
|
||||
};
|
||||
Some(format!(
|
||||
"\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n {} REG_SZ {raw}\r\n\r\n",
|
||||
args[3]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_registry_output_parses_to_the_locale_tag() {
|
||||
// 抓得住:解析器与真机 `reg query` 输出格式之间的契约(CRLF、前导空白、值名与类型之间的
|
||||
// 多个空格)。这两段夹具是真机抓的原文,不是我编的。
|
||||
// 抓不住:这台机器的值本身会变(用户改区域设置)——测试只钉格式,不钉具体语言。
|
||||
assert_eq!(
|
||||
parse_reg_sz(REAL_LOCALE_NAME, LOCALE_NAME_VALUE).as_deref(),
|
||||
Some("zh-CN")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_reg_sz(REAL_LOCALE, LOCALE_VALUE).as_deref(),
|
||||
Some("00000804")
|
||||
);
|
||||
// 值名大小写不敏感(reg.exe 会原样回显查询里给的名字)
|
||||
assert_eq!(
|
||||
parse_reg_sz(REAL_LOCALE_NAME, "localename").as_deref(),
|
||||
Some("zh-CN")
|
||||
);
|
||||
// 空值 = 没设(不是空串语言)
|
||||
let empty = " LocaleName REG_SZ \r\n";
|
||||
assert_eq!(parse_reg_sz(empty, LOCALE_NAME_VALUE), None);
|
||||
// 别的值名不该被误取
|
||||
assert_eq!(parse_reg_sz(REAL_LOCALE_NAME, "Locale"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lcid_fallback_covers_chinese_and_english() {
|
||||
// 抓得住:LCID 兜底表的关键格子(中文四地 + 英语三地)被改动或删掉;
|
||||
// 以及「不认识就 None」这条(不许猜成某个语言)。
|
||||
// 抓不住:这 20 条之外的语言——没有 `LocaleName` 的老系统上它们会落到英文。
|
||||
assert_eq!(lcid_tag("00000804"), Some("zh-CN"));
|
||||
assert_eq!(lcid_tag("00000804\r"), Some("zh-CN"), "要去空白");
|
||||
assert_eq!(lcid_tag("00000404"), Some("zh-TW"));
|
||||
assert_eq!(lcid_tag("00000C04"), Some("zh-HK"), "大小写不敏感");
|
||||
assert_eq!(lcid_tag("00001004"), Some("zh-SG"));
|
||||
assert_eq!(lcid_tag("00000409"), Some("en-US"));
|
||||
assert_eq!(lcid_tag("00000411"), Some("ja-JP"));
|
||||
assert_eq!(lcid_tag("00000437"), None, "表外语言必须 None");
|
||||
assert_eq!(lcid_tag(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_prefers_locale_name_and_falls_back_to_lcid() {
|
||||
// 抓得住:两条取值路径的顺序(有 LocaleName 就不该再看 LCID),以及命令失败时的 None。
|
||||
// 抓不住:真机注册表里到底有什么(下面的 windows_live 测试才碰真机)。
|
||||
assert_eq!(
|
||||
detect_with(&FakeReg::both(Some("zh-CN"), Some("00000409"))).as_deref(),
|
||||
Some("zh-CN"),
|
||||
"LocaleName 优先"
|
||||
);
|
||||
assert_eq!(
|
||||
detect_with(&FakeReg::both(None, Some("00000804"))).as_deref(),
|
||||
Some("zh-CN"),
|
||||
"没有 LocaleName 才退 LCID"
|
||||
);
|
||||
assert_eq!(detect_with(&FakeReg::both(None, None)), None);
|
||||
assert_eq!(
|
||||
detect_with(&FakeReg {
|
||||
name: Some("zh-CN"),
|
||||
locale: Some("00000804"),
|
||||
fail: true,
|
||||
}),
|
||||
None,
|
||||
"命令失败就是 None,不许猜"
|
||||
);
|
||||
// 值名对但值不像标签(策略回了一串本地化文本)→ 不许当成语言
|
||||
assert_eq!(detect_with(&FakeReg::both(Some("中文(简体)"), None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_live_registry_query_returns_a_tag() {
|
||||
// 抓得住:`reg.exe` 这条路在本机真的通(spawn 成功、退出码为 0、输出能被解析)。
|
||||
// 抓不住:它读到的「区域格式」是否等于用户想用的**界面语言**(见模块文档的诚实边界)。
|
||||
let tag = SystemLocale.locale().expect("本机应当读得到 LocaleName");
|
||||
assert!(!tag.is_empty());
|
||||
assert!(looks_like_tag(&tag), "解析出来的不是语言标签:{tag:?}");
|
||||
assert_eq!(SystemLocale.label(), "registry");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点,
|
||||
//! 以及系统语言真值源,三件事分居三处。
|
||||
//!
|
||||
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
||||
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
||||
//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 →
|
||||
//! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明;
|
||||
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` +
|
||||
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
|
||||
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
|
||||
//!
|
||||
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
|
||||
//! 窗口级 α)**可以**被断言证明;而 FFI 的**视觉**结果在本仓库的验证环境(RDP + 虚拟显示
|
||||
//! 适配器)上没有参考价值。把可测的那一半独立出来,才有「有测试的判据 + 没测试的观感」这条
|
||||
//! 诚实的分界。
|
||||
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
use crate::Backend;
|
||||
|
||||
pub mod locale;
|
||||
pub mod win32;
|
||||
|
||||
/// 窗口圆角的**判定**(M116)住在 `win32::corner`:判据是 Windows 版本与 DWM 能力,
|
||||
/// 非 Windows 平台在这里恒得「直角」(平台没有可承诺的系统圆角)。
|
||||
pub use win32::corner::{plan_corner, CornerExclusion, CornerPlan, CornerSource};
|
||||
|
||||
/// 本机 Windows 版本(`None` = 非 Windows / 读不到)。**进程内只探测一次**。
|
||||
///
|
||||
/// 为什么缓存:底层是 `reg.exe` 子进程(`unlucky_core::platform::windows_version`,实测约 50 ms),
|
||||
/// 而圆角判定会在启动、切档、热重载时各问一次。探测结果在进程生命周期内不会变。
|
||||
///
|
||||
/// 为什么走 `unlucky_presenter::prefs` 的重导出而不是 `unlucky_core` 直连:
|
||||
/// `unlucky-app-iced` 不直接依赖 core(分层:UI → presenter → core),这是既有的唯一通道。
|
||||
#[must_use]
|
||||
pub fn current_windows_version() -> Option<unlucky_presenter::prefs::WindowsVersion> {
|
||||
static CACHED: std::sync::OnceLock<Option<unlucky_presenter::prefs::WindowsVersion>> =
|
||||
std::sync::OnceLock::new();
|
||||
*CACHED.get_or_init(unlucky_presenter::prefs::probe_windows_version)
|
||||
}
|
||||
|
||||
/// 用户请求的整窗 α → 真正写进窗口的值(纯函数)。
|
||||
///
|
||||
/// - `requested <= 0.0`(含 `-0.0`)→ `None`:**拒绝整窗穿透**。α = 0 表示窗口内容全透明,
|
||||
/// 用户会以为程序消失/卡死,所以这不是「合法的最小值」而是「不给设」(D36)。
|
||||
/// 非有限值(`NaN` / `±∞`)同样 `None`——它们没有合法落点,也不该被 `clamp` 悄悄吞掉。
|
||||
/// - 其余一律夹到 [`WINDOW_ALPHA_MIN`]`..=1.0`。
|
||||
/// - 后端**不**参与「能不能用窗口级 α」的判断:D36 **修订**了 D27 的旧结论——
|
||||
/// CPU 渲染档同样允许整窗 α(CPU 档被限制的是面板 α / 玻璃 / 模糊,不是窗口 α)。
|
||||
/// 因此三个后端分支给出同一个值;写成 `match` 是为了让「将来真分档」只改这一处,
|
||||
/// 而不是让读代码的人以为这里漏了一个分支。
|
||||
#[must_use]
|
||||
pub fn effective_window_alpha(backend: Backend, requested: f64) -> Option<f64> {
|
||||
if !requested.is_finite() || requested <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
match backend {
|
||||
Backend::Gpu | Backend::Cpu | Backend::Auto => Some(requested.clamp(WINDOW_ALPHA_MIN, 1.0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::effective_window_alpha;
|
||||
use crate::Backend;
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
const BACKENDS: [Backend; 3] = [Backend::Gpu, Backend::Cpu, Backend::Auto];
|
||||
|
||||
#[test]
|
||||
fn truth_table_valid_alpha_passes_through_every_backend() {
|
||||
// GPU / CPU × 合法值:四个格子都要 Some(CPU 档不再强制不透明)。
|
||||
for backend in BACKENDS {
|
||||
assert_eq!(
|
||||
effective_window_alpha(backend, 0.5),
|
||||
Some(0.5),
|
||||
"{backend:?}:合法 α 原样返回"
|
||||
);
|
||||
assert_eq!(effective_window_alpha(backend, 1.0), Some(1.0));
|
||||
assert_eq!(
|
||||
effective_window_alpha(backend, WINDOW_ALPHA_MIN),
|
||||
Some(WINDOW_ALPHA_MIN)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truth_table_out_of_range_is_clamped_not_refused() {
|
||||
for backend in BACKENDS {
|
||||
assert_eq!(
|
||||
effective_window_alpha(backend, 0.001),
|
||||
Some(WINDOW_ALPHA_MIN),
|
||||
"{backend:?}:正数低于下限 → 夹到 0.08(钳位,不拒绝)"
|
||||
);
|
||||
assert_eq!(
|
||||
effective_window_alpha(backend, 9.0),
|
||||
Some(1.0),
|
||||
"{backend:?}:高于 1.0 → 夹到 1.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truth_table_non_positive_is_refused_on_every_backend() {
|
||||
for backend in BACKENDS {
|
||||
for requested in [0.0, -0.0, -0.01, -1.0] {
|
||||
assert_eq!(
|
||||
effective_window_alpha(backend, requested),
|
||||
None,
|
||||
"{backend:?}:requested={requested} → 拒绝整窗穿透"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truth_table_non_finite_is_refused() {
|
||||
// NaN 用 `<= 0.0` 判不出来(比较恒 false),所以必须先 `is_finite()`;
|
||||
// 否则 NaN 会穿到 `clamp` 里再原样返回,变成「给窗口一个 NaN」。
|
||||
for backend in BACKENDS {
|
||||
for requested in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(
|
||||
effective_window_alpha(backend, requested),
|
||||
None,
|
||||
"{backend:?}:requested={requested} 不是有限值"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_tier_is_not_forced_opaque() {
|
||||
// D36 对 D27 的**修订**:旧文档写「CPU 档一律不透明」,这条测试钉住修订后的行为,
|
||||
// 防止谁照着旧文档把 CPU 分支改回 `None`。
|
||||
assert_eq!(effective_window_alpha(Backend::Cpu, 0.5), Some(0.5));
|
||||
assert_eq!(effective_window_alpha(Backend::Gpu, 0.5), Some(0.5));
|
||||
}
|
||||
|
||||
/// `appearance.palette_source` 的码值必须与 `design` 的 `Level::code()` 逐字一致。
|
||||
///
|
||||
/// 为什么这条断言住在这里:`unlucky-core` 按分层纪律不依赖 `unlucky-design`,
|
||||
/// 它的测试**看不到** design,所以 core 自己只能钉字面量。本 crate 同时依赖
|
||||
/// `unlucky-design`(`Level`)与 `unlucky-presenter`(重导出 `PaletteSource`),
|
||||
/// 是全仓唯一能真做这次对照的地方。
|
||||
///
|
||||
/// 抓得住:design 侧改任一档的稳定码(例如把 `wallpaper` 改成 `desktop`)、或有人改了
|
||||
/// core 的字符串表——两个方向都红(下面往返那条覆盖「只改一侧」)。
|
||||
/// 抓不住:档位的**语义**是否仍对齐(只保证字符串一致),以及 UI 是否真按这个码值取色。
|
||||
#[test]
|
||||
fn palette_source_codes_match_design_level_codes() {
|
||||
use unlucky_design::palette::Level;
|
||||
use unlucky_presenter::prefs::PaletteSource;
|
||||
|
||||
for (source, level) in [
|
||||
(PaletteSource::BackgroundImage, Level::BackgroundImage),
|
||||
(PaletteSource::Wallpaper, Level::Wallpaper),
|
||||
(PaletteSource::Preset, Level::Preset),
|
||||
] {
|
||||
assert_eq!(
|
||||
source.as_str(),
|
||||
level.code(),
|
||||
"core 的 {source:?} 码值与 design 的 {level:?} 不一致"
|
||||
);
|
||||
// 反方向:design 的码值喂回 core 必须还原成同一档(防「两个字符串互换了」)。
|
||||
assert_eq!(PaletteSource::from_key_value(level.code()), source);
|
||||
// 且必须真的在 core 的允许值表里(否则 schema 会拒绝写这个值)。
|
||||
assert!(unlucky_presenter::prefs::PALETTE_SOURCES.contains(&level.code()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! M116:窗口外框圆角的**判据**(纯函数,零 `unsafe`、零 FFI、零环境读取)。
|
||||
//!
|
||||
//! ## 判据为什么单独拎出来
|
||||
//!
|
||||
//! 「能不能圆」= 用户策略 × 系统版本 × 渲染档,这三件事**可以被断言证明**;而 DWM 的**视觉**
|
||||
//! 结果在本仓库的验证环境(RDP 断连、抓屏失效)上没有参考价值。把可测的那一半独立出来,
|
||||
//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`]
|
||||
//! 同一套分法)。
|
||||
//!
|
||||
//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。
|
||||
//!
|
||||
//! ## 平台矩阵(哪些是实测、哪些是推断)
|
||||
//!
|
||||
//! | 平台 | 我们做什么 | 依据级别 |
|
||||
//! |---|---|---|
|
||||
//! | 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 的调整带只给**有 `WS_CAPTION|WS_THICKFRAME` 的窗口**:`EXTENDED_FRAME_BOUNDS −
|
||||
//! GetWindowRect` 在有边框窗口上是 `+0,−7`,在**裸 `WS_POPUP` 假形状**上是 `+0,+0`。
|
||||
//!
|
||||
//! ⚠️ **第 ③ 条不能拿裸 popup 的读数去解释产品窗口**(M116 r4 复审订正):产品窗口
|
||||
//! **不是** `WS_POPUP`(winit 只在带 owner 的弹出窗才加它;无边框观感来自 `WM_NCCALCSIZE`),
|
||||
//! 它带 `WS_CAPTION|WS_THICKFRAME` ⇒ 按同一张表应得 `+0,−7`,与 `decorated` 同族。
|
||||
//! 探针里 `product` / `product+alpha` 两行量的就是这个真实形状。
|
||||
//!
|
||||
//! ④ **整窗 α 会关掉圆角**(tower 裁定 A / D47:整窗透明度优先于 DWM 圆角):α < 1 时我们补
|
||||
//! `WS_EX_LAYERED`,此时系统会忽略圆角请求——请求照样发出(日志里 `pref=` 有值),
|
||||
//! 只是不生效;α 回到 1.0 就恢复。日志用 `suppressed_by=window_alpha` 记这一条**观测事实**。
|
||||
//!
|
||||
//! 「圆角长什么样」归真机验收,证据目录见 `DevDocs/artifacts/m116/`。
|
||||
//!
|
||||
//! 后两行在本 crate 里落到同一格:探测不到 Windows 版本 ⇒ `UnknownOs` ⇒ 直角。
|
||||
//! 这是「不承诺」,不是「做不到」——两者的区别写在日志的原因码里。
|
||||
|
||||
use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD};
|
||||
|
||||
use crate::Backend;
|
||||
|
||||
/// 一次「圆角该从哪来」的判定结果(M116)。
|
||||
///
|
||||
/// 只有两种来源,且**互相排斥**:
|
||||
///
|
||||
/// | 来源 | 谁画圆角 | 我们做什么 |
|
||||
/// |---|---|---|
|
||||
/// | [`CornerSource::Dwm`] | 系统合成器(是否真画、半径多少都不由我们定,也读不到) | 只下发 `DWMWA_WINDOW_CORNER_PREFERENCE` |
|
||||
/// | [`CornerSource::Square`] | 没人 | 明确下发 `DWMWCP_DONOTROUND`,把「方」也变成可观测的选择 |
|
||||
///
|
||||
/// **没有 `Self`(自绘圆角)这一档**,这是刻意的(用户定案 2026-11-03 + tower 裁定):
|
||||
/// 窗口是 `decorations=false` + 自绘标题栏 + 整窗 `LWA_ALPHA`,自绘圆角意味着把内容层裁成圆角,
|
||||
/// 那会把标题栏三键与边缘缩放热区一起裁掉;系统也不暴露圆角半径(无 API 可读),
|
||||
/// 「固定 8/12/16」只是凭空维护一个数字。所以非 DWM 平台**就是直角**,并且日志里说明原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CornerSource {
|
||||
/// DWM 系统圆角(Win11 / Server 2025 内核,build ≥ [`WIN11_MIN_BUILD`])。
|
||||
Dwm,
|
||||
/// 直角(用户选择、系统不支持、或该平台不承诺)。
|
||||
Square,
|
||||
}
|
||||
|
||||
impl CornerSource {
|
||||
/// 日志取值(`corner=… source=dwm|square`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Dwm => "dwm",
|
||||
Self::Square => "square",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否要求 DWM 真去画圆角。
|
||||
#[must_use]
|
||||
pub const fn is_dwm(self) -> bool {
|
||||
matches!(self, Self::Dwm)
|
||||
}
|
||||
}
|
||||
|
||||
/// 为什么**不是** DWM 圆角(`None` = 就是 DWM 圆角)。日志里逐字打出来。
|
||||
///
|
||||
/// 为什么要这个枚举而不是一个 `bool`:用户报的现象是「别的窗口都有圆角,只有我们的没有」,
|
||||
/// 于是「没圆角」至少有四种完全不同的原因,混成一个 `square` 以后没人能分辨
|
||||
/// 「没生效」与「生效了但看不见」(M116 任务书原话)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CornerExclusion {
|
||||
/// 用户在设置里选了「直角」。
|
||||
UserSquare,
|
||||
/// 系统太老:Win10 及更早(build < [`WIN11_MIN_BUILD`])——DWM 没有这个偏好属性。
|
||||
OsBuild,
|
||||
/// 版本探测不出来(非 Windows / 注册表读不到 / 有东西在骗版本号)。
|
||||
UnknownOs,
|
||||
/// 非 DWM 平台上的软件渲染档:圆角只能靠自绘/`SetWindowRgn` 毛边路,D36 的
|
||||
/// 「CPU 档不碰合成技巧」优先,所以退回直角。
|
||||
CpuTier,
|
||||
}
|
||||
|
||||
impl CornerExclusion {
|
||||
/// 日志取值(`excluded_reason=…`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::UserSquare => "user",
|
||||
Self::OsBuild => "os-build",
|
||||
Self::UnknownOs => "unknown-os",
|
||||
Self::CpuTier => "cpu-tier",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定结果:来源 + 排除原因 + 平台版本(日志与测试共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CornerPlan {
|
||||
/// 圆角从哪来。
|
||||
pub source: CornerSource,
|
||||
/// `None` = 走 DWM;`Some(..)` = 走直角,并说明为什么。
|
||||
pub excluded: Option<CornerExclusion>,
|
||||
/// 探测到的 Windows 版本(`None` = 不是 Windows / 读不到)。
|
||||
pub os: Option<WindowsVersion>,
|
||||
}
|
||||
|
||||
impl CornerPlan {
|
||||
/// 日志行:`corner=policy=system source=dwm os=10.0.26100 excluded_reason=none radius=system`。
|
||||
///
|
||||
/// **`radius=system` 是刻意的**:系统不暴露 DWM 的圆角半径,我们也不自己定一个数字
|
||||
/// (用户定案取消了 8/12/16 档),所以这一栏永远只说「系统决定」。
|
||||
#[must_use]
|
||||
pub fn log_line(&self, policy: WindowCorner) -> String {
|
||||
let os = self
|
||||
.os
|
||||
.map_or_else(|| "unknown".to_owned(), |os| os.version_string());
|
||||
format!(
|
||||
"corner=policy={} source={} os={os} excluded_reason={} radius=system",
|
||||
policy.as_str(),
|
||||
self.source.as_str(),
|
||||
self.excluded.map_or("none", CornerExclusion::as_str),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定「这一次窗口该不该有系统圆角」(纯函数,不调 FFI、不读环境)。
|
||||
///
|
||||
/// 真值表(`backend` 只影响**非 DWM** 平台,见下):
|
||||
///
|
||||
/// | `policy` | `os.build` | backend | `source` | `excluded` |
|
||||
/// |---|---|---|---|---|
|
||||
/// | `Square` | 任意 | 任意 | `Square` | `UserSquare` |
|
||||
/// | `System` | `None` | 任意 | `Square` | `UnknownOs` |
|
||||
/// | `System` | `< 22000` | GPU/Auto | `Square` | `OsBuild` |
|
||||
/// | `System` | `< 22000` | CPU | `Square` | `CpuTier` |
|
||||
/// | `System` | `>= 22000` | 任意 | `Dwm` | `None` |
|
||||
///
|
||||
/// 为什么 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,
|
||||
os: Option<WindowsVersion>,
|
||||
backend: Backend,
|
||||
) -> CornerPlan {
|
||||
let square = |excluded| CornerPlan {
|
||||
source: CornerSource::Square,
|
||||
excluded: Some(excluded),
|
||||
os,
|
||||
};
|
||||
|
||||
if policy == WindowCorner::Square {
|
||||
return square(CornerExclusion::UserSquare);
|
||||
}
|
||||
match os {
|
||||
None => square(CornerExclusion::UnknownOs),
|
||||
Some(os) if os.build < WIN11_MIN_BUILD => match backend {
|
||||
Backend::Cpu => square(CornerExclusion::CpuTier),
|
||||
Backend::Gpu | Backend::Auto => square(CornerExclusion::OsBuild),
|
||||
},
|
||||
Some(_) => CornerPlan {
|
||||
source: CornerSource::Dwm,
|
||||
excluded: None,
|
||||
os,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 产品日志行的**唯一格式化落点**(纯函数:把四段文本拼成一个固定形状)。
|
||||
///
|
||||
/// ```text
|
||||
/// [corner] <context> corner=policy=system source=dwm os=10.0.26100 excluded_reason=none \
|
||||
/// radius=system pref=2 readback=? read_hr=0x80070057 frame=overlapped layered=on \
|
||||
/// suppressed_by=window_alpha
|
||||
/// ```
|
||||
///
|
||||
/// 为什么单独拎出来:`App::apply_window_corner` 要真句柄、真 DWM 才能跑,而**这一行的形状**
|
||||
/// 恰恰是「以后分辨没生效 / 生效了看不见」的唯一依据(M116 任务书原话)。形状必须在没有窗口的
|
||||
/// 环境里也能被断言——tower 2026-11-03 明确要求:不为了这行日志去构建 bin。
|
||||
///
|
||||
/// 参数里 `outcome` / `style` 由调用方给(`CornerOutcome::describe()` / `style_summary(hwnd)`),
|
||||
/// 本文件不依赖 FFI 层,因此可以在非 Windows 目标上编、也能被单测覆盖。
|
||||
///
|
||||
/// `suppressed_by` 是**观测事实**(tower 裁定 A / D47 要求的那一栏):整窗 α < 1 时我们补了
|
||||
/// `WS_EX_LAYERED`,此时系统忽略圆角请求。配合同一行里的 `pref=`(请求确实发出去了)与
|
||||
/// `layered=`(分层位确实在),读者能自己推出「不是我们没发请求」——本函数不写结论性措辞。
|
||||
#[must_use]
|
||||
pub fn format_line(
|
||||
context: &str,
|
||||
plan: &CornerPlan,
|
||||
policy: WindowCorner,
|
||||
outcome: &str,
|
||||
style: &str,
|
||||
suppressed_by: Option<&'static str>,
|
||||
) -> String {
|
||||
let mut line = format!(
|
||||
"[corner] {context} {} {outcome} {style}",
|
||||
plan.log_line(policy)
|
||||
);
|
||||
if let Some(reason) = suppressed_by {
|
||||
line.push(' ');
|
||||
line.push_str("suppressed_by=");
|
||||
line.push_str(reason);
|
||||
}
|
||||
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,
|
||||
/// 整窗 α 是否 < 1(= 我们补了 `WS_EX_LAYERED`,系统会忽略圆角请求)。
|
||||
///
|
||||
/// 进指纹是为了让「α 从 1.0 拉低 / 拉回 1.0」各触发一次重下与一行日志——那两次的
|
||||
/// 系统行为确实不同,不重记就看不出来。
|
||||
pub suppressed_by_window_alpha: bool,
|
||||
}
|
||||
|
||||
impl CornerApplied {
|
||||
/// 由判定 + 「有没有句柄」+ 「整窗 α 是否补了分层」构造指纹。
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
policy: WindowCorner,
|
||||
plan: &CornerPlan,
|
||||
has_window: bool,
|
||||
suppressed_by_window_alpha: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
policy,
|
||||
source: plan.source,
|
||||
excluded: plan.excluded,
|
||||
has_window,
|
||||
suppressed_by_window_alpha,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一次要不要真的下发 / 记日志(纯函数)。
|
||||
///
|
||||
/// `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;
|
||||
@@ -0,0 +1,289 @@
|
||||
//! `win32::corner` 的单测(M116 从 `corner.rs` 搬出来:那个文件要守 400 行上限,
|
||||
//! 而日志行的形状断言本身就有几十行)。
|
||||
//!
|
||||
//! 判据与真实读数的出处:`DevDocs/artifacts/m116/probe-table.md`。
|
||||
|
||||
use super::{format_line, plan_corner, CornerExclusion, CornerPlan, CornerSource};
|
||||
use crate::Backend;
|
||||
use unlucky_presenter::prefs::{WindowCorner, WindowsVersion, WIN11_MIN_BUILD};
|
||||
|
||||
/// 本机实测版本(Server 2025 / build 26100)。
|
||||
fn win11() -> WindowsVersion {
|
||||
WindowsVersion::new(10, 0, 26_100)
|
||||
}
|
||||
|
||||
/// Win10 22H2(build 19045):DWM 没有圆角偏好属性。
|
||||
fn win10() -> WindowsVersion {
|
||||
WindowsVersion::new(10, 0, 19_045)
|
||||
}
|
||||
|
||||
/// **抓得住**:判定链的顺序与每一格的原因码(把 `policy`/`os`/`backend` 任一条判反都会红)。
|
||||
/// **抓不住**:DWM 到底有没有真画圆角(那要像素,见 `platform/win32` 的探针输出)。
|
||||
#[test]
|
||||
fn truth_table_policy_os_backend() {
|
||||
let cases = [
|
||||
// policy=Square:任何系统/后端都直角,原因是用户选的。
|
||||
(
|
||||
WindowCorner::Square,
|
||||
Some(win11()),
|
||||
Backend::Gpu,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::UserSquare),
|
||||
),
|
||||
(
|
||||
WindowCorner::Square,
|
||||
Some(win10()),
|
||||
Backend::Cpu,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::UserSquare),
|
||||
),
|
||||
(
|
||||
WindowCorner::Square,
|
||||
None,
|
||||
Backend::Auto,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::UserSquare),
|
||||
),
|
||||
// policy=System + 探测不出系统:不许猜「Win11」。
|
||||
(
|
||||
WindowCorner::System,
|
||||
None,
|
||||
Backend::Gpu,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::UnknownOs),
|
||||
),
|
||||
// Win10:直角,GPU 档原因是版本、CPU 档原因是渲染档(同一个直角,原因不同)。
|
||||
(
|
||||
WindowCorner::System,
|
||||
Some(win10()),
|
||||
Backend::Gpu,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::OsBuild),
|
||||
),
|
||||
(
|
||||
WindowCorner::System,
|
||||
Some(win10()),
|
||||
Backend::Auto,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::OsBuild),
|
||||
),
|
||||
(
|
||||
WindowCorner::System,
|
||||
Some(win10()),
|
||||
Backend::Cpu,
|
||||
CornerSource::Square,
|
||||
Some(CornerExclusion::CpuTier),
|
||||
),
|
||||
// Win11 / Server 2025:三个后端都用 DWM(D36 只管我们自己的合成技巧)。
|
||||
(
|
||||
WindowCorner::System,
|
||||
Some(win11()),
|
||||
Backend::Gpu,
|
||||
CornerSource::Dwm,
|
||||
None,
|
||||
),
|
||||
(
|
||||
WindowCorner::System,
|
||||
Some(win11()),
|
||||
Backend::Cpu,
|
||||
CornerSource::Dwm,
|
||||
None,
|
||||
),
|
||||
(
|
||||
WindowCorner::System,
|
||||
Some(win11()),
|
||||
Backend::Auto,
|
||||
CornerSource::Dwm,
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
for (policy, os, backend, want_source, want_excluded) in cases {
|
||||
let plan = plan_corner(policy, os, backend);
|
||||
assert_eq!(
|
||||
plan.source, want_source,
|
||||
"policy={policy:?} os={os:?} backend={backend:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.excluded, want_excluded,
|
||||
"policy={policy:?} os={os:?} backend={backend:?} 的原因码"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 边界:`WIN11_MIN_BUILD` **本身**算 Win11(`>=`,不是 `>`)。
|
||||
///
|
||||
/// 抓得住:把 `build < WIN11_MIN_BUILD` 写成 `<=`——Win11 21H2(build 22000)会静默掉成直角,
|
||||
/// 而那正好是第一台 Win11 机器。
|
||||
#[test]
|
||||
fn the_win11_boundary_is_inclusive() {
|
||||
let at = WindowsVersion::new(10, 0, WIN11_MIN_BUILD);
|
||||
assert_eq!(
|
||||
plan_corner(WindowCorner::System, Some(at), Backend::Gpu).source,
|
||||
CornerSource::Dwm
|
||||
);
|
||||
let below = WindowsVersion::new(10, 0, WIN11_MIN_BUILD - 1);
|
||||
assert_eq!(
|
||||
plan_corner(WindowCorner::System, Some(below), Backend::Gpu).source,
|
||||
CornerSource::Square,
|
||||
"build 21999 = Win10 22H2 家族末尾,不能算 Win11"
|
||||
);
|
||||
}
|
||||
|
||||
/// 日志行的形状:四个字段都在,且 `excluded_reason=none` 与 `reason=user` 不许混。
|
||||
///
|
||||
/// 为什么要钉这一条:这行是「后来的人分辨没生效 / 生效了看不见」的唯一依据(任务书原话),
|
||||
/// 少一栏就等于把这条线索丢掉。
|
||||
#[test]
|
||||
fn the_log_line_carries_every_field() {
|
||||
let dwm = plan_corner(WindowCorner::System, Some(win11()), Backend::Gpu)
|
||||
.log_line(WindowCorner::System);
|
||||
assert!(dwm.starts_with("corner=policy=system"), "{dwm}");
|
||||
assert!(dwm.contains("source=dwm"), "{dwm}");
|
||||
assert!(dwm.contains("os=10.0.26100"), "{dwm}");
|
||||
assert!(dwm.contains("excluded_reason=none"), "{dwm}");
|
||||
assert!(dwm.contains("radius=system"), "{dwm}");
|
||||
|
||||
let square = plan_corner(WindowCorner::Square, Some(win11()), Backend::Gpu)
|
||||
.log_line(WindowCorner::Square);
|
||||
assert!(square.contains("source=square"), "{square}");
|
||||
assert!(square.contains("excluded_reason=user"), "{square}");
|
||||
|
||||
let unknown: CornerPlan = plan_corner(WindowCorner::System, None, Backend::Gpu);
|
||||
assert!(unknown
|
||||
.log_line(WindowCorner::System)
|
||||
.contains("os=unknown"));
|
||||
}
|
||||
|
||||
/// **日志行的形状**,用 M116 探针窗口的**真实读数**构造(`DevDocs/artifacts/m116/probe-table.md`)。
|
||||
///
|
||||
/// 抓得住:字段被删/改名/顺序错乱(`excluded_reason` 少一栏、`radius=system` 丢了、
|
||||
/// `frame` / `layered` 被挪出这一行)——这些都会让「没生效 vs 生效了看不见」的区分重新变成猜谜。
|
||||
/// 抓不住:**产品进程**里那一行(本测试只钉格式)。真值已有:2026-11-04 两次真跑产品 bin
|
||||
/// (α=1.0 / α=0.85)的 `[corner] boot …` 两行,见 `DevDocs/artifacts/m116/README.md`。
|
||||
#[test]
|
||||
fn the_log_line_shape_is_pinned_with_the_measured_readings() {
|
||||
// 本机实测:Server 2025 / build 26100;读回 `E_INVALIDARG`;产品形状是 `overlapped`,
|
||||
// 而 `layered=on` 只可能来自整窗 α(我们的 `WS_EX_LAYERED`) ⇒ 带 `suppressed_by=`。
|
||||
let os = WindowsVersion::new(10, 0, 26_100);
|
||||
let plan: CornerPlan = plan_corner(WindowCorner::System, Some(os), Backend::Gpu);
|
||||
let line = format_line(
|
||||
"boot",
|
||||
&plan,
|
||||
WindowCorner::System,
|
||||
"pref=2 readback=? read_hr=0x80070057",
|
||||
"frame=overlapped layered=on",
|
||||
Some("window_alpha"),
|
||||
);
|
||||
assert_eq!(
|
||||
line,
|
||||
concat!(
|
||||
"[corner] boot corner=policy=system source=dwm os=10.0.26100 ",
|
||||
"excluded_reason=none radius=system pref=2 readback=? ",
|
||||
"read_hr=0x80070057 frame=overlapped layered=on ",
|
||||
"suppressed_by=window_alpha"
|
||||
)
|
||||
);
|
||||
|
||||
// 设置里选「直角」:原因码必须从 `none` 变成 `user`,其余字段不动。
|
||||
let square = format_line(
|
||||
"change",
|
||||
&plan_corner(WindowCorner::Square, Some(os), Backend::Gpu),
|
||||
WindowCorner::Square,
|
||||
"pref=1 readback=? read_hr=0x80070057",
|
||||
"frame=overlapped layered=off",
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
square.contains("corner=policy=square source=square"),
|
||||
"{square}"
|
||||
);
|
||||
assert!(square.contains("excluded_reason=user"), "{square}");
|
||||
assert!(square.contains("frame=overlapped layered=off"), "{square}");
|
||||
// α = 1(不补分层)时**不许**出现 `suppressed_by=`:那一栏只在整窗 α 打开时才有意义。
|
||||
assert!(!square.contains("suppressed_by"), "{square}");
|
||||
|
||||
// Win10:原因码是 `os-build`(GPU 档)/ `cpu-tier`(CPU 档),来源仍是 square。
|
||||
let win10 = WindowsVersion::new(10, 0, 19_045);
|
||||
let os_build =
|
||||
plan_corner(WindowCorner::System, Some(win10), Backend::Gpu).log_line(WindowCorner::System);
|
||||
assert!(
|
||||
os_build.contains("source=square") && os_build.contains("excluded_reason=os-build"),
|
||||
"{os_build}"
|
||||
);
|
||||
let cpu =
|
||||
plan_corner(WindowCorner::System, Some(win10), Backend::Cpu).log_line(WindowCorner::System);
|
||||
assert!(cpu.contains("excluded_reason=cpu-tier"), "{cpu}");
|
||||
|
||||
// 探测不到版本(macOS / X11 / Wayland 落到同一格):`unknown-os`,不许猜 Win11。
|
||||
let unknown =
|
||||
plan_corner(WindowCorner::System, None, Backend::Gpu).log_line(WindowCorner::System);
|
||||
assert!(
|
||||
unknown.contains("os=unknown") && unknown.contains("excluded_reason=unknown-os"),
|
||||
"{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, false);
|
||||
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, false)
|
||||
));
|
||||
|
||||
// 句柄从无到有必须重下(首次拿到 HWND 时才真能设属性)。
|
||||
let mut no_window: Option<CornerApplied> = None;
|
||||
let first = CornerApplied::new(WindowCorner::System, &plan, false, 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, false)
|
||||
));
|
||||
|
||||
// 整窗 α 拉低(补分层)与拉回 1.0 都必须各重下/各记一行:系统行为在这两次确实不同。
|
||||
let opaque = CornerApplied::new(WindowCorner::System, &plan, true, false);
|
||||
let translucent = CornerApplied::new(WindowCorner::System, &plan, true, true);
|
||||
assert!(
|
||||
needs_redispatch(Some(&opaque), &translucent),
|
||||
"α 从 1.0 拉低必须重下(那一行才会出现 suppressed_by=window_alpha)"
|
||||
);
|
||||
assert!(
|
||||
needs_redispatch(Some(&translucent), &opaque),
|
||||
"α 拉回 1.0 也必须重下(圆角请求重新生效)"
|
||||
);
|
||||
assert!(
|
||||
!needs_redispatch(Some(&translucent), &translucent),
|
||||
"α 一直在 <1 时重复触发不该刷屏"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Win32 专属的窗口能力。
|
||||
//!
|
||||
//! | 子模块 | 是什么 | 有没有 `unsafe` |
|
||||
//! |---|---|---|
|
||||
//! | [`transparency`] | D36 定案的整窗 α 唯一实现落点(也是 M116 的 DWM 圆角落点) | **有**(全仓唯一) |
|
||||
//! | [`corner`] | M116 的圆角**判据**:策略 × 系统版本 × 渲染档 → 来源与原因码 | 没有(纯函数) |
|
||||
//!
|
||||
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
|
||||
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
|
||||
//!
|
||||
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
|
||||
//! `corner` 的判据在探测不到系统版本时给出「直角 + `unknown-os`」,所以调用点不需要 `#[cfg]`,
|
||||
//! 行为(返回「本平台不支持」)可被测试断言。
|
||||
|
||||
pub mod corner;
|
||||
pub mod transparency;
|
||||
@@ -0,0 +1,398 @@
|
||||
//! 整窗透明度(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`(`SetLayeredWindowAttributes` 的调用前提)**也归本文件**:加扩展位只有
|
||||
//! `Get/SetWindowLongPtrW` 能走,必须 `unsafe`,而全仓放行 unsafe 的文件集合恰好只有本文件。
|
||||
//! 所以窗口创建侧(`app::boot`)不碰扩展样式,它只负责拿到句柄后先调 [`ensure_layered`]
|
||||
//! 再调 [`apply_window_alpha`]——两件事都在同一个 unsafe 落点里,门禁仍是「集合相等」。
|
||||
//!
|
||||
//! ## 为什么它不影响命中测试与自绘标题栏拖动(D36 任务书要求写清)
|
||||
//!
|
||||
//! `LWA_ALPHA` 只改**合成**(DWM 把整窗乘一个常数),不动窗口的命中测试:
|
||||
//!
|
||||
//! | 机制 | 会不会让鼠标穿透 | 本项目用不用 |
|
||||
//! |---|---|---|
|
||||
//! | `WS_EX_TRANSPARENT`(分层窗口的「点击穿透」位) | **会**(鼠标事件直接发给下层的窗口) | 不用 |
|
||||
//! | `LWA_COLORKEY`(色键:某种颜色整块变透明) | **会**(命中测试按颜色挖洞) | 不用 |
|
||||
//! | `WS_EX_LAYERED` + `LWA_ALPHA` | **不会**(只是合成时乘 α,整个矩形仍然接收命中) | 用这一条 |
|
||||
//!
|
||||
//! 所以自绘标题栏的拖动照旧:`Message::DragWindow` → `window::drag(id)` 走 winit 的
|
||||
//! `WM_NCLBUTTONDOWN(HTCAPTION)`,它依赖的是**窗口矩形内的鼠标事件**,与 α 无关。
|
||||
//! 唯一会「点不动」的情形是 α = 0(整窗不可见,用户以为程序消失)——这就是
|
||||
//! [`unlucky_presenter::prefs::WINDOW_ALPHA_MIN`](0.08)作为硬底、且 `α ≤ 0` 直接拒绝的原因。
|
||||
//!
|
||||
//! D36 已在真机上实测「α ≥ 1 时分层样式不破坏命中测试与拖动」;本仓库的 RDP 环境无法复核
|
||||
//! 观感,所以这条结论的**行为**部分归真机验收(见 M93 报告「没验的部分」)。
|
||||
//!
|
||||
//! ## 非 Windows
|
||||
//!
|
||||
//! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`,
|
||||
//! 而「这个平台不支持」这件事本身可以被断言。
|
||||
|
||||
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
mod alpha;
|
||||
mod dwm;
|
||||
mod exstyle;
|
||||
mod probe;
|
||||
|
||||
pub use dwm::{apply_corner, style_summary, CornerOutcome};
|
||||
pub use exstyle::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
|
||||
pub use probe::{controlled_experiment, TABLE_HEADER};
|
||||
|
||||
pub use alpha::{
|
||||
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
|
||||
LWA_ALPHA, WS_EX_LAYERED,
|
||||
};
|
||||
|
||||
/// 读扩展样式(跨位宽的唯一落点:64 位走 `GetWindowLongPtrW`,32 位走 `GetWindowLongW`)。
|
||||
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
|
||||
pub(super) fn raw_ex_style(hwnd: isize) -> isize {
|
||||
// SAFETY: 只读一个窗口属性,按值传参、不持有指针、无所有权转移;无效句柄只会返回 0。
|
||||
unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) }
|
||||
}
|
||||
|
||||
/// 32 位目标:符号是 `GetWindowLongW`(`link_name` 已映射),返回 `i32`,扩到 `isize`。
|
||||
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
|
||||
pub(super) fn raw_ex_style(hwnd: isize) -> isize {
|
||||
// SAFETY: 同 64 位分支。
|
||||
unsafe { isize::from(GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) }
|
||||
}
|
||||
|
||||
/// 非 Windows:没有扩展样式这个概念,读 0(`ensure_layered` 因此恒 `false`)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(super) fn raw_ex_style(_hwnd: isize) -> isize {
|
||||
0
|
||||
}
|
||||
|
||||
/// 写扩展样式(位宽分档同上)。
|
||||
#[cfg(all(target_os = "windows", target_pointer_width = "64"))]
|
||||
pub(super) fn raw_set_ex_style(hwnd: isize, style: isize) {
|
||||
// SAFETY: 同 `raw_ex_style`:按值传参、不持有指针;返回值刻意不用(改完靠回读取证)。
|
||||
unsafe {
|
||||
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// 32 位目标:符号是 `SetWindowLongW`;扩展样式本身就是 32 位量,收窄无损。
|
||||
#[cfg(all(target_os = "windows", target_pointer_width = "32"))]
|
||||
pub(super) fn raw_set_ex_style(hwnd: isize, style: isize) {
|
||||
let style = i32::try_from(style).unwrap_or(i32::MAX);
|
||||
// SAFETY: 同 64 位分支。
|
||||
unsafe {
|
||||
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// 非 Windows:空实现。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(super) fn raw_set_ex_style(_hwnd: isize, _style: isize) {}
|
||||
|
||||
/// 回读判据:这一位在不在扩展样式里。
|
||||
pub(super) fn has_layered(hwnd: isize) -> bool {
|
||||
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
|
||||
}
|
||||
|
||||
/// 句柄是否指向一个**存在**的窗口(全仓唯一 `unsafe` 落点的一部分)。
|
||||
///
|
||||
/// 为什么在这里而不是在取证探针那个文件里:`transparency.rs` 是全仓**唯一**允许 `unsafe` 的
|
||||
/// 文件(`unsafe-scope` 判据 = 「带 `#![allow(unsafe_code)]` 的文件集合恰好等于本文件」)。
|
||||
/// 探针用到 FFI 时就只能**把 FFI 搬进来**,不能让 `unsafe` 溜到没有标记的文件里——
|
||||
/// 那会让门禁照样绿、而「唯一落点」这个性质已经破了(M100 r1 复审实测抓到的形状)。
|
||||
///
|
||||
/// `0` / 无效句柄 → `false`(Win32 的 `IsWindow` 对无效句柄返回 0)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn window_exists(hwnd: isize) -> bool {
|
||||
// SAFETY: `IsWindow` 只按值接受句柄,不持有指针、无所有权转移;无效句柄返回 0。
|
||||
(unsafe { IsWindow(hwnd) }) != 0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "user32")]
|
||||
extern "system" {
|
||||
/// `IsWindow`:句柄是否指向一个存在的窗口。
|
||||
///
|
||||
/// 为什么需要它(M100 r1):`GetWindowLongPtrW` 对**无效句柄**返回 0,与「扩展样式全 0」
|
||||
/// 不可区分。取证探针要分「拿不到」与「读到了没有这一位」,所以先验一次句柄。
|
||||
fn IsWindow(hwnd: isize) -> i32;
|
||||
|
||||
/// `SetLayeredWindowAttributes`:窗口必须已带 `WS_EX_LAYERED`,α 才生效。
|
||||
///
|
||||
/// 签名照 Win32 头文件:`BOOL SetLayeredWindowAttributes(HWND, COLORREF, BYTE, DWORD)`。
|
||||
fn SetLayeredWindowAttributes(hwnd: isize, color_key: u32, alpha: u8, flags: u32) -> i32;
|
||||
|
||||
/// `GetWindowLongPtrW`:读窗口属性(`GWL_EXSTYLE` = `-20`)。
|
||||
///
|
||||
/// 头文件里 `GetWindowLongPtrW` 是宏:64 位映射到 `GetWindowLongPtrW`、32 位映射到
|
||||
/// `GetWindowLongW`。user32 在 32 位导出里**没有** `...PtrW`,所以这里按指针宽度分档声明,
|
||||
/// 否则 i686 目标会在链接期失败(本仓的 Win7 冒烟是 x64,但编译不该只在一个位宽上成立)。
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
fn GetWindowLongPtrW(hwnd: isize, index: i32) -> isize;
|
||||
/// `SetWindowLongPtrW`:写窗口属性(同上,32 位映射到 `SetWindowLongW`)。
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: isize) -> isize;
|
||||
|
||||
/// 32 位目标的实际符号(见上)。
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
#[link_name = "GetWindowLongW"]
|
||||
fn GetWindowLongPtrW(hwnd: isize, index: i32) -> i32;
|
||||
/// 32 位目标的实际符号(见上)。
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
#[link_name = "SetWindowLongW"]
|
||||
fn SetWindowLongPtrW(hwnd: isize, index: i32, new_value: i32) -> i32;
|
||||
|
||||
/// `GetWindowRect`:窗口矩形的屏幕坐标(**含** DWM 加出来的不可见边框)。
|
||||
///
|
||||
/// 与 `DWMWA_EXTENDED_FRAME_BOUNDS` 对照,是 M116 探针里「DWM 到底给这个窗口加了多宽的
|
||||
/// 边框」这条差值的来源:差值 ≈ 系统认为的边框带宽度。
|
||||
fn GetWindowRect(hwnd: isize, rect: *mut WinRect) -> i32;
|
||||
|
||||
/// `CreateWindowExW`:M116 的**受控对照实验**要用它造三种样式的窗口(同进程)。
|
||||
///
|
||||
/// 只用系统自带类 `STATIC`(不需要 `RegisterClassW`,也就没有窗口过程要写)。
|
||||
fn CreateWindowExW(
|
||||
ex_style: u32,
|
||||
class: *const u16,
|
||||
name: *const u16,
|
||||
style: u32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
parent: isize,
|
||||
menu: isize,
|
||||
instance: isize,
|
||||
param: *mut core::ffi::c_void,
|
||||
) -> isize;
|
||||
|
||||
/// `DestroyWindow`:探针窗口用完即毁(不泄漏 HWND)。
|
||||
fn DestroyWindow(hwnd: isize) -> i32;
|
||||
|
||||
/// `ShowWindow`:DWM 只管理**可见**窗口,所以探针窗口必须真显示一次才有读数。
|
||||
fn ShowWindow(hwnd: isize, command: i32) -> i32;
|
||||
}
|
||||
|
||||
/// `RECT`(`GetWindowRect` 的 out 参数)。字段顺序照 Win32 头文件,`repr(C)` 保证布局。
|
||||
#[cfg(target_os = "windows")]
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(super) struct WinRect {
|
||||
/// 左
|
||||
pub left: i32,
|
||||
/// 上
|
||||
pub top: i32,
|
||||
/// 右
|
||||
pub right: i32,
|
||||
/// 下
|
||||
pub bottom: i32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[link(name = "dwmapi")]
|
||||
extern "system" {
|
||||
/// `DwmSetWindowAttribute`:M116 圆角的唯一落点(`DWMWA_WINDOW_CORNER_PREFERENCE`)。
|
||||
///
|
||||
/// 返回 `HRESULT`:`S_OK`(0) = 成功;属性不被这个 Windows 版本认识时是 `E_INVALIDARG`
|
||||
/// (`0x8007_0057`)——Win10 上就是这个返回值,**不是**「设了但没效果」。
|
||||
fn DwmSetWindowAttribute(
|
||||
hwnd: isize,
|
||||
attribute: u32,
|
||||
value: *const core::ffi::c_void,
|
||||
size: u32,
|
||||
) -> i32;
|
||||
|
||||
/// `DwmGetWindowAttribute`:读回 DWM 侧的属性(探针与日志的证据来源)。
|
||||
fn DwmGetWindowAttribute(
|
||||
hwnd: isize,
|
||||
attribute: u32,
|
||||
value: *mut core::ffi::c_void,
|
||||
size: u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
/// 写一个 `u32` 的 DWM 属性,返回 `HRESULT`(0 = `S_OK`)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn dwm_set_u32(hwnd: isize, attribute: u32, value: u32) -> i32 {
|
||||
// SAFETY: `value` 是栈上的 POD,指针只在调用期间有效,`size` 与它逐字节一致;
|
||||
// DWM 只按值读取,不保留指针。
|
||||
unsafe {
|
||||
DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
attribute,
|
||||
(&raw const value).cast::<core::ffi::c_void>(),
|
||||
u32::try_from(std::mem::size_of::<u32>()).unwrap_or(4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `HRESULT` 的成功判据:**看严重性位**(bit 31),不是「等于 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
|
||||
}
|
||||
|
||||
/// 读一个 `u32` 的 DWM 属性**并保留 `HRESULT`**(M116 探针要它:读不回来时得知道为什么)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn dwm_get_u32_hr(hwnd: isize, attribute: u32) -> (Option<u32>, u32) {
|
||||
let mut value: u32 = 0;
|
||||
// SAFETY: out 指针指向本栈帧里的 `u32`,`size` 与之一致;失败时 DWM 不写。
|
||||
let hr = unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
attribute,
|
||||
(&raw mut value).cast::<core::ffi::c_void>(),
|
||||
u32::try_from(std::mem::size_of::<u32>()).unwrap_or(4),
|
||||
)
|
||||
};
|
||||
// 判据是**严重性位**(bit 31),不是「等于 0」:`S_FALSE` / 信息级码也是成功。
|
||||
// ⚠️ 报错时给的是 `hr as u32`(原始位),**不是** `unsigned_abs()`——后者会把
|
||||
// `E_INVALIDARG`(`0x8007_0057`)印成 `0x7FF8_FFA9`(M116 实测踩过:负数取绝对值
|
||||
// 得到的是「补码的绝对值」,不是 HRESULT)。`hresult_ok` 与这条打印规则一起被测试钉住。
|
||||
(hresult_ok(hr).then_some(value), hr as u32)
|
||||
}
|
||||
|
||||
/// 读一个 `u32` 的 DWM 属性;`HRESULT` 非 0 返回 `None`(**不许**把失败读成 0)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn dwm_get_u32(hwnd: isize, attribute: u32) -> Option<u32> {
|
||||
dwm_get_u32_hr(hwnd, attribute).0
|
||||
}
|
||||
|
||||
/// 读 `DWMWA_EXTENDED_FRAME_BOUNDS` 这类 `RECT` 属性;失败返回 `None`。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn dwm_get_rect(hwnd: isize, attribute: u32) -> Option<WinRect> {
|
||||
let mut rect = WinRect::default();
|
||||
// SAFETY: 同 `dwm_get_u32`,只是 out 类型换成 `RECT`。
|
||||
let hr = unsafe {
|
||||
DwmGetWindowAttribute(
|
||||
hwnd,
|
||||
attribute,
|
||||
(&raw mut rect).cast::<core::ffi::c_void>(),
|
||||
u32::try_from(std::mem::size_of::<WinRect>()).unwrap_or(16),
|
||||
)
|
||||
};
|
||||
// 与 `dwm_get_u32_hr` 同一条判据:`HRESULT` 看严重性位,不是「等于 0」(judgment 只留一处)。
|
||||
hresult_ok(hr).then_some(rect)
|
||||
}
|
||||
|
||||
/// `GetWindowRect`:窗口矩形(屏幕坐标)。失败返回 `None`。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn window_rect(hwnd: isize) -> Option<WinRect> {
|
||||
let mut rect = WinRect::default();
|
||||
// SAFETY: out 指针指向本栈帧的 `RECT`;`GetWindowRect` 只在成功时写它。
|
||||
let ok = unsafe { GetWindowRect(hwnd, &raw mut rect) };
|
||||
(ok != 0).then_some(rect)
|
||||
}
|
||||
|
||||
/// 造一个探针窗口(受控对照实验用)。`0` = 创建失败。
|
||||
///
|
||||
/// 类名用系统自带的 `STATIC`:不需要 `RegisterClassW`,也就不必在这个文件里再写一个窗口过程。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn create_probe_window(style: u32, ex_style: u32) -> isize {
|
||||
let class: Vec<u16> = "STATIC\0".encode_utf16().collect();
|
||||
let title: Vec<u16> = "ULL M116 corner probe\0".encode_utf16().collect();
|
||||
// SAFETY: 两个指针都指向本帧里以 NUL 结尾的 UTF-16 缓冲;其余参数全是按值 POD。
|
||||
// 失败返回 0,调用方按「拿不到句柄」处理,不会产生未定义行为。
|
||||
unsafe {
|
||||
CreateWindowExW(
|
||||
ex_style,
|
||||
class.as_ptr(),
|
||||
title.as_ptr(),
|
||||
style,
|
||||
40,
|
||||
40,
|
||||
420,
|
||||
260,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
core::ptr::null_mut(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 让探针窗口可见(DWM 只管理可见窗口),`SW_SHOWNA` = 显示但不激活。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn show_probe_window(hwnd: isize) {
|
||||
const SW_SHOWNA: i32 = 8;
|
||||
// SAFETY: 按值传句柄;无效句柄只会让 Win32 返回 0。
|
||||
unsafe {
|
||||
ShowWindow(hwnd, SW_SHOWNA);
|
||||
}
|
||||
}
|
||||
|
||||
/// 销毁探针窗口(成对使用,避免 HWND 泄漏)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn destroy_probe_window(hwnd: isize) {
|
||||
if hwnd == 0 {
|
||||
return;
|
||||
}
|
||||
// SAFETY: 只对本模块 `create_probe_window` 造出来的句柄调用;销毁后不再使用它。
|
||||
unsafe {
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读窗口样式位(`GWL_STYLE` = `-16`)。探针用它取 `frame=` 那一栏(产品的真实样式见
|
||||
/// `probe::PRODUCT_STYLE`,**不是** `WS_POPUP`)。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) fn window_style(hwnd: isize) -> isize {
|
||||
const GWL_STYLE: i32 = -16;
|
||||
// SAFETY: 同 `raw_ex_style`:只读一个窗口属性,按值传参、不持有指针。
|
||||
unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(super) 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 {
|
||||
// ⚠️ **这里的 `unsigned_abs()` 是有意的,别跟 DWM 那几处「统一」**(tower 裁定 2026-11-03):
|
||||
//
|
||||
// - 这里取的是 `GetLastError()` 的 **DWORD**(`raw_os_error()` 给的是被塞进 `i32` 的 DWORD,
|
||||
// 例如 `87` / `1400`),它不是 HRESULT,没有严重性位;`unsigned_abs` 只是把
|
||||
// 「高位被当符号」的那点噪声抹掉,语义仍是「错误号」。
|
||||
// - `DwmGet/SetWindowAttribute` 返回的是 **HRESULT**:判成功看 bit 31(`hresult_ok`),
|
||||
// 打印必须用 `hr as u32` 的原始位——用 `unsigned_abs()` 会把 `E_INVALIDARG`
|
||||
// (`0x8007_0057`)印成 `0x7FF8_FFA9`(M116 实测踩过,见 `dwm_get_u32_hr` 的注释)。
|
||||
//
|
||||
// 两种「错误号」不同源,所以打印方式也不该相同。这条由
|
||||
// `tests::dword_error_codes_and_hresults_are_printed_differently` 钉住。
|
||||
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"))]
|
||||
pub(super) fn set_layered(_hwnd: isize, _byte: u8) -> AlphaOutcome {
|
||||
AlphaOutcome::Unsupported
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 整窗 α 的**判据与执行**(D36)——从 `transparency.rs` 搬出来,那里要留行数给 DWM 的 FFI。
|
||||
//!
|
||||
//! ## 本文件零 `unsafe`、零 `extern`
|
||||
//!
|
||||
//! 全仓 `unsafe` 只允许出现在 [`super`](`transparency.rs`)一个文件里,判据是「**带放行属性的
|
||||
//! 文件集合恰好等于它**」。本文件要的 FFI 全部由 `super` 提供安全包装
|
||||
//! (`raw_ex_style` / `raw_set_ex_style` / `has_layered` / `set_layered`),这里只做钳位、量化与三态映射。
|
||||
//!
|
||||
//! ⚠️ **别把 FFI 写回来**(`exstyle.rs` 的模块文档里有同一句,M100 r1 复审实测抓过一次):
|
||||
//! lint 级别沿模块树继承,在这里写 `unsafe` 块也**编得过**、门禁也照样绿,但「唯一落点」就破了。
|
||||
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
use super::{has_layered, raw_ex_style, raw_set_ex_style, set_layered};
|
||||
|
||||
/// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。
|
||||
///
|
||||
/// 单独立成常量是为了让「写错成 `LWA_COLORKEY`(`0x1`)等于什么都没做」这件事有断言盯着。
|
||||
pub const LWA_ALPHA: u32 = 0x0000_0002;
|
||||
|
||||
/// `WS_EX_LAYERED`:分层窗口扩展样式(`0x0008_0000`)。**整窗 α 的调用前提**。
|
||||
pub const WS_EX_LAYERED: isize = 0x0008_0000;
|
||||
|
||||
/// `GWL_EXSTYLE`:扩展样式在 `Get/SetWindowLongPtrW` 里的槽位号(`-20`)。
|
||||
pub const GWL_EXSTYLE: i32 = -20;
|
||||
|
||||
/// 一次整窗 α 应用的结果。
|
||||
#[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`] 的过滤,也不会把窗口写成穿透。
|
||||
///
|
||||
/// **向上取整**(`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() {
|
||||
WINDOW_ALPHA_MIN
|
||||
} else {
|
||||
alpha.clamp(WINDOW_ALPHA_MIN, 1.0)
|
||||
};
|
||||
(clamped * 255.0).ceil() as u8
|
||||
}
|
||||
|
||||
/// 给窗口补上 `WS_EX_LAYERED`(幂等)。
|
||||
///
|
||||
/// ## 为什么这件事必须在这里(而不是「窗口创建侧」)
|
||||
///
|
||||
/// 加扩展样式只有 `GetWindowLongPtrW` / `SetWindowLongPtrW` 一条路,都是 `unsafe`;而
|
||||
/// [`super::super`] 的 CI 门禁(`.github/workflows/guards.yml` 的 `unsafe-scope`)要求
|
||||
/// 全仓显式放行 `unsafe_code` 的文件集合**恰好等于本文件**。所以窗口创建侧的
|
||||
/// `app/boot.rs` 只负责「拿句柄 → 调本函数 → 调 [`apply_window_alpha`]」,
|
||||
/// 不新增第二个放行文件。
|
||||
///
|
||||
/// ## 为什么创建侧无法预先带上这一位
|
||||
///
|
||||
/// iced 0.14 的 `window::Settings` 没有扩展样式入口;winit 0.30 只在
|
||||
/// `IGNORE_CURSOR_EVENT`(鼠标穿透,`WS_EX_TRANSPARENT | WS_EX_LAYERED`)时置位,
|
||||
/// `transparent: true` 走的是 DWM 那条路(`window.rs` 的 `ENABLE BLUR BEHIND WINDOW`),
|
||||
/// 与 `LWA_ALPHA` 无关。所以只能拿到句柄后补。
|
||||
///
|
||||
/// ## 返回值
|
||||
///
|
||||
/// `true` = 这一位**已就位**(原本就有,或本次加上了)。判据是**再加读一次**验证,
|
||||
/// 而不是信 `SetWindowLongPtrW` 的返回值:Win32 的它「失败返回 0 且成功也可能返回 0」
|
||||
/// (前值就是 0 时),只能靠回读取证。句柄为 0 / 无效 → `false`(`GetWindowLongPtrW`
|
||||
/// 也读不到这一位)。
|
||||
pub fn ensure_layered(hwnd: isize) -> bool {
|
||||
if hwnd == 0 {
|
||||
return false;
|
||||
}
|
||||
if has_layered(hwnd) {
|
||||
return true;
|
||||
}
|
||||
raw_set_ex_style(hwnd, raw_ex_style(hwnd) | WS_EX_LAYERED);
|
||||
has_layered(hwnd)
|
||||
}
|
||||
|
||||
/// 把整窗 α 下发到窗口。
|
||||
///
|
||||
/// `alpha` 一般直接来自 [`super::super::effective_window_alpha`](`None` = 拒绝整窗穿透)。
|
||||
/// 本函数是**执行层**:不再看渲染后端、不读环境变量,只认「有没有一个合法的值」+
|
||||
/// 「有没有句柄」,然后(仅 Windows)调一次 `SetLayeredWindowAttributes`。
|
||||
///
|
||||
/// 调用前提是窗口已带 `WS_EX_LAYERED`(见 [`ensure_layered`]):没有它 `SetLayeredWindowAttributes`
|
||||
/// 一定失败(Win32 返回 0,本机实测 `GetLastError` 常见 87 / `ERROR_INVALID_PARAMETER`)。
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! DWM 圆角(M116):把 [`CornerPlan`](super::super::corner::CornerPlan) 落成一次属性下发。
|
||||
//!
|
||||
//! ## 本文件零 `unsafe`、零 `extern`
|
||||
//!
|
||||
//! `DwmSetWindowAttribute` / `DwmGetWindowAttribute` 的声明与调用点在 [`super`]
|
||||
//! (全仓唯一 `unsafe` 落点);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。
|
||||
//!
|
||||
//! ## 为什么「直角」也要真的下发一次
|
||||
//!
|
||||
//! `DWMWCP_DONOTROUND` 是**显式**的直角请求。不下发也能得到直角(Win10 就没有这个属性),
|
||||
//! 但那样日志里「方」与「没生效」长得一模一样。下发 + 回读之后,
|
||||
//! `corner=… source=square excluded_reason=user` 与 `excluded_reason=os-build` 是两条
|
||||
//! 可区分的事实,而不是一句猜测。
|
||||
//!
|
||||
//! ## 回读为什么是证据而不是自证
|
||||
//!
|
||||
//! `DwmSetWindowAttribute` 返回 `S_OK` 只说明「参数被接受了」。属性值由 DWM 保存,
|
||||
//! 回读拿到的是 **DWM 侧的当前值**——它能抓住「设了 A 属性、读回还是旧值」这类事
|
||||
//! (比如把 `DWMWA_WINDOW_CORNER_PREFERENCE` 写成别的号,回读会给出 `None` 或旧值)。
|
||||
|
||||
/// `DWMWA_WINDOW_CORNER_PREFERENCE`:窗口圆角偏好的属性号(Win11 / Server 2025,build ≥ 22000)。
|
||||
pub const DWMWA_WINDOW_CORNER_PREFERENCE: u32 = 34;
|
||||
/// `DWMWCP_DONOTROUND`:显式直角。
|
||||
pub const DWMWCP_DONOTROUND: u32 = 1;
|
||||
/// `DWMWCP_ROUND`:显式圆角(半径由系统决定,**不暴露**给应用)。
|
||||
pub const DWMWCP_ROUND: u32 = 2;
|
||||
/// `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS`:可见边框厚度(像素,Win11 起)。
|
||||
///
|
||||
/// ⚠️ **本机实测它不能当「圆不圆」的判据**(我最初以为能,被探针否掉了):
|
||||
/// 三种窗口样式 × 三种状态(默认 / `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;
|
||||
|
||||
/// 一次圆角下发的结局(日志与测试共用)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CornerOutcome {
|
||||
/// 非 Windows:空实现,什么都没做。
|
||||
UnsupportedPlatform,
|
||||
/// 句柄为 0:还没拿到窗口,或窗口已销毁。
|
||||
NoWindow,
|
||||
/// 已下发且回读一致。`readback == None` 表示**这一版 DWM 不接受回读**
|
||||
/// (属性不被认识时 `DwmGetWindowAttribute` 返回 `E_INVALIDARG`),此时只有 `S_OK` 作证。
|
||||
Applied {
|
||||
/// 下发下去的偏好值([`DWMWCP_ROUND`] / [`DWMWCP_DONOTROUND`])。
|
||||
preference: u32,
|
||||
/// DWM 侧回读到的值(`None` = 读不回来)。
|
||||
readback: Option<u32>,
|
||||
/// 回读那一次的 `HRESULT`(`0` = `S_OK`)。
|
||||
read_hresult: u32,
|
||||
},
|
||||
/// 属性不被这个 Windows 版本认识(`E_INVALIDARG`):Win10 就是这个结局。
|
||||
Unsupported {
|
||||
/// `DwmSetWindowAttribute` 的原始 `HRESULT`。
|
||||
hresult: u32,
|
||||
},
|
||||
/// 其他失败(句柄无效等),`hresult` 来自 DWM。
|
||||
Failed {
|
||||
/// `DwmSetWindowAttribute` 的原始 `HRESULT`。
|
||||
hresult: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl CornerOutcome {
|
||||
/// 一行日志尾巴:`corner=source=dwm pref=2 readback=2`。
|
||||
///
|
||||
/// `readback=?` 与 `pref=none`(平台不支持)是不同的坏消息:前者是「设了但读不回来」,
|
||||
/// 后者是「这里根本没有这个能力」。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::UnsupportedPlatform => "pref=none readback=n/a platform=unsupported".to_owned(),
|
||||
Self::NoWindow => "pref=none readback=n/a window=none".to_owned(),
|
||||
Self::Applied {
|
||||
preference,
|
||||
readback,
|
||||
read_hresult,
|
||||
} => format!(
|
||||
"pref={preference} readback={} read_hr=0x{read_hresult:08X}",
|
||||
readback.map_or_else(|| "?".to_owned(), |value| value.to_string())
|
||||
),
|
||||
Self::Unsupported { hresult } => {
|
||||
format!("pref=none readback=n/a hresult=0x{hresult:08X} (attribute unsupported)")
|
||||
}
|
||||
Self::Failed { hresult } => format!("pref=none readback=n/a hresult=0x{hresult:08X}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `HRESULT` 是不是 `E_INVALIDARG`(`0x8007_0057`):Win10 上设这个属性就是这个返回值。
|
||||
#[cfg(target_os = "windows")]
|
||||
const E_INVALIDARG: u32 = 0x8007_0057;
|
||||
|
||||
/// 把「来源」映射成要下发的偏好值(纯函数,测试直接钉它)。
|
||||
///
|
||||
/// `Dwm` → [`DWMWCP_ROUND`];`Square` → [`DWMWCP_DONOTROUND`]。
|
||||
/// 刻意**不用**默认值(`0`):那在 Win11 上等于「请 DWM 自己决定」,而无边框窗口
|
||||
/// 到底算不算「有框」由 DWM 的启发式决定——那正是我们要么拿到、要么明确放弃的东西。
|
||||
#[must_use]
|
||||
pub const fn preference_for(source: super::super::corner::CornerSource) -> u32 {
|
||||
use super::super::corner::CornerSource;
|
||||
match source {
|
||||
CornerSource::Dwm => DWMWCP_ROUND,
|
||||
CornerSource::Square => DWMWCP_DONOTROUND,
|
||||
}
|
||||
}
|
||||
|
||||
/// 下发一次圆角偏好(幂等:重复调用结果相同)。
|
||||
pub fn apply_corner(hwnd: isize, plan: &super::super::corner::CornerPlan) -> CornerOutcome {
|
||||
if hwnd == 0 {
|
||||
return CornerOutcome::NoWindow;
|
||||
}
|
||||
set_preference(hwnd, preference_for(plan.source))
|
||||
}
|
||||
|
||||
/// 下发一个具体的偏好值并回读(拆开是为了让探针能逐格遍历)。
|
||||
pub fn set_preference(hwnd: isize, preference: u32) -> CornerOutcome {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (hwnd, preference);
|
||||
return CornerOutcome::UnsupportedPlatform;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if hwnd == 0 {
|
||||
return CornerOutcome::NoWindow;
|
||||
}
|
||||
let hresult = super::dwm_set_u32(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, preference);
|
||||
if !super::hresult_ok(hresult) {
|
||||
// 原始位,不是 `unsigned_abs()`(见 `dwm_get_u32_hr` 的注释:后者印不出 HRESULT)。
|
||||
let code = hresult as u32;
|
||||
return if code == E_INVALIDARG {
|
||||
CornerOutcome::Unsupported { hresult: code }
|
||||
} else {
|
||||
CornerOutcome::Failed { hresult: code }
|
||||
};
|
||||
}
|
||||
let (readback, read_hresult) = corner_preference_hr(hwnd);
|
||||
CornerOutcome::Applied {
|
||||
preference,
|
||||
readback,
|
||||
read_hresult,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读圆角偏好**以及它的 `HRESULT`**(`(None, code)` = 读不回来,`code` 是 DWM 给的原因)。
|
||||
///
|
||||
/// 为什么要暴露 `HRESULT`:M116 本机实测——**写成功、读回 `E_INVALIDARG`**。只报 `?`
|
||||
/// 会让「设上了吗」变成猜谜;有了码就能说清是「属性不支持读」而不是「没设上」。
|
||||
#[must_use]
|
||||
pub fn corner_preference_hr(hwnd: isize) -> (Option<u32>, u32) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::dwm_get_u32_hr(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
(None, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读可见边框厚度(像素)。**诊断用,不可作判据**(本机三样式三态实测全是 1,见常量文档)。
|
||||
#[must_use]
|
||||
pub fn visible_border_thickness(hwnd: isize) -> Option<u32> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::dwm_get_u32(hwnd, DWMWA_VISIBLE_FRAME_BORDER_THICKNESS)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 DWM 的窗口边界(`left, top, right, bottom`,屏幕坐标)。
|
||||
#[must_use]
|
||||
pub fn extended_frame_bounds(hwnd: isize) -> Option<(i32, i32, i32, i32)> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::dwm_get_rect(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS)
|
||||
.map(|rect| (rect.left, rect.top, rect.right, rect.bottom))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 `GetWindowRect`(含 DWM 加出来的不可见边框),用于和 [`extended_frame_bounds`] 求差。
|
||||
#[must_use]
|
||||
pub fn window_rect(hwnd: isize) -> Option<(i32, i32, i32, i32)> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
super::window_rect(hwnd).map(|rect| (rect.left, rect.top, rect.right, rect.bottom))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读窗口样式位(`GWL_STYLE` = `-16`):日志里 `frame=` 那一栏的取值来源。
|
||||
///
|
||||
/// ⚠️ 产品窗口**不是** `WS_POPUP`(M116 r4 复审订正):winit 在 `decorations = false` 下给的是
|
||||
/// `WS_CAPTION|WS_BORDER|...`(见 `probe::PRODUCT_STYLE`),真进程的 `frame=` 应为 `overlapped`
|
||||
/// (关掉 min/max 则 `other`)。「无边框」是 `WM_NCCALCSIZE` 的效果,不是样式位。
|
||||
#[must_use]
|
||||
pub fn window_style(hwnd: isize) -> Option<isize> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
(hwnd != 0).then(|| super::window_style(hwnd))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 读窗口扩展样式位(`GWL_EXSTYLE`):配合 [`window_style`] 回答「我们是不是 `WS_EX_LAYERED`」。
|
||||
#[must_use]
|
||||
pub fn window_ex_style(hwnd: isize) -> Option<isize> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
(hwnd != 0).then(|| super::raw_ex_style(hwnd))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = hwnd;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `WS_POPUP` 位(`0x8000_0000`):**带 owner 的弹出窗**才置它;产品主窗口不用这一位。
|
||||
pub const WS_POPUP: isize = 0x8000_0000_u32 as isize;
|
||||
|
||||
/// 帧样式的一句话标签(纯函数,日志用):`popup` / `overlapped` / `other`。
|
||||
///
|
||||
/// 为什么要它:用户的现象是「别的窗口有圆角,我们的没有」。把它打进日志,才能一眼看出
|
||||
/// 窗口形状这一项**不是**变量(产品恒为 `overlapped`)——真正让系统忽略圆角请求的是整窗 α
|
||||
/// 引入的 `WS_EX_LAYERED`(见 `layered=` 与 `suppressed_by=window_alpha`),不是 `popup` 形状。
|
||||
#[must_use]
|
||||
pub const fn frame_label(style: isize) -> &'static str {
|
||||
const WS_OVERLAPPEDWINDOW_MASK: isize = 0x00CF_0000;
|
||||
if style & WS_POPUP != 0 {
|
||||
"popup"
|
||||
} else if style & WS_OVERLAPPEDWINDOW_MASK == WS_OVERLAPPEDWINDOW_MASK {
|
||||
"overlapped"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// 分层位的一句话标签(纯函数,日志用):`on` / `off`。
|
||||
#[must_use]
|
||||
pub const fn layered_label(ex_style: isize) -> &'static str {
|
||||
if ex_style & super::WS_EX_LAYERED != 0 {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个窗口的样式摘要(`frame=overlapped layered=off`,产品真进程实测值);读不到时如实写
|
||||
/// `frame=? layered=?`。「`frame=popup`」只会出现在**带 owner 的弹出窗**上,产品不会。
|
||||
#[must_use]
|
||||
pub fn style_summary(hwnd: isize) -> String {
|
||||
let frame = window_style(hwnd).map_or("?", frame_label);
|
||||
let layered = window_ex_style(hwnd).map_or("?", layered_label);
|
||||
format!("frame={frame} layered={layered}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_corner, corner_preference_hr, preference_for, CornerOutcome,
|
||||
DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_DONOTROUND,
|
||||
DWMWCP_ROUND,
|
||||
};
|
||||
use crate::platform::win32::corner::{CornerPlan, CornerSource};
|
||||
use unlucky_presenter::prefs::WindowsVersion;
|
||||
|
||||
/// **抓得住**:属性号写错(34 写成 33 / 37 写成 36 —— 这两个都在 Win11 上存在,改错不会崩,
|
||||
/// 只会静默读出别的东西)、两个偏好值互换。
|
||||
/// **抓不住**:这些值在真窗口上是否真的生效(那要探针读数,见 `probe` 与报告)。
|
||||
#[test]
|
||||
fn attribute_numbers_follow_the_win32_header() {
|
||||
assert_eq!(DWMWA_WINDOW_CORNER_PREFERENCE, 34);
|
||||
assert_eq!(DWMWA_VISIBLE_FRAME_BORDER_THICKNESS, 37);
|
||||
assert_eq!(DWMWCP_ROUND, 2);
|
||||
assert_eq!(DWMWCP_DONOTROUND, 1);
|
||||
}
|
||||
|
||||
/// **抓得住**:`preference_for` 把圆角/直角映射反了(这一格反了就是「设置里选直角反而更圆」)。
|
||||
#[test]
|
||||
fn the_preference_mapping_cannot_be_swapped() {
|
||||
assert_eq!(preference_for(CornerSource::Dwm), DWMWCP_ROUND);
|
||||
assert_eq!(preference_for(CornerSource::Square), DWMWCP_DONOTROUND);
|
||||
}
|
||||
|
||||
/// 无效句柄:**不下发**、也不 panic。句柄 0 走 `NoWindow`(还没拿到窗口 ≠ 平台不支持)。
|
||||
#[test]
|
||||
fn a_missing_window_is_reported_not_guessed() {
|
||||
let plan = CornerPlan {
|
||||
source: CornerSource::Dwm,
|
||||
excluded: None,
|
||||
os: Some(WindowsVersion::new(10, 0, 26_100)),
|
||||
};
|
||||
assert_eq!(apply_corner(0, &plan), CornerOutcome::NoWindow);
|
||||
assert_eq!(corner_preference_hr(0).0, None);
|
||||
// 1 号句柄永远无效:非 Windows 上是「平台不支持」,Windows 上是 DWM 的失败码,
|
||||
// 两种都不许表现成「成功」。
|
||||
let outcome = apply_corner(1, &plan);
|
||||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
CornerOutcome::Failed { .. }
|
||||
| CornerOutcome::Unsupported { .. }
|
||||
| CornerOutcome::UnsupportedPlatform
|
||||
),
|
||||
"无效句柄应报失败,实际 {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 日志尾巴三态可区分:设了 / 设了但读不回 / 属性不支持。
|
||||
#[test]
|
||||
fn the_log_tail_separates_applied_from_unsupported() {
|
||||
let applied = CornerOutcome::Applied {
|
||||
preference: DWMWCP_ROUND,
|
||||
readback: Some(DWMWCP_ROUND),
|
||||
read_hresult: 0,
|
||||
};
|
||||
assert!(
|
||||
applied.describe().starts_with("pref=2 readback=2"),
|
||||
"{applied:?}"
|
||||
);
|
||||
|
||||
let blind = CornerOutcome::Applied {
|
||||
preference: DWMWCP_ROUND,
|
||||
readback: None,
|
||||
read_hresult: 0x8000_4005,
|
||||
};
|
||||
assert!(
|
||||
blind.describe().contains("readback=?"),
|
||||
"{}",
|
||||
blind.describe()
|
||||
);
|
||||
|
||||
let old = CornerOutcome::Unsupported {
|
||||
hresult: 0x8007_0057,
|
||||
};
|
||||
assert!(
|
||||
old.describe().contains("attribute unsupported"),
|
||||
"{}",
|
||||
old.describe()
|
||||
);
|
||||
assert!(CornerOutcome::UnsupportedPlatform
|
||||
.describe()
|
||||
.contains("platform=unsupported"));
|
||||
}
|
||||
}
|
||||
|
||||
/// **抓得住**:`frame_label` 把 `WS_POPUP` 与 `WS_OVERLAPPEDWINDOW` 判反(那会让日志把
|
||||
/// 「我们自己的窗口」写成「普通窗口」,这行日志的全部价值就是分辨这个)。
|
||||
/// **抓不住**:真窗口上到底置了哪一位(那属于运行时读数)。
|
||||
#[test]
|
||||
fn the_frame_label_separates_popup_from_a_decorated_window() {
|
||||
assert_eq!(WS_POPUP, 0x8000_0000_u32 as isize);
|
||||
assert_eq!(frame_label(WS_POPUP | 0x1000_0000), "popup");
|
||||
assert_eq!(frame_label(0x00CF_0000), "overlapped");
|
||||
assert_eq!(frame_label(0), "other");
|
||||
}
|
||||
|
||||
/// 分层位标签:只认 `WS_EX_LAYERED`,别把 `WS_EX_TRANSPARENT` 之类的邻居混进来。
|
||||
#[test]
|
||||
fn the_layered_label_reads_the_right_bit() {
|
||||
let layered = crate::platform::win32::transparency::WS_EX_LAYERED;
|
||||
assert_eq!(layered_label(layered), "on");
|
||||
assert_eq!(layered_label(WS_POPUP), "off");
|
||||
assert_eq!(layered_label(0), "off");
|
||||
}
|
||||
|
||||
/// 读不到句柄时摘要不许编:`frame=? layered=?`(「不知道」与「没有」是两件事)。
|
||||
#[test]
|
||||
fn the_style_summary_never_guesses() {
|
||||
assert_eq!(style_summary(0), "frame=? layered=?");
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 取证用的**扩展样式探针**(M100 r1 P2-2):这个窗口是不是「点击穿透」。
|
||||
//!
|
||||
//! ## 本文件零 `unsafe`、零 `extern`
|
||||
//!
|
||||
//! 全仓 `unsafe` 只允许出现在 [`super`](`transparency.rs`)一个文件里,判据是
|
||||
//! 「**带放行属性**的文件集合恰好等于它」。所以探针需要的 FFI 一律由 `transparency.rs` 提供
|
||||
//! **安全包装**([`super::window_exists`] / [`super::raw_ex_style`]),本文件只做纯判断、
|
||||
//! 三态映射与测试。
|
||||
//!
|
||||
//! ⚠️ **别把 FFI 写回来**(M100 r1 复审实测抓过一次):lint 级别沿模块树继承,所以在这里写
|
||||
//! 一个 `unsafe` 块 + `extern` 声明也**编得过**,`unsafe-scope` 门禁也照样绿(它只数「带放行
|
||||
//! 属性的文件」)——但「全仓唯一允许 unsafe 的文件」这个性质就破了。
|
||||
//! 属性的作用是可审计性,不是给 CI 看的。
|
||||
//!
|
||||
//! ## 判据来源
|
||||
//!
|
||||
//! winit 0.30 在 Windows 把 `set_cursor_hittest(false)` 落成
|
||||
//! `WS_EX_TRANSPARENT | WS_EX_LAYERED`(`window_state.rs`)。所以读回这一位,就是
|
||||
//! 「取证窗口真的把命中测试关掉了」的 OS 侧证据——比「出图 hash 不变」有判别力得多
|
||||
//! (实测:同 argv 开/关穿透,hash 完全相同)。
|
||||
|
||||
use super::raw_ex_style;
|
||||
|
||||
/// `WS_EX_TRANSPARENT`:**点击穿透**位(`0x0000_0020`)。
|
||||
pub const WS_EX_TRANSPARENT: isize = 0x0000_0020;
|
||||
|
||||
/// 纯判据:扩展样式里 [`WS_EX_TRANSPARENT`] 在不在。
|
||||
///
|
||||
/// 单独拎成 `const fn` 是为了让「位判断写反」有断言盯着——`& WS_EX_TRANSPARENT != 0` 写成
|
||||
/// `== 0` 是这类 getter 最经典的错,而且只会在「开/关恰好反向」时暴露(两边都"有输出")。
|
||||
#[must_use]
|
||||
pub const fn is_click_through_style(ex_style: isize) -> bool {
|
||||
ex_style & WS_EX_TRANSPARENT != 0
|
||||
}
|
||||
|
||||
/// 取证用:窗口**现在**是否点击穿透(`WS_EX_TRANSPARENT` 在扩展样式里)。
|
||||
///
|
||||
/// ## 为什么是三态而不是 `bool`
|
||||
///
|
||||
/// `None` = **没读到**(非 Windows / 句柄无效 / 句柄还没拿到)。这时调用方必须记 `unknown`,
|
||||
/// **不许记成 `off`**——「拿不到」和「没有」是两件事,混为一谈就是假绿(本项目在别处栽过:
|
||||
/// 扫描读不全时报「没有」)。所以这里宁可 `None`,也不用「读失败的 0」冒充「没这一位」。
|
||||
///
|
||||
/// ## 为什么本文件零 `unsafe`、零 `extern`
|
||||
///
|
||||
/// `unsafe` 全仓只在 [`super`](`transparency.rs`)那一个文件里。探针需要的 FFI
|
||||
/// (`IsWindow` / `GetWindowLongPtrW`)都由 `super` 提供安全包装
|
||||
/// ([`super::window_exists`] / [`super::raw_ex_style`]),本文件只做**纯判断**。
|
||||
///
|
||||
/// ## 用途
|
||||
///
|
||||
/// 出图时把它打进日志(`pages::harness` 的 `[caps] passthrough_ext=…`),于是「取证窗口到底
|
||||
/// 有没有把指针输入关掉」每次运行都能自证,不依赖外部探针。
|
||||
#[cfg(target_os = "windows")]
|
||||
#[must_use]
|
||||
pub fn click_through(hwnd: isize) -> Option<bool> {
|
||||
if hwnd == 0 || !super::window_exists(hwnd) {
|
||||
return None;
|
||||
}
|
||||
Some(is_click_through_style(raw_ex_style(hwnd)))
|
||||
}
|
||||
|
||||
/// 非 Windows:**恒 `None`**。空实现不许冒充「没开穿透」(那就是把「不知道」写成「没有」)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[must_use]
|
||||
pub fn click_through(_hwnd: isize) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{click_through, is_click_through_style, WS_EX_TRANSPARENT};
|
||||
use crate::platform::win32::transparency::WS_EX_LAYERED;
|
||||
|
||||
/// **抓得住**:`& WS_EX_TRANSPARENT != 0` 被写成 `== 0`(位判断反向)——这是这类 getter 最经典
|
||||
/// 的错,而且只会在「开/关恰好互换」时暴露:日志里永远有值,只是意思反了。
|
||||
/// **抓不住**:真实窗口上 winit 到底置没置这一位(那要出图日志里看 `passthrough_ext=on`)。
|
||||
#[test]
|
||||
fn the_click_through_bit_cannot_be_read_backwards() {
|
||||
assert_eq!(WS_EX_TRANSPARENT, 0x0000_0020, "位值照 Win32 头文件");
|
||||
assert!(is_click_through_style(WS_EX_TRANSPARENT));
|
||||
assert!(
|
||||
is_click_through_style(WS_EX_TRANSPARENT | WS_EX_LAYERED),
|
||||
"winit 的鼠标穿透是两位一起置的,这种组合必须是 true"
|
||||
);
|
||||
assert!(
|
||||
!is_click_through_style(WS_EX_LAYERED),
|
||||
"只有分层位不算穿透——LWA_ALPHA 也加这一位,反向了会把正常窗口误判成穿透"
|
||||
);
|
||||
assert!(!is_click_through_style(0));
|
||||
}
|
||||
|
||||
/// **抓得住**:「拿不到」被记成「没有」。句柄 0 / 无效句柄 / 非 Windows 必须是 `None`
|
||||
/// (调用方据此写 `unknown`),不许用「读失败的 0」冒充「这一位没开」。
|
||||
/// **抓不住**:一个**有效**窗口上的读数(那要真窗口,见 harness 的出图日志)。
|
||||
#[test]
|
||||
fn click_through_reports_unknown_instead_of_off_when_it_cannot_read() {
|
||||
assert_eq!(click_through(0), None, "句柄 0 = 还没拿到窗口 ⇒ unknown");
|
||||
assert_eq!(click_through(1), None, "无效句柄 ⇒ unknown(不许掉成 off)");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(
|
||||
click_through(0x1234),
|
||||
None,
|
||||
"非 Windows 的空实现必须恒 None,不许冒充 off"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! M116 的**受控对照实验**:同进程里造几种窗口样式,逐个读 DWM 属性。
|
||||
//!
|
||||
//! ## 为什么需要它
|
||||
//!
|
||||
//! 用户的观察是「同一台机器上别的窗口都有圆角,只有我们的没有」。于是「没圆角」至少有三种
|
||||
//! 可能:①这是 DWM 的默认行为(我们没申请);②某个**样式位**把它 opt-out 了;③属性根本设不上。
|
||||
//!
|
||||
//! 只在自己的窗口上读一次,分不清 ①②③。所以这里**同一进程**里造几个窗口,除「样式」外
|
||||
//! 条件全相同:
|
||||
//!
|
||||
//! | 标签 | 样式 | 它代表什么 |
|
||||
//! |---|---|---|
|
||||
//! | `decorated` | `WS_OVERLAPPEDWINDOW` | 用户说的「别的窗口」(对照) |
|
||||
//! | `probe:popup` | 裸 `WS_POPUP` | **对照,不是产品形状**:带 owner 的弹出窗才用这一位 |
|
||||
//! | `probe:popup+layered` | `WS_POPUP` + `WS_EX_LAYERED`(α) | **对照**:把两个嫌疑同时打开 |
|
||||
//! | `product` | [`PRODUCT_STYLE`](α = 1,不补分层) | 产品的**真实**样式(出厂态) |
|
||||
//! | `product+alpha` | [`PRODUCT_STYLE`] + `WS_EX_LAYERED`(α) | 产品真实样式 + 用户把整窗 α 拉低 |
|
||||
//!
|
||||
//! ## ⚠️ 产品窗口**不是** `WS_POPUP`(M116 r4 复审订正)
|
||||
//!
|
||||
//! iced/winit 在 `decorations = false` 下**不会**用 `WS_POPUP`:winit 的
|
||||
//! `window_state.rs::to_window_styles` 起点是 `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU`,
|
||||
//! 可缩放/可最大化时再加 `WS_THICKFRAME|WS_MINIMIZEBOX|WS_MAXIMIZEBOX`;`WS_POPUP` 只在
|
||||
//! `WindowFlags::POPUP`(**带 owner** 的弹出窗)时才加。产品的「无边框观感」来自
|
||||
//! `WM_NCCALCSIZE` 把客户区铺满,**不是**样式位。
|
||||
//!
|
||||
//! 所以本文件早期那句「`popup+layered` = 本产品窗口的完整形状」是**错的**:那一行的读数
|
||||
//! (尤其 `Δframe = +0,+0`)只属于假形状,不能拿去解释产品窗口。要解释产品,看 `product`
|
||||
//! 与 `product+alpha` 两行。
|
||||
//!
|
||||
//! ## 这个实验能证明什么、不能证明什么
|
||||
//!
|
||||
//! **能**:属性层能不能开(`DwmSetWindowAttribute` 的 `HRESULT`)、开完 DWM 侧回读是多少、
|
||||
//! 各样式之间 `DWMWA_VISIBLE_FRAME_BORDER_THICKNESS` 与
|
||||
//! `DWMWA_EXTENDED_FRAME_BOUNDS - GetWindowRect` 的数值差是否随样式变化。
|
||||
//!
|
||||
//! **不能**:角上到底是不是透明的(那要合成输出像素,本机 RDP 断连拿不到)。所以本文件
|
||||
//! 的读数只回答「属性层」,观感归真机验收——报告里逐条标注。
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::alpha::{apply_window_alpha, ensure_layered};
|
||||
use super::dwm::{
|
||||
corner_preference_hr, extended_frame_bounds, set_preference, visible_border_thickness,
|
||||
window_rect, window_style, CornerOutcome, DWMWCP_DONOTROUND, DWMWCP_ROUND,
|
||||
};
|
||||
|
||||
/// `WS_POPUP`(`0x8000_0000`):**带 owner 的弹出窗**才用它;产品主窗口没有 owner,不用。
|
||||
pub const WS_POPUP: u32 = 0x8000_0000;
|
||||
/// `WS_OVERLAPPEDWINDOW`:普通有边框窗口(对照组的「别的窗口」)。
|
||||
pub const WS_OVERLAPPEDWINDOW: u32 = 0x00CF_0000;
|
||||
|
||||
/// `WS_CAPTION`(`WS_BORDER | WS_DLGFRAME`)。
|
||||
const WS_CAPTION: u32 = 0x00C0_0000;
|
||||
/// `WS_BORDER`(= `WS_THICKFRAME` 的低位别名,`0x0080_0000`)。
|
||||
const WS_BORDER: u32 = 0x0080_0000;
|
||||
/// `WS_CLIPSIBLINGS`:子窗口层叠顺序位(DWM 不看它,winit 的起点里有)。
|
||||
const WS_CLIPSIBLINGS: u32 = 0x0400_0000;
|
||||
/// `WS_SYSMENU`。
|
||||
const WS_SYSMENU: u32 = 0x0008_0000;
|
||||
/// `WS_THICKFRAME`(可缩放边框)。
|
||||
const WS_THICKFRAME: u32 = 0x0004_0000;
|
||||
/// `WS_MINIMIZEBOX`。
|
||||
const WS_MINIMIZEBOX: u32 = 0x0002_0000;
|
||||
/// `WS_MAXIMIZEBOX`。
|
||||
const WS_MAXIMIZEBOX: u32 = 0x0001_0000;
|
||||
|
||||
/// **产品窗口的真实样式位**(iced/winit,`decorations = false`,可缩放、可最大化、可最小化)。
|
||||
///
|
||||
/// 出处(可复核):winit `window_state.rs::to_window_styles` 从
|
||||
/// `WS_CAPTION|WS_BORDER|WS_CLIPSIBLINGS|WS_SYSMENU` 起手,`RESIZABLE` / `MAXIMIZABLE` /
|
||||
/// `MINIMIZABLE` 分别加 `WS_THICKFRAME` / `WS_MAXIMIZEBOX` / `WS_MINIMIZEBOX`;`WS_POPUP`
|
||||
/// 只在 `WindowFlags::POPUP`(带 owner)时才加——产品主窗口不带。
|
||||
///
|
||||
/// 这些**位**是输入(照抄 winit 的产物,带出处);表里那一行 `product` 的**读数**是跑出来的,
|
||||
/// 不是我写死的。
|
||||
pub const PRODUCT_STYLE: u32 = WS_CAPTION
|
||||
| WS_BORDER
|
||||
| WS_CLIPSIBLINGS
|
||||
| WS_SYSMENU
|
||||
| WS_THICKFRAME
|
||||
| WS_MINIMIZEBOX
|
||||
| WS_MAXIMIZEBOX;
|
||||
|
||||
/// `WS_EX_LAYERED` 的 `u32` 形态(`create_probe_window` 的参数类型)。
|
||||
const WS_EX_LAYERED_U32: u32 = 0x0008_0000;
|
||||
|
||||
/// 实验里的一行(一个窗口样式)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeRow {
|
||||
/// 这一行是什么样式(见模块文档的表)。
|
||||
pub label: &'static str,
|
||||
/// 创建时请求的样式位。
|
||||
pub style: u32,
|
||||
/// 创建时请求的扩展样式位。
|
||||
pub ex_style: u32,
|
||||
/// 读回的 `GWL_STYLE`(证明请求的样式真的生效了)。
|
||||
pub style_readback: Option<isize>,
|
||||
/// 什么都没设时 DWM 侧的偏好值(`0` = `DWMWCP_DEFAULT`)。
|
||||
pub preference_default: Option<u32>,
|
||||
/// 读上面那一格时的 `HRESULT`(`0` = `S_OK`;非 0 说明这个会话读不出 DWM 属性)。
|
||||
pub preference_read_hresult: u32,
|
||||
/// 设为 [`DWMWCP_ROUND`] 的结局(含回读)。
|
||||
pub set_round: CornerOutcome,
|
||||
/// 设为 [`DWMWCP_DONOTROUND`] 的结局(含回读)。
|
||||
pub set_square: CornerOutcome,
|
||||
/// 默认状态下的可见边框厚度(像素)。
|
||||
pub border_default: Option<u32>,
|
||||
/// 圆角状态下的可见边框厚度(像素)。
|
||||
pub border_round: Option<u32>,
|
||||
/// 直角状态下的可见边框厚度(像素)。
|
||||
pub border_square: Option<u32>,
|
||||
/// `extended_frame_bounds - GetWindowRect` = `(dx, dy)`(各边差值之和);
|
||||
/// `None` = 任一侧读不到。
|
||||
pub frame_delta: Option<(i32, i32)>,
|
||||
/// 窗口是否真的造出来了(`false` = 本机造不出窗口,整行无效)。
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
impl ProbeRow {
|
||||
/// 表格里的一行(对齐成文本,报告直接贴)。
|
||||
///
|
||||
/// **一格一格拼**而不是一条长 `format!`:位置参数一多,「多了/少了一个占位符」在编译期报、
|
||||
/// 在报告里却表现为整张表错位;这里列数由 [`TABLE_HEADER`] 与 [`Self::cells`] 同时决定,
|
||||
/// 单测比对竖线数。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
format!("| {} |", self.cells().join(" | "))
|
||||
}
|
||||
|
||||
/// 各列的原始文本(与 [`TABLE_HEADER`] 同序)。
|
||||
#[must_use]
|
||||
pub fn cells(&self) -> Vec<String> {
|
||||
let show = |value: Option<u32>| value.map_or_else(|| "?".to_owned(), |v| v.to_string());
|
||||
let delta = self
|
||||
.frame_delta
|
||||
.map_or_else(|| "?".to_owned(), |(dx, dy)| format!("{dx:+},{dy:+}"));
|
||||
vec![
|
||||
format!("{:<14}", self.label),
|
||||
format!("0x{:08X}", self.style),
|
||||
format!("0x{:08X}", self.ex_style),
|
||||
self.style_readback
|
||||
.map_or_else(|| "?".to_owned(), |style| format!("0x{:08X}", style as u32)),
|
||||
show(self.preference_default),
|
||||
format!("0x{:08X}", self.preference_read_hresult),
|
||||
show(self.border_default),
|
||||
show(self.border_round),
|
||||
show(self.border_square),
|
||||
delta,
|
||||
self.set_round.describe(),
|
||||
self.set_square.describe(),
|
||||
if self.created { "ok" } else { "FAILED" }.to_owned(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 表头(与 [`ProbeRow::describe`] 的列一一对应)。
|
||||
pub const TABLE_HEADER: &str = "| style | requested | exstyle | readback | pref | read_hr | bdef | brnd | bsq | Δframe | set ROUND | set SQUARE | created |";
|
||||
|
||||
/// 跑一遍受控对照实验(非 Windows 返回空表)。
|
||||
///
|
||||
/// 前两行是对照(**不是产品形状**),后两行才是产品:`product` = 出厂态(α = 1,不补分层),
|
||||
/// `product+alpha` = 用户把整窗 α 拉低之后的真实形状。
|
||||
///
|
||||
/// 每个窗口用完立刻销毁:这是诊断路径,不许留 HWND。
|
||||
#[must_use]
|
||||
pub fn controlled_experiment() -> Vec<ProbeRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (label, style, ex_style) in [
|
||||
("decorated", WS_OVERLAPPEDWINDOW, 0u32),
|
||||
("probe:popup", WS_POPUP, 0u32),
|
||||
("probe:popup+layered", WS_POPUP, WS_EX_LAYERED_U32),
|
||||
("product", PRODUCT_STYLE, 0u32),
|
||||
("product+alpha", PRODUCT_STYLE, WS_EX_LAYERED_U32),
|
||||
] {
|
||||
rows.push(run_one(label, style, ex_style));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// 一个样式的完整读数。`created = false` 时其余字段全 `None`(**不许**拿 0 冒充读数)。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_one(label: &'static str, style: u32, ex_style: u32) -> ProbeRow {
|
||||
use super::{create_probe_window, destroy_probe_window, show_probe_window};
|
||||
|
||||
let hwnd = create_probe_window(style, ex_style);
|
||||
if hwnd == 0 {
|
||||
return empty_row(label, style, ex_style);
|
||||
}
|
||||
show_probe_window(hwnd);
|
||||
// D36 的整窗 α:只有 layered 那一行才有意义(`apply_window_alpha` 对 α=1 不下发)。
|
||||
if ex_style & WS_EX_LAYERED_U32 != 0 {
|
||||
ensure_layered(hwnd);
|
||||
let _ = apply_window_alpha(hwnd, Some(0.8));
|
||||
}
|
||||
|
||||
let (preference_default, preference_read_hresult) = corner_preference_hr(hwnd);
|
||||
let border_default = visible_border_thickness(hwnd);
|
||||
let set_round = set_preference(hwnd, DWMWCP_ROUND);
|
||||
let border_round = visible_border_thickness(hwnd);
|
||||
let set_square = set_preference(hwnd, DWMWCP_DONOTROUND);
|
||||
let border_square = visible_border_thickness(hwnd);
|
||||
|
||||
let frame_delta =
|
||||
window_rect(hwnd)
|
||||
.zip(extended_frame_bounds(hwnd))
|
||||
.map(|(plain, extended)| {
|
||||
(
|
||||
(extended.0 - plain.0) + (extended.2 - plain.2),
|
||||
(extended.1 - plain.1) + (extended.3 - plain.3),
|
||||
)
|
||||
});
|
||||
|
||||
let row = ProbeRow {
|
||||
label,
|
||||
style,
|
||||
ex_style,
|
||||
style_readback: window_style(hwnd),
|
||||
preference_default,
|
||||
preference_read_hresult,
|
||||
set_round,
|
||||
set_square,
|
||||
border_default,
|
||||
border_round,
|
||||
border_square,
|
||||
frame_delta,
|
||||
created: true,
|
||||
};
|
||||
destroy_probe_window(hwnd);
|
||||
row
|
||||
}
|
||||
|
||||
/// 非 Windows:造不出 Win32 窗口,整行标记 `created = false`(不许编读数)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn run_one(label: &'static str, style: u32, ex_style: u32) -> ProbeRow {
|
||||
empty_row(label, style, ex_style)
|
||||
}
|
||||
|
||||
fn empty_row(label: &'static str, style: u32, ex_style: u32) -> ProbeRow {
|
||||
ProbeRow {
|
||||
label,
|
||||
style,
|
||||
ex_style,
|
||||
style_readback: None,
|
||||
preference_default: None,
|
||||
preference_read_hresult: 0,
|
||||
set_round: CornerOutcome::UnsupportedPlatform,
|
||||
set_square: CornerOutcome::UnsupportedPlatform,
|
||||
border_default: None,
|
||||
border_round: None,
|
||||
border_square: None,
|
||||
frame_delta: None,
|
||||
created: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::dwm::frame_label;
|
||||
use super::{
|
||||
controlled_experiment, ProbeRow, PRODUCT_STYLE, TABLE_HEADER, WS_OVERLAPPEDWINDOW, WS_POPUP,
|
||||
};
|
||||
|
||||
/// **抓得住**:样式常量写错(`WS_POPUP` 少一个 0 就变成别的位,对照实验的前提就没了)。
|
||||
#[test]
|
||||
fn the_style_constants_match_the_win32_header() {
|
||||
assert_eq!(WS_POPUP, 0x8000_0000);
|
||||
assert_eq!(WS_OVERLAPPEDWINDOW, 0x00CF_0000);
|
||||
}
|
||||
|
||||
/// **产品窗口不是 popup**(M116 r4 复审的核心事实,钉住它,别再写回去)。
|
||||
///
|
||||
/// 抓得住:有人把 `PRODUCT_STYLE` 改回 `WS_POPUP`(那会让「产品形状」整条证据链回到错的假设上);
|
||||
/// 也抓得住 `PRODUCT_STYLE` 丢掉 `WS_CAPTION`/`WS_THICKFRAME`(那会让 `Δframe` 从 `+0,−7`
|
||||
/// 掉成 `+0,+0`,把「产品没有边框带」这个错结论复活)。
|
||||
/// 抓不住:真进程上 winit 到底置了哪些位(那看 `[corner] … frame=` 的运行时读数)。
|
||||
#[test]
|
||||
fn the_product_window_is_overlapped_not_popup() {
|
||||
assert_eq!(
|
||||
PRODUCT_STYLE & WS_POPUP,
|
||||
0,
|
||||
"产品主窗口没有 owner ⇒ 不带 WS_POPUP"
|
||||
);
|
||||
assert_ne!(
|
||||
PRODUCT_STYLE & 0x00C0_0000,
|
||||
0,
|
||||
"WS_CAPTION 在(winit 起点)"
|
||||
);
|
||||
assert_ne!(PRODUCT_STYLE & 0x0004_0000, 0, "WS_THICKFRAME 在(可缩放)");
|
||||
assert_eq!(frame_label(PRODUCT_STYLE as isize), "overlapped");
|
||||
assert_eq!(frame_label(WS_POPUP as isize), "popup");
|
||||
}
|
||||
|
||||
/// 实验固定五行,顺序固定:两行对照 + 两行产品 + `decorated` 参照。
|
||||
///
|
||||
/// 抓得住:有人把分层那一行或产品行删了/调换——那样「layered 会不会让 DWM 忽略圆角请求」
|
||||
/// 与「产品真实形状有没有边框带」这两条对照就都没了。
|
||||
/// 抓不住:读数本身(那要么真机跑,要么看报告里的表)。
|
||||
#[test]
|
||||
fn the_experiment_always_covers_the_same_five_rows_in_order() {
|
||||
let rows = controlled_experiment();
|
||||
assert_eq!(rows.len(), 5);
|
||||
let labels: Vec<_> = rows.iter().map(|row| row.label).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"decorated",
|
||||
"probe:popup",
|
||||
"probe:popup+layered",
|
||||
"product",
|
||||
"product+alpha"
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[0].style, WS_OVERLAPPEDWINDOW);
|
||||
assert_eq!(rows[1].style, WS_POPUP);
|
||||
assert_eq!(rows[2].ex_style & 0x0008_0000, 0x0008_0000);
|
||||
// 产品两行:样式位相同(都是真实样式),只有 layered 一位不同。
|
||||
assert_eq!(rows[3].style, PRODUCT_STYLE);
|
||||
assert_eq!(rows[4].style, PRODUCT_STYLE);
|
||||
assert_eq!(rows[3].ex_style & 0x0008_0000, 0, "出厂态 α=1 ⇒ 不补分层");
|
||||
assert_eq!(rows[4].ex_style & 0x0008_0000, 0x0008_0000, "α<1 ⇒ 补分层");
|
||||
}
|
||||
|
||||
/// 表头与每行的竖线数一致(`format!` 里多/少一列会让表格在报告里错位)。
|
||||
#[test]
|
||||
fn the_table_columns_line_up() {
|
||||
let header_pipes = TABLE_HEADER.matches('|').count();
|
||||
for row in controlled_experiment() {
|
||||
let line = row.describe();
|
||||
assert_eq!(
|
||||
line.matches('|').count(),
|
||||
header_pipes,
|
||||
"列数不一致:{line}"
|
||||
);
|
||||
}
|
||||
// 没造出窗口的行必须**没有**读数(不许用 0 冒充)。
|
||||
let failed = ProbeRow {
|
||||
created: false,
|
||||
..controlled_experiment().into_iter().next().expect("五行")
|
||||
};
|
||||
assert!(failed.describe().contains("FAILED"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! `transparency` 的测试(M116 从 `transparency.rs` 搬出来:那个文件要留行数给 DWM 的 FFI)。
|
||||
//!
|
||||
//! **本文件零 `unsafe`、零 `extern`**:只调父模块的安全包装。别把 FFI 写进来——
|
||||
//! 「全仓唯一允许 unsafe 的文件」是 `transparency.rs`,不是它下面的每个文件。
|
||||
|
||||
use super::{
|
||||
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
|
||||
LWA_ALPHA, WS_EX_LAYERED,
|
||||
};
|
||||
use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
#[test]
|
||||
fn layered_constants_match_the_win32_header() {
|
||||
// 抓得住:常量写错(例如把 GWL_EXSTYLE 写成 -16 = GWL_STYLE,或 WS_EX_LAYERED 少写一个 0)。
|
||||
// 抓不住:这些值在真实窗口上是否真的生效(那要靠真机日志,见 app 侧)。
|
||||
assert_eq!(WS_EX_LAYERED, 0x0008_0000);
|
||||
assert_eq!(GWL_EXSTYLE, -20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_layered_refuses_a_missing_or_invalid_window() {
|
||||
// 句柄 0 = 还没拿到窗口:不许「假设成功」。
|
||||
assert!(!ensure_layered(0), "句柄 0 必须返回 false");
|
||||
// 1 号句柄永远无效:读不到扩展样式,也就加不上这一位。
|
||||
assert!(!ensure_layered(1), "无效句柄必须返回 false(不 panic)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(WINDOW_ALPHA_MIN),
|
||||
21,
|
||||
"0.08×255=20.4 → 向上取整 21;用 round 会得到 20,实际下发 20/255=0.0784 低于 D36 下限"
|
||||
);
|
||||
// 漏过预处理的坏值也不会变成「穿透」:一律落到下限字节。
|
||||
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 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)] {
|
||||
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);
|
||||
}
|
||||
|
||||
/// **受控对照实验**(M116):同进程造三种窗口样式,逐行读 DWM 属性。
|
||||
///
|
||||
/// 为什么是 `#[ignore]`:它要真造窗口(CI 的无桌面环境里跑不出读数),所以只在需要出证据时
|
||||
/// 手动跑:
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture
|
||||
/// ```
|
||||
///
|
||||
/// 输出就是报告里那张表(`| style | requested | exstyle | readback | … |`)。
|
||||
/// **抓不住**:角上到底是透明还是方块(那要合成输出像素,本机 RDP 断连拿不到)。
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"]
|
||||
fn m116_corner_controlled_experiment() {
|
||||
println!("{}", super::probe::TABLE_HEADER);
|
||||
for row in super::controlled_experiment() {
|
||||
println!("{}", row.describe());
|
||||
}
|
||||
}
|
||||
|
||||
/// **成功判据的语义**:`HRESULT` 看严重性位(bit 31),不是「等于 0」——`S_FALSE`(1) 也算成功。
|
||||
///
|
||||
/// ⚠️ 这条测试**不是**「本机 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 也是成功");
|
||||
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 踩过,一次误读的源头),
|
||||
// 于是「E_INVALIDARG」读起来像「一个信息级成功码」,整个证据链就毁了。
|
||||
let e_invalidarg = 0x8007_0057_u32 as i32;
|
||||
assert_eq!(e_invalidarg as u32, 0x8007_0057);
|
||||
assert_ne!(e_invalidarg.unsigned_abs(), 0x8007_0057);
|
||||
}
|
||||
|
||||
/// **两种「错误号」不同源,打印方式也不同**(tower 裁定 2026-11-03:不许统一)。
|
||||
///
|
||||
/// - `GetLastError()` 是 **DWORD**:`87`(`ERROR_INVALID_PARAMETER`)/ `1400`
|
||||
/// (`ERROR_INVALID_WINDOW_HANDLE`)这类小整数,没有严重性位,`set_layered` 用
|
||||
/// `raw_os_error().unsigned_abs()` 取的就是它;
|
||||
/// - `Dwm*WindowAttribute` 返回 **HRESULT**:成功看 bit 31,打印用 `as u32` 原始位。
|
||||
///
|
||||
/// 抓得住:有人把 `set_layered` 也改成 `as u32`(或把 DWM 那侧改成 `unsigned_abs`)——
|
||||
/// 前者对 `87` 无害但对高位被置的错误号会印出负数,后者会把 `E_INVALIDARG` 印成
|
||||
/// `0x7FF8FFA9`(M116 实测踩过的那个坑)。
|
||||
#[test]
|
||||
fn dword_error_codes_and_hresults_are_printed_differently() {
|
||||
use super::hresult_ok;
|
||||
|
||||
// DWORD 侧:典型值是小的正数,两种打印结果一致(所以错误只会在高位情形暴露)。
|
||||
for dword in [87_i32, 1400] {
|
||||
assert_eq!(dword.unsigned_abs(), dword as u32);
|
||||
assert!(
|
||||
hresult_ok(dword),
|
||||
"DWORD 不是 HRESULT,不该拿这个判据去读它"
|
||||
);
|
||||
}
|
||||
|
||||
// HRESULT 侧:失败码的原始位与 `unsigned_abs()` 必须不同。
|
||||
let e_invalidarg = 0x8007_0057_u32 as i32;
|
||||
assert!(!hresult_ok(e_invalidarg));
|
||||
assert_eq!(e_invalidarg as u32, 0x8007_0057);
|
||||
assert_ne!(e_invalidarg.unsigned_abs(), e_invalidarg as u32);
|
||||
}
|
||||
|
||||
/// **产品日志行 `[corner] …` 的真实取值**(同一套判据 + 同一套格式化,句柄换成探针窗口)。
|
||||
///
|
||||
/// `excluded_reason=` / `frame=` / `layered=` 三栏是「为什么我们的窗口没有圆角」的唯一现场线索;
|
||||
/// 报告里要贴真实取值,不是模板。跑法同 `m116`:
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture
|
||||
/// ```
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "要真窗口:手动跑 cargo test -p unlucky-app-iced --lib m116 -- --ignored --nocapture"]
|
||||
fn m116_corner_log_line_on_real_windows() {
|
||||
use crate::platform::win32::corner::plan_corner;
|
||||
use unlucky_presenter::prefs::WindowCorner;
|
||||
|
||||
let os = crate::platform::current_windows_version();
|
||||
let plan = plan_corner(WindowCorner::System, os, crate::Backend::Gpu);
|
||||
// 直接遍历 `controlled_experiment` 的**同一份**行定义(含 `product` / `product+alpha`),
|
||||
// 免得这里另抄一份样式表、跟探针悄悄分叉。
|
||||
for row in super::controlled_experiment() {
|
||||
let hwnd = super::create_probe_window(row.style, row.ex_style);
|
||||
if hwnd == 0 {
|
||||
println!("[corner] {} window=none", row.label);
|
||||
continue;
|
||||
}
|
||||
super::show_probe_window(hwnd);
|
||||
// layered=on 的那几行(产品的 α<1 档与裸 popup 对照)按裁定 A 带 `suppressed_by=`。
|
||||
let layered = row.ex_style & 0x0008_0000 != 0;
|
||||
println!(
|
||||
"{}",
|
||||
crate::platform::win32::corner::format_line(
|
||||
row.label,
|
||||
&plan,
|
||||
WindowCorner::System,
|
||||
&super::apply_corner(hwnd, &plan).describe(),
|
||||
&super::dwm::style_summary(hwnd),
|
||||
layered.then_some("window_alpha"),
|
||||
)
|
||||
);
|
||||
super::destroy_probe_window(hwnd);
|
||||
}
|
||||
|
||||
// 设置里选「直角」时的取值(`excluded_reason` 必须变成 `user`)。
|
||||
let square = plan_corner(WindowCorner::Square, os, crate::Backend::Gpu);
|
||||
println!(
|
||||
"[corner] policy=square {}",
|
||||
square.log_line(WindowCorner::Square)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user