diff --git a/crates/app-iced/src/cli.rs b/crates/app-iced/src/cli.rs index bfdb72d..d740aec 100644 --- a/crates/app-iced/src/cli.rs +++ b/crates/app-iced/src/cli.rs @@ -116,6 +116,11 @@ pub enum ForcedState { /// 第 6 次给出第三个 hash,差异区是某个按钮被画成 `Hovered`——因为 harness/产品都开真窗口, /// `button::Status` 由系统光标位置决定。取证图不能因为「指针恰好压在某个按钮上」而变色。 /// 显式 `--force-state=hover|pressed` 仍然优先(那是**故意**要画那个态)。 + /// + /// ⚠️ **语义边界**:`Rest` 落成 `Interaction::Enabled`,而 `components::buttons::assemble()` + /// 把「没有 `on_press`」的按钮默认画成 **Disabled** —— 于是 `Rest` 会把一个本应禁用的按钮 + /// 画成可用。当前零影响(所有 `forced_state(` 调用点与 shell 的 caption / nav 按钮都带消息), + /// 但给「无消息按钮」出取证图时别套 `Rest`。 Rest, /// 所有可交互控件按 hover 画。 Hover, diff --git a/crates/app-iced/src/pages/harness.rs b/crates/app-iced/src/pages/harness.rs index 4e29697..9d1ec69 100644 --- a/crates/app-iced/src/pages/harness.rs +++ b/crates/app-iced/src/pages/harness.rs @@ -10,15 +10,39 @@ //! ## 取证密闭(M100):取证图不许跟着系统指针变 //! //! M99 复审实测过:同一个 exe 连跑 8 次,第 6 次给出**第三个 hash**,差异区是某个按钮被画成 Hovered -//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定,而「连跑两次一样」不是判据。 +//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定。 //! -//! 所以 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough` +//! 于是 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough` //! (winit 的 `set_cursor_hittest(false)`)⇒ 窗口从此收不到指针事件 ⇒ 全树控件恒为 `Active`。 -//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不消费 -//! forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新组件随时会漏。 //! -//! 同时数指针事件(`iced::event::listen_with`)并打进日志:**「这张图收到几个鼠标事件」是可读数字**, -//! 于是「hash 稳」不再是「大概没漏」,而是「这张图压根没收到指针事件」。 +//! ⚠️ **限定**:上句的因果链在**有指针输入的会话**里才被实测过。本机(RDP 断开)连 +//! `SetCursorPos` 都失败,所以「`指针事件=0`」在本环境里**既可能是穿透生效,也可能是根本没输入**。 +//! 要让 `指针事件=N` 变成密闭证据,必须同一 argv 再跑一次 `--passthrough=off` 做对照: +//! 那一跑该给出 `N>0`(复审注入后实测 12)**并且** hash 与开穿透时不同。没有这个对照, +//! `指针事件=0` 就退回成「没观测到输入」,而不能读成「输入被挡住了」。 +//! +//! ### 证据怎么读(M100 r1 复审订正,优先级从高到低) +//! +//! 1. **`[caps] passthrough_ext=on|off|unknown`** ← 主证据。winit 0.30 在 Windows 把 +//! `set_cursor_hittest(false)` 落成 `WS_EX_TRANSPARENT | WS_EX_LAYERED`,这一位可以从 +//! `GWL_EXSTYLE` 读回来([`caps`] → `platform::win32::transparency::click_through`)。 +//! 复审探针实测:`--screenshot` 默认 176/214 次采样命中、`--passthrough=off` **0/320**、 +//! 产品 bin **0/535**。三态里的 `unknown` 是「没读到」,**不许**读成 `off`。 +//! 2. `指针事件=N`:**辅助**指标。它证明「鼠标消息到没到」,**不**证明「像素会不会变」 +//! (复审实测:注入 12 个事件而像素不变)。别拿它当密闭证据。 +//! 3. ~~hash 连跑几次都一样~~:**不是证据**。同 argv 加 `--passthrough=off` 出来的 hash 与 +//! 开穿透时完全相同 ⇒ 它只能说明「那几帧恰好静止」。 +//! +//! ### 同一族的另一个坑:一次一采样(P2-1) +//! +//! 以前第 3 帧之后**每一帧**都请求一次采样、每次都覆盖同一文件才 `exit()`,而已派发的采样不会 +//! 因为 exit 撤回 ⇒ **最后写入者说了算**(复审实测 12 次里 7 次写了两次;把窗口在两次采样之间 +//! 改成 900×600,落盘的就是 900×600 那张)。现在闸门在 [`should_request_capture`],日志里的 +//! `落盘次数=` 必须恒为 1。 +//! +//! **为什么不是给每个控件灌 forced**:设置页「外观」区的滑块 / 文本框 / 裸 `iced::button` 都不 +//! 消费 forced,那样做要改十几个组件文件——为取证去动产品组件,代价与风险都不对,而且新产品 +//! 组件随时会漏。 //! //! ```text //! cargo run -p unlucky-app-iced --example pages_demo -- --screen=instances --width=1180 \ @@ -31,190 +55,33 @@ use std::time::Instant; use iced::{window, Element, Point, Subscription, Task}; -use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; - -use crate::app::components::interaction::Interaction; -use crate::cli::Backend; -use crate::fonts; use crate::pages::appearance; -use crate::probe::{self, Probe}; use crate::renderer; use crate::shot; use crate::theme::Ui; use super::{app_with_appearance, demo_model, Model}; +mod app; +mod caps; mod request; +pub use app::run; pub use request::{Request, CAPTURE_POSITION}; /// 截图落盘的帧号(见模块文档)。 const CAPTURE_AT_FRAME: u32 = 3; -/// 启动(阻塞到出图或关窗)。 +/// 这一帧要不要请求采样? /// -/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时 -/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被 -/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。 +/// **M100 r1 P2-1 的核心判据**:`dispatched` 一旦置位就永不再次请求。以前这里是 +/// `frames >= CAPTURE_AT_FRAME`,于是第 3 帧之后**每一帧**都发一次 `window::screenshot`, +/// 而 `Captured` 每次都覆盖同一文件才 `exit()`——已派发的采样不会因为 exit 被撤回, +/// 落盘内容变成调度结果(复审实测 12 次里 7 次写了两次)。 /// -/// # Errors -/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。 -pub fn run(request: Request) -> Result<(), String> { - let started = Instant::now(); - - let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled(); - let probe = if needs_probe { - Probe::run() - } else { - Probe::default() - }; - // 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。 - let decision = renderer::strict(probe::apply(request.backend, &probe)); - eprintln!( - "[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)", - decision.primary.name(), - decision.reason - ); - renderer::acceptance::report_backend_plan(&decision, request.backend); - - let resolved = fonts::resolve(); - eprintln!("{}", resolved.describe()); - let families = resolved.families(); - - // 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训) - let settings = window::Settings { - size: iced::Size::new(request.width, request.height), - decorations: false, - resizable: true, - // 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由 - // `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`) - position: match request.screenshot { - Some(_) => { - window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1)) - } - None => window::Position::default(), - }, - ..window::Settings::default() - }; - renderer::acceptance::report_window( - &renderer::acceptance::WindowFacts::from_settings(&settings), - request.scale_factor, - "pages_demo", - None, - ); - - // 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。 - // 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。 - let appearance = request.real_appearance.then(|| { - let mut state = appearance::State::new( - &AppearancePrefs::factory(), - &AppearanceBase::factory(), - request.backend, - request.flavor, - ); - state.apply_env(); - eprintln!( - "[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \ - 配色来源={:?} 选中分组={}", - state.window_opacity, - state.panel_alpha, - state.effective_panel_alpha(), - state.bg_enabled, - state.bg_fill, - state.bg_dim, - state.palette_source, - state.selected_group, - ); - state - }); - - let application = |backend: renderer::Kind| { - let boot = request.clone(); - let appearance = appearance.clone(); - iced::application( - move || { - let mut model = demo_model(boot.screen, boot.width); - // 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot` - // 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced), - // 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。 - if boot.screenshot.is_some() { - model.forced = Some(Interaction::Enabled); - } - ( - Harness { - ui: Ui::new(boot.flavor, families), - model, - appearance: appearance.clone(), - request: boot.clone(), - window: None, - placed: None, - frames: 0, - pointer: 0, - started, - first_frame: None, - backend: backend.name(), - }, - Task::done(Message::Boot), - ) - }, - Harness::update, - Harness::view, - ) - .theme(Harness::theme) - .subscription(Harness::subscription) - .default_font(iced::Font { - family: iced::font::Family::Name(families.text), - ..iced::Font::DEFAULT - }) - .window(settings) - }; - - eprintln!( - "[harness] screen={} width={} height={} slug={}(布局档位 {})", - request.screen.slug(), - request.width, - request.height, - request.slug(), - super::layout::WidthClass::of(request.width).name() - ); - - // 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致, - // 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。 - let session_started = Instant::now(); - let result = renderer::run_once(decision, |backend| { - std::env::set_var("ICED_BACKEND", backend.env_value()); - let application = application(backend); - match request.scale_factor { - Some(scale) => application.scale_factor(move |_| scale).run(), - None => application.run(), - } - }); - let session = session_started.elapsed(); - - let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) { - Some(named) => format!( - "渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}", - decision.primary.name() - ), - None => format!("iced 启动失败:{error}"), - }); - - // 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4) - renderer::acceptance::report_outcome( - &decision, - outcome.as_ref().err().map(String::as_str), - renderer::acceptance::failure_via(decision.fallback.is_some(), true), - "screenshot", - session, - ); - - outcome?; - - eprintln!( - "[harness] 用时 {:.0} ms", - started.elapsed().as_secs_f64() * 1000.0 - ); - Ok(()) +/// 单独拎成自由函数是为了让它可被断言(`Harness::update` 要 iced 运行时才能跑)。 +fn should_request_capture(frames: u32, dispatched: bool, capture_mode: bool) -> bool { + capture_mode && !dispatched && frames >= CAPTURE_AT_FRAME } struct Harness { @@ -225,10 +92,20 @@ struct Harness { /// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。 appearance: Option, window: Option, + /// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读, + /// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。 + hwnd: Option, /// 窗口落点(`window::position` 的**返回值**,不是请求值)。 placed: Option, frames: u32, - /// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数;密闭性靠它自证)。 + /// 采样**已派发**(M100 r1 P2-1):置位后不再请求第二张,避免「最后写入者说了算」。 + dispatched: bool, + /// 本进程真正落盘了几次(正常必须恒为 1;打进日志让复审查得到)。 + writes: u32, + /// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数)。 + /// + /// ⚠️ 它只证明「鼠标消息到没到」,**不**证明「像素会不会变」(复审实测:注入 12 个事件而像素 + /// 不变)。密闭的**主证据**是 `[caps] passthrough_ext=`,见 [`caps`]。 pointer: u32, /// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。 started: Instant, @@ -242,6 +119,8 @@ struct Harness { enum Message { Boot, WindowReady(Option), + /// `window::raw_id` 的回值(原生句柄,OS 侧密闭指标要它)。 + RawId(u64), /// `window::position` 的回值。 Placed(Option), /// `window::size` 的回值。 @@ -266,14 +145,19 @@ impl Harness { let Some(id) = id else { return Task::none(); }; - let mut tasks = Vec::new(); + let mut tasks: Vec> = Vec::new(); if self.request.passthrough { eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)"); tasks.push(window::enable_mouse_passthrough(id)); } + tasks.push(window::raw_id::(id).map(Message::RawId)); tasks.push(window::position(id).map(Message::Placed)); Task::batch(tasks) } + Message::RawId(raw) => { + self.hwnd = isize::try_from(raw).ok(); + Task::none() + } Message::Placed(position) => { self.placed = position; match self.window { @@ -296,11 +180,23 @@ impl Harness { self.first_frame = Some(self.started.elapsed()); } self.frames += 1; - if self.frames < CAPTURE_AT_FRAME || self.request.screenshot.is_none() { + // **一次一采样**(M100 r1 P2-1):以前这里是 `frames >= CAPTURE_AT_FRAME`,于是第 3 帧 + // 之后**每一帧**都发一次 `window::screenshot`,而 `Captured` 每次都覆盖同一文件才 + // `exit()`——已派发的采样不会因为 exit 被撤回 ⇒ **最后写入者说了算**。复审实测 12 次里 + // 7 次真的写了两次;把窗口在两次采样之间改成 900×600,落盘的就是 900×600 那张。 + // 现在 `dispatched` 一置位就不再请求(`window` 还没就绪时不置位,保留重试,不会挂死)。 + if !should_request_capture( + self.frames, + self.dispatched, + self.request.screenshot.is_some(), + ) { return Task::none(); } match self.window { - Some(id) => window::screenshot(id).map(Message::Captured), + Some(id) => { + self.dispatched = true; + window::screenshot(id).map(Message::Captured) + } None => Task::none(), } } @@ -312,16 +208,24 @@ impl Harness { let Some(path) = self.request.screenshot.clone() else { return Task::none(); }; + self.writes += 1; match shot::save(&screenshot, &path) { Ok(summary) => eprintln!("{}", summary.describe()), Err(error) => eprintln!("[harness] 截图失败:{error}"), } eprintln!( - "[harness] slug={} backend={} 指针事件={}(这张图的来源与密闭性,自证用)", + "[harness] slug={} backend={} 落盘次数={} 指针事件={}", self.request.slug(), self.backend, + self.writes, self.pointer ); + // OS 侧密闭指标(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。三态,`unknown` 不许 + // 写成 `off`。这一行是「窗口真的把命中测试关掉了」的主证据;`指针事件=` 只是辅助。 + eprintln!( + "[caps] passthrough_ext={}(GWL_EXSTYLE 的 WS_EX_TRANSPARENT;unknown = 没读到)", + caps::passthrough_label(self.hwnd) + ); // `[perf]` 两格都是实测:`first_frame` = 首帧事件的时间戳,`fps` = 帧数 / 时长 // (**取证窗口**的吞吐,不是长跑稳态帧率——那个看产品路径的 `[tick]`)。 renderer::acceptance::report_perf_scene( diff --git a/crates/app-iced/src/pages/harness/app.rs b/crates/app-iced/src/pages/harness/app.rs new file mode 100644 index 0000000..4e33e1d --- /dev/null +++ b/crates/app-iced/src/pages/harness/app.rs @@ -0,0 +1,190 @@ +//! 截图 harness 的**运行部分**(M100 r1:`harness.rs` 撞 400 行后从那里搬出来)。 +//! +//! 这里只有 [`run`]:探后端 → 造外观真状态 → 起 iced → 出图 → 按验收口径打日志。 +//! 参数解析在 [`super::request`],窗口状态机与采样闸门在 [`super`],OS 侧密闭指标在 +//! [`super::caps`]。搬出来没有改行为(同一 argv 的 PNG sha256 不变)。 + +use std::time::Instant; + +use iced::{window, Point, Task}; + +use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs}; + +use crate::app::components::interaction::Interaction; +use crate::cli::Backend; +use crate::fonts; +use crate::pages::appearance; +use crate::probe::{self, Probe}; +use crate::renderer; +use crate::theme::Ui; + +use super::{demo_model, Harness, Message, Request, CAPTURE_POSITION}; + +/// 启动(阻塞到出图或关窗)。 +/// +/// **取证通道的硬规则(M97)**:宁可红着退出,也不能悄悄换一种后端继续出图(以前 wgpu 挂掉时 +/// 会静默降级 tiny-skia 照样落 PNG,而报告上写着「GPU 渲染截图」)。所以这里 ① 决策被 +/// [`renderer::strict`] 剥掉兜底、② 起不来带**具名**错误返回、③ 每张图日志都带 `backend=`。 +/// +/// # Errors +/// 后端探测/字体求值失败、**渲染后端起不来(含 iced 的具名图形错误)**、截图落盘失败时返回人话。 +pub fn run(request: Request) -> Result<(), String> { + let started = Instant::now(); + + let needs_probe = request.backend == Backend::Auto || renderer::acceptance::enabled(); + let probe = if needs_probe { + Probe::run() + } else { + Probe::default() + }; + // 剥掉兜底:gallery 没有「偷偷换 CPU」这个选项。 + let decision = renderer::strict(probe::apply(request.backend, &probe)); + eprintln!( + "[shot] 出图后端 = {} reason={}(取证通道无回退:起不来就红着退出,绝不换 CPU 出图)", + decision.primary.name(), + decision.reason + ); + renderer::acceptance::report_backend_plan(&decision, request.backend); + + let resolved = fonts::resolve(); + eprintln!("{}", resolved.describe()); + let families = resolved.families(); + + // 这一份就是喂给 iced 的真值:`[window]` 那行从它取字段(M98 r1 p2-1 的同一个教训) + let settings = window::Settings { + size: iced::Size::new(request.width, request.height), + decorations: false, + resizable: true, + // 取证窗口钉在固定落点:这样「把指针放进窗口某个位置」是可复算的(真实矩形由 + // `Harness` 用 `window::position` / `window::size` 的返回值打日志报出,见 `Message::Sized`) + position: match request.screenshot { + Some(_) => { + window::Position::Specific(Point::new(CAPTURE_POSITION.0, CAPTURE_POSITION.1)) + } + None => window::Position::default(), + }, + ..window::Settings::default() + }; + renderer::acceptance::report_window( + &renderer::acceptance::WindowFacts::from_settings(&settings), + request.scale_factor, + "pages_demo", + None, + ); + + // 外观**真状态**(`--appearance=real`):工厂态 + `ULL_APPEARANCE_*` 覆盖。 + // 这不是给设置页塞常量:状态从 `State::new` / `apply_env` 来,打日志的是它算出来的生效值。 + let appearance = request.real_appearance.then(|| { + let mut state = appearance::State::new( + &AppearancePrefs::factory(), + &AppearanceBase::factory(), + request.backend, + request.flavor, + ); + state.apply_env(); + eprintln!( + "[harness] 外观真状态:window={:.2} panel={:.2}(生效 {:.2})bg={} fill={:?} dim={:.2} \ + 配色来源={:?} 选中分组={}", + state.window_opacity, + state.panel_alpha, + state.effective_panel_alpha(), + state.bg_enabled, + state.bg_fill, + state.bg_dim, + state.palette_source, + state.selected_group, + ); + state + }); + + let application = |backend: renderer::Kind| { + let boot = request.clone(); + let appearance = appearance.clone(); + iced::application( + move || { + let mut model = demo_model(boot.screen, boot.width); + // 截图通道把**消费 forced 的那批控件**(实例页)钉成常态,与产品 `--screenshot` + // 的 `ForcedState::Rest` 同一条纪律。它管不到设置页 / 外观区(那边不读 forced), + // 那两屏的密闭靠窗口级 passthrough——别把这条当成万能的。 + if boot.screenshot.is_some() { + model.forced = Some(Interaction::Enabled); + } + ( + Harness { + ui: Ui::new(boot.flavor, families), + model, + appearance: appearance.clone(), + request: boot.clone(), + window: None, + hwnd: None, + placed: None, + frames: 0, + dispatched: false, + writes: 0, + pointer: 0, + started, + first_frame: None, + backend: backend.name(), + }, + Task::done(Message::Boot), + ) + }, + Harness::update, + Harness::view, + ) + .theme(Harness::theme) + .subscription(Harness::subscription) + .default_font(iced::Font { + family: iced::font::Family::Name(families.text), + ..iced::Font::DEFAULT + }) + .window(settings) + }; + + eprintln!( + "[harness] screen={} width={} height={} slug={}(布局档位 {})", + request.screen.slug(), + request.width, + request.height, + request.slug(), + crate::pages::layout::WidthClass::of(request.width).name() + ); + + // 兜底已被 `strict` 剥掉,所以这条最多跑一次;具名错误的口径与 App 启动一致, + // 但**取证通道不许降级**:出不了图就红着退出,不换后端(那是另一处 iced 行为)。 + let session_started = Instant::now(); + let result = renderer::run_once(decision, |backend| { + std::env::set_var("ICED_BACKEND", backend.env_value()); + let application = application(backend); + match request.scale_factor { + Some(scale) => application.scale_factor(move |_| scale).run(), + None => application.run(), + } + }); + let session = session_started.elapsed(); + + let outcome = result.map_err(|error| match renderer::named_graphics_error(&error) { + Some(named) => format!( + "渲染后端 {} 初始化失败(取证通道无回退,未出图):{named}", + decision.primary.name() + ), + None => format!("iced 启动失败:{error}"), + }); + + // 取证通道恒为 evidence 启动 ⇒ 有兜底时 via=refuse、没兜底时 none(不再写死,M98 r1 p3-4) + renderer::acceptance::report_outcome( + &decision, + outcome.as_ref().err().map(String::as_str), + renderer::acceptance::failure_via(decision.fallback.is_some(), true), + "screenshot", + session, + ); + + outcome?; + + eprintln!( + "[harness] 用时 {:.0} ms", + started.elapsed().as_secs_f64() * 1000.0 + ); + Ok(()) +} diff --git a/crates/app-iced/src/pages/harness/caps.rs b/crates/app-iced/src/pages/harness/caps.rs new file mode 100644 index 0000000..4dcbff8 --- /dev/null +++ b/crates/app-iced/src/pages/harness/caps.rs @@ -0,0 +1,53 @@ +//! 取证窗口的**OS 侧密闭指标**(M100 r1 P2-2):`WS_EX_TRANSPARENT` 在不在。 +//! +//! 为什么单独一个文件:① `harness.rs` 正卡在 400 行硬线上;② 「三态映射」这件事本身值得有断言 +//! (把 `unknown` 掉成 `off` 是这类日志最典型的假绿)。 +//! +//! 判据来自 winit 0.30 的 Windows 实现:`set_cursor_hittest(false)` ⇒ +//! `WS_EX_TRANSPARENT | WS_EX_LAYERED`。读位的手在 +//! [`crate::platform::win32::transparency::click_through`](全仓唯一 unsafe 落点)。 + +use crate::platform::win32::transparency; + +/// 窗口是不是点击穿透 → 日志里的三态字面量。 +/// +/// - `on` = 读到了,且有 `WS_EX_TRANSPARENT`(winit 已把命中测试关掉); +/// - `off` = 读到了,没有这一位; +/// - `unknown` = **没读到**(非 Windows / 句柄无效 / 还没拿到句柄)。 +/// +/// `unknown` 绝不许写成 `off`:「拿不到」和「没有」是两件事(U1 同族)。 +#[must_use] +pub fn passthrough_label(hwnd: Option) -> &'static str { + match hwnd.and_then(transparency::click_through) { + Some(true) => "on", + Some(false) => "off", + None => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::passthrough_label; + + /// **抓得住**:谁把「没读到」掉成 `off`(那样一次失败的读数就变成了「密闭没开」的假证据, + /// 或者反过来——在一台读不到扩展样式的机器上永远看不出密闭是否生效)。 + /// **抓不住**:真实窗口上的位到底置没置(那要出图日志里看 `on`)。 + #[test] + fn a_missing_handle_is_unknown_not_off() { + assert_eq!( + passthrough_label(None), + "unknown", + "没句柄 = 没读到 ⇒ unknown" + ); + assert_eq!( + passthrough_label(Some(0)), + "unknown", + "句柄 0 = 拿不到 ⇒ unknown(不是 off)" + ); + assert_eq!( + passthrough_label(Some(1)), + "unknown", + "无效句柄 ⇒ unknown(不是 off)" + ); + } +} diff --git a/crates/app-iced/src/pages/harness/tests.rs b/crates/app-iced/src/pages/harness/tests.rs index f04b6a7..d7076d2 100644 --- a/crates/app-iced/src/pages/harness/tests.rs +++ b/crates/app-iced/src/pages/harness/tests.rs @@ -1,9 +1,11 @@ //! `pages::harness` 的断言(M90 r2 从 `harness.rs` 外置:那边曾 402 行,guards 的 file-size 会判红)。 //! -//! 只测**参数解析**与**出图 slug**,不碰 iced 运行时——所以「指针穿透真的把窗口的鼠标输入关了」 -//! 这类事这里证明不了,只能靠出图时日志里的 `指针事件=N`(见 `harness.rs` 的模块文档)。 +//! 只测**参数解析 / slug / 采样闸门 / 三态标签**,不碰 iced 运行时。所以「穿透在真实窗口上真的 +//! 关掉了命中测试」这条这里证明不了——它要看出图日志里的 `[caps] passthrough_ext=`(三态, +//! 来自 `GWL_EXSTYLE` 的 `WS_EX_TRANSPARENT`);`指针事件=N` 只是辅助指标(它只证明消息到没到)。 use super::request::Request; +use super::should_request_capture; use crate::cli::Backend; use crate::pages::Screen; @@ -102,8 +104,7 @@ fn the_control_run_can_turn_the_passthrough_off() { assert!(shot.screenshot.is_some(), "off 只对截图通道有意义"); } -/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。 -/// +/// **外观真状态那一屏(M100)**:`--appearance=real` 只在设置页成立,且 slug 要与演示行版分开。/// /// 抓得住:① 谁把 real 接到别的页(那会出一张「假装接了线」的图); /// ② 两张图落成同名文件(归档后无法区分哪张是真控件版)。 /// 抓不住:那张图里到底有没有控件——那是像素的事,由两张图的 sha256 不同 + tower 亲自看图判。 @@ -123,3 +124,50 @@ fn the_real_control_screen_is_settings_only_and_says_so_in_the_slug() { ); assert!(parse(&["--screen=settings", "--appearance=x"]).is_err()); } + +/// **一次一采样(M100 r1 P2-1)**:采样请求最多发一次,且最早在 `CAPTURE_AT_FRAME`。 +/// +/// 抓得住:谁把闸门改回 `frames >= CAPTURE_AT_FRAME`(那样第 3 帧之后每帧都发一次采样, +/// `Captured` 每次都覆盖同一文件 ⇒ **最后写入者说了算**;复审实测 12 次里 7 次写了两次, +/// 并把窗口在两次采样之间改成 900×600 之后落盘的就是 900×600 那张)。 +/// 抓不住:「落盘次数真的是 1」——那要看真实运行的日志 `落盘次数=1`(本测试只测闸门逻辑)。 +#[test] +fn the_capture_gate_opens_once_and_never_again() { + // 窗口到第 4 帧才就绪也要恰好请求一次(`window` 未就绪时不置位,保留重试,不会挂死) + let mut dispatched = false; + let mut requests = 0; + for frames in 1..=10u32 { + let window_ready = frames >= 4; + if should_request_capture(frames, dispatched, true) && window_ready { + dispatched = true; + requests += 1; + } + } + assert_eq!(requests, 1, "整个进程只许请求一次采样"); + + assert!(!should_request_capture(1, false, true), "第 3 帧前不许采样"); + assert!(should_request_capture(3, false, true), "第 3 帧取第一张"); + assert!( + !should_request_capture(99, true, true), + "已派发过就不再请求(这条就是 P2-1 的修复本身)" + ); + assert!( + !should_request_capture(99, false, false), + "交互模式(没有 --screenshot)不采样" + ); +} + +/// **密闭日志三态(M100 r1 P2-2)**:拿不到句柄必须记 `unknown`,不许记 `off`。 +/// +/// 抓得住:谁把 `None` 掉成 `off`(那会把「没读到」说成「读到了、没开」——这台机器上从此永远 +/// 看不出密闭到底生效没有)。 +/// 抓不住:真实窗口上的位(那要看运行日志里的 `passthrough_ext=on`)。 +#[test] +fn the_passthrough_capability_is_three_states() { + assert_eq!(super::caps::passthrough_label(None), "unknown"); + assert_eq!( + super::caps::passthrough_label(Some(0)), + "unknown", + "句柄 0 = 还没拿到窗口" + ); +} diff --git a/crates/app-iced/src/pages/settings/tests/appearance_section.rs b/crates/app-iced/src/pages/settings/tests/appearance_section.rs index 96726bb..1741086 100644 --- a/crates/app-iced/src/pages/settings/tests/appearance_section.rs +++ b/crates/app-iced/src/pages/settings/tests/appearance_section.rs @@ -1,7 +1,9 @@ -//! 「Appearance」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! 「Appearance」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的, +//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! //! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在 //! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。 +//! 本组已经把 `body` 接上了,所以 `tests/mod.rs` 那条「只有一组有真控件」的绊线不用再动。 use super::super::appearance; use unlucky_design::icons::IconKind; diff --git a/crates/app-iced/src/pages/settings/tests/download_section.rs b/crates/app-iced/src/pages/settings/tests/download_section.rs index db55145..8404c39 100644 --- a/crates/app-iced/src/pages/settings/tests/download_section.rs +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -1,6 +1,8 @@ -//! 「Downloads」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! 「Downloads」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的, +//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! -//! **归 download 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。 +//! **归 download 线**。接真控件那天要改**三个**文件:本文件 `the_downloads_group_is_still_demo_rows` +//! (`body.is_none()` 绊线)+ `settings/download.rs` + `tests/mod.rs` 的跨 section 绊线。 //! //! 别把它和下载页(`pages/download*`)搞混:那是「装哪个版本」,这一组是「怎么下」的默认值。 diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 262598d..4bc8834 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -1,7 +1,9 @@ -//! 「General」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! 「General」分组自己的断言(M100 **新增**:`settings/tests.rs` 那 7 条全是跨 section 的, +//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。 //! -//! **归 language+appearance 线**:接真控件时改本文件(`GROUP.body` 那条绊线要跟着翻), -//! 不必动 `tests/mod.rs`,也不碰别的分组。 +//! **归 language+appearance 线**。接真控件那天要改**三个**文件:本文件 +//! `the_general_group_is_still_demo_rows`(`body.is_none()` 绊线)+ `settings/general.rs` + +//! `tests/mod.rs` 的跨 section 绊线。 use super::super::general; use unlucky_design::icons::IconKind; diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index b30e0fb..a08df1b 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -1,7 +1,14 @@ -//! 「Java」分组自己的断言(M100 从 `settings/tests.rs` 下放)。 +//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有, +//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。 //! -//! **归 java 线**:接真控件时改本文件,不必动 `tests/mod.rs`,也不碰别的分组。 -//! 判据(M100 交付里正面回答的那条):给 java 加一条新断言 = **只改本文件**。 +//! **归 java 线**。接真控件那天要改**三个**文件: +//! 1. 本文件 `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红); +//! 2. `settings/java.rs`(写 `body = Some(..)`); +//! 3. `tests/mod.rs` 的 `only_the_appearance_group_carries_a_real_control_body`(跨 section 绊线)。 +//! +//! download / general 同型。 +//! +//! 判据(M100 交付里正面回答的那条):给 java 加一条**新断言** = **只改本文件**。 use super::super::java; use unlucky_design::icons::IconKind; diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 8d8e1f1..665b3a4 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -1,15 +1,31 @@ -//! 设置页的**跨 section** 守门测试(M100 按 section 下放之后,留在 mod 层的那一份)。 +//! 设置页的**跨 section** 守门测试(M100:跨 section 的留在这里,section 自己的断言**新增**在 +//! `tests/
_section.rs`)。 //! //! ## 为什么这里只剩「跨 section」的断言 //! //! 设置页正文接下来由四条线并行接(java / download / language+appearance / ai+about)。每条线要能 -//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律下放到 -//! `tests/
_section.rs`;本文件只留**必须四组一起看**的断言——下放到任何单组文件里, -//! 都会变成「某组替其它三组说话」,四条线并行时必然互相踩。 +//! **只改自己的测试**就跑通,所以「某一组自己的行文案 / 图标 / body 现状」一律放在 +//! `tests/
_section.rs`(M100 **新增**的 12 条,不是从本文件搬过去的——本文件在 `main` +//! 上就只有下面这 7 条跨 section 断言,一条 section 断言都没有);本文件只留**必须四组一起看**的 +//! 断言——下放到任何单组文件里,都会变成「某组替其它三组说话」,四条线并行时必然互相踩。 //! //! 判据(M100 报告里正面回答的那条):**给 java 加一条新断言,改几个文件?** —— 1 个 //! (`tests/java_section.rs`)。本文件不动。 //! +//! 但「java 线**接真控件**那天改几个文件」是另一回事,答案是 **3 个**(M100 r1 复审 p3-3 订正, +//! 我最初只写成 2 个):`settings/java.rs`(写 body)+ `tests/java_section.rs` 的 +//! `the_java_group_is_still_demo_rows`(它断言 `body.is_none()`,接上就红)+ 本文件末尾那条 +//! `only_the_appearance_group_carries_a_real_control_body`。download / general 同型。 +//! +//! ## ai+about 线要额外改本文件的两处(M100 r1 复审 p3-4) +//! +//! 它加第 5 组时,除了 `settings/mod.rs` 的 `GROUPS`,**本文件还有两处必改**: +//! 1. 顶部 `mod ai_section;`(新测试文件要挂进来,否则永远不编译、永远不跑); +//! 2. [`the_split_kept_the_left_column_order`] 断言的是**字面量** `["General","Appearance", +//! "Java","Downloads"]`,加第 5 组必红——这是刻意的:左列顺序变了就是观感变了,要人确认。 +//! 另外 `the_appearance_group_is_where_the_module_says_it_is` 依赖 `APPEARANCE_GROUP == 1`, +//! **把新组插在 Appearance 前面会连带红**(所以新组应该排最后)。 +//! //! ## 站位 //! //! 本文件是 `pages::settings::tests`:`super` = `pages::settings`,`super::super` = `pages`。 diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 9be8cb5..9854812 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -44,6 +44,10 @@ // D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。 #![allow(unsafe_code)] +mod exstyle; + +pub use exstyle::{click_through, is_click_through_style, WS_EX_TRANSPARENT}; + use unlucky_presenter::prefs::WINDOW_ALPHA_MIN; /// Win32 的 `LWA_ALPHA` 标志位:只改整窗 α(`0x2`)。 diff --git a/crates/app-iced/src/platform/win32/transparency/exstyle.rs b/crates/app-iced/src/platform/win32/transparency/exstyle.rs new file mode 100644 index 0000000..83c646f --- /dev/null +++ b/crates/app-iced/src/platform/win32/transparency/exstyle.rs @@ -0,0 +1,106 @@ +//! 取证用的**扩展样式探针**(M100 r1 P2-2):这个窗口是不是「点击穿透」。 +//! +//! 为什么单开一个文件:`transparency.rs` 撞 400 行硬线(361 → 447)。`unsafe` 的**放行属性仍只有 +//! `transparency.rs` 顶部那一个**——lint 级别沿模块树继承,子模块直接继承父模块的 +//! `#![allow(unsafe_code)]`,所以本文件**不许**再写那行属性(CI 的 `unsafe-scope` 判据是 +//! 「带放行属性的文件集合恰好等于 `transparency.rs`」)。 +//! +//! 判据来源: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」冒充「没这一位」。 +/// +/// ## 用途 +/// +/// 出图时把它打进日志(`pages::harness` 的 `[caps] passthrough_ext=…`),于是「取证窗口到底 +/// 有没有把指针输入关掉」每次运行都能自证,不依赖外部探针。 +#[cfg(target_os = "windows")] +#[must_use] +pub fn click_through(hwnd: isize) -> Option { + if hwnd == 0 { + return None; + } + // SAFETY: `IsWindow` 只按值接受句柄,不持有指针、无所有权转移;无效句柄返回 0。 + if unsafe { IsWindow(hwnd) } == 0 { + 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 { + None +} + +#[cfg(target_os = "windows")] +#[link(name = "user32")] +extern "system" { + /// `IsWindow`:句柄是否指向一个存在的窗口(读扩展样式前的门槛)。 + /// + /// 为什么要先问一句:`GetWindowLongPtrW` 对无效句柄返回 0,与「扩展样式全 0」不可区分—— + /// 不先验一次,[`click_through`] 就会把「读不到」说成「没开穿透」。 + fn IsWindow(hwnd: isize) -> i32; +} + +#[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" + ); + } +} diff --git a/crates/app-iced/src/shell/tests.rs b/crates/app-iced/src/shell/tests.rs index 5713a8e..0aa7b6d 100644 --- a/crates/app-iced/src/shell/tests.rs +++ b/crates/app-iced/src/shell/tests.rs @@ -55,10 +55,11 @@ fn flavor_slug_is_stable_for_filenames() { /// **交互模式不许被取证开关污染**(M100 的头号不变量)。 /// -/// 抓得住:谁把 `--screenshot` 的默认值 `Some(Rest)` 顺手设成了 `Options::force_state` 的默认值 -/// (或者把默认往下挪一层,落到 `overridden` 的 `None` 分支上)——那会让**用户窗口里所有按钮 -/// 永远不显示 hover**,是产品事故,而不是取证问题。 -/// 抓不住:取证模式有没有真的压掉 hover(那是下一条)。 +/// 抓得住:`overridden` 的 `None` 分支被改坏——例如把「跟控件自己的状态走」写成了固定 +/// `Active`/`Hovered`(那会让**用户窗口里所有按钮**不再反映 hover/pressed,是产品事故)。 +/// 抓不住:**默认值被顺手设成 `Some(Rest)`** 这类错误——`overridden(None, …)` 根本不会被调用, +/// 本条照样绿;那一条由 `cli::tests::screenshots_default_to_the_rest_state` 抓(它断言 +/// 交互模式 `force_state == None`)。两条各守一半,别拿一条当另一条的证据。 #[test] fn interactive_mode_still_hovers() { use iced::widget::button::Status; @@ -77,7 +78,11 @@ fn interactive_mode_still_hovers() { /// 抓得住:谁把 `Rest` 接成「保留现状」(那等于没钉:系统指针 hover 照样漏进图——M99 复审实测 /// 连跑 8 次翻盘 1 次就是这里没钉住)。 /// 抓不住:① 不经过 `overridden` 的控件(标题栏以外的裸 `iced::button`,例如设置页「外观」区); -/// ② 窗口到底还收不收鼠标事件(那是 harness 的 passthrough,靠出图日志的 `指针事件=N` 自证)。 +/// ② 窗口到底还收不收鼠标事件(那是 harness 的 passthrough,看 `[caps] passthrough_ext=`); +/// ③ **`Rest` 与「真 Disabled」撞语义**:`components::buttons::assemble()` 把「无 `on_press`」的 +/// 按钮默认画成 Disabled,而 `Rest => Enabled` 会把它画成可用。当前零影响(8 处 `forced_state(` +/// 调用点与 shell 的 caption / nav 全带消息),但将来若有人加一个**无消息**的按钮又被 `Rest` 罩住, +/// 那张取证图会画出「一个本应禁用的按钮」而这条断言照绿。 #[test] fn rest_suppresses_cursor_hover() { use iced::widget::button::Status;