Files
Unlucky-Launcher/crates/app-iced/src/pages/harness.rs
T
AskaEth 380fac0324 fix(app-iced): M100 r1 返工 —— 一次一采样 + OS 层密闭指标 + 文档口径订正
三条复审意见 + 六条 p3,全部落在原有文件里(另加两个拆分文件)。

P2-1 截图落盘「最后写入者说了算」(真竞态)
  `Message::Frame` 以前在 `frames >= CAPTURE_AT_FRAME` 之后每帧都发一次 `window::screenshot`,
  `Captured` 每次都覆盖同一文件才 `exit()`;已派发的采样不会因 exit 撤回 ⇒ 落盘内容是调度结果
  (复审实测 12 次里 7 次写了两次;把窗口在两次采样之间改成 900×600,落盘就是 900×600)。
  现在闸门 = `should_request_capture(frames, dispatched, capture_mode)`,派发即置位、只开一次;
  `Captured` 计数落盘次数并打进日志。实测 12/12 次「[shot] -> 文件」行恒为 1、`落盘次数=1`。

P2-2 密闭证据换成 OS 层
  `platform/win32/transparency.rs` 加安全 getter `click_through`(unsafe 仍只在既有唯一落点);
  该文件因超 400 行又拆出 `transparency/exstyle.rs`(子模块继承父模块的 `#![allow(unsafe_code)]`,
  `unsafe-scope` 门禁判据不受影响)。日志三态 `[caps] passthrough_ext=on|off|unknown`:
  `None` 记 `unknown`,绝不记 `off`。实测默认 on 12/12、`--passthrough=off` 为 off 3/3。

P2-3 头注释「下放」→「新增」,并订正「接真控件改 3 个文件」
  (section 文件里的 body 绊线 + 该 section 的 `GROUP` 文件 + `tests/mod.rs` 跨 section 绊线)。

p3 六条:shell 断言说明改准(默认值那条归 cli 的测试)、harness 文档给「指针事件=0」加对照限定、
数字订正(纯搬移 234 / 新增 134)、三文件口径、ai+about 对本文件的两处必改、`Rest` 与真 Disabled
撞语义写进「抓不住」。

文档口径:「8 连跑全稳」不再当密闭证据——同 argv 开/关穿透 hash 相同(实测 0d7b4f5b 两边一样),
它只说明「帧静止 + 落盘确定」。密闭主证据是 `[caps] passthrough_ext=`。

顺带:`run()` 从 `harness.rs` 拆到 `harness/app.rs`(272 / 190 行)。搬移中丢过一条既有断言
`ensure_layered_refuses_a_missing_or_invalid_window`,被编译器 unused-import 警告抓出并复原。
2026-11-03 15:22:55 +08:00

273 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 截图 harness:把**一页**按给定窗口宽度 / 主题单独渲染并出图(`examples/pages_demo.rs` 的入口)。
//!
//! **刻意不接 `cli.rs`**:等 `cli.rs` 具备 `--screen` / `--window-width` 之后,这个 harness 应当
//! 删掉,改由正式启动参数出图。参数:`--screen=instances|download|settings|logs`、`--width`、
//! `--height`、`--theme` / `--screenshot-theme`(后者优先)、`--screenshot`、`--renderer`、
//! `--software-renderer`、`--scale-factor`、`--appearance=real|demo`、`--passthrough=on|off`。
//! 解析与默认值在 [`request`](M100 拆出去:本文件当时正好 400 行、零余量);没给 `--screenshot`
//! 就只是开窗看。
//!
//! ## 取证密闭(M100):取证图不许跟着系统指针变
//!
//! M99 复审实测过:同一个 exe 连跑 8 次,第 6 次给出**第三个 hash**,差异区是某个按钮被画成 Hovered
//! ——根因是 harness 开真窗口,`button::Status` 由**系统光标位置**决定。
//!
//! 于是 `--screenshot` 有一条**通道级**钉死:开窗那一刻调 `window::enable_mouse_passthrough`
//! (winit 的 `set_cursor_hittest(false)`)⇒ 窗口从此收不到指针事件 ⇒ 全树控件恒为 `Active`。
//!
//! ⚠️ **限定**:上句的因果链在**有指针输入的会话**里才被实测过。本机(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 \
//! --height=760 --screenshot=out.png --screenshot-theme=light --software-renderer
//! ```
//!
//! 等 3 帧再截图与 `app.rs` 同一口径(M60 坑 #5):第一帧可能是启动时的旧尺寸,字体首次上屏会抖动。
use std::time::Instant;
use iced::{window, Element, Point, Subscription, Task};
use crate::pages::appearance;
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;
/// 这一帧要不要请求采样?
///
/// **M100 r1 P2-1 的核心判据**:`dispatched` 一旦置位就永不再次请求。以前这里是
/// `frames >= CAPTURE_AT_FRAME`,于是第 3 帧之后**每一帧**都发一次 `window::screenshot`,
/// 而 `Captured` 每次都覆盖同一文件才 `exit()`——已派发的采样不会因为 exit 被撤回,
/// 落盘内容变成调度结果(复审实测 12 次里 7 次写了两次)。
///
/// 单独拎成自由函数是为了让它可被断言(`Harness::update` 要 iced 运行时才能跑)。
fn should_request_capture(frames: u32, dispatched: bool, capture_mode: bool) -> bool {
capture_mode && !dispatched && frames >= CAPTURE_AT_FRAME
}
struct Harness {
request: Request,
ui: Ui,
/// 视图模型(演示数据;harness 不接 presenter,只出**单页**图)
model: Model,
/// 外观运行期状态(`--appearance=real`);`None` = 设置页画演示行。
appearance: Option<appearance::State>,
window: Option<window::Id>,
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
hwnd: Option<isize>,
/// 窗口落点(`window::position` 的**返回值**,不是请求值)。
placed: Option<Point>,
frames: u32,
/// 采样**已派发**(M100 r1 P2-1):置位后不再请求第二张,避免「最后写入者说了算」。
dispatched: bool,
/// 本进程真正落盘了几次(正常必须恒为 1;打进日志让复审查得到)。
writes: u32,
/// 本张图收到过几个鼠标事件(`Message::Pointer` 的计数)。
///
/// ⚠️ 它只证明「鼠标消息到没到」,**不**证明「像素会不会变」(复审实测:注入 12 个事件而像素
/// 不变)。密闭的**主证据**是 `[caps] passthrough_ext=`,见 [`caps`]。
pointer: u32,
/// 进程起点(`run()` 的开头)——`[perf]` 的首帧口径与产品路径一致:从进 `run()` 算。
started: Instant,
/// 第一帧到达的时刻(相对 `started`)。`None` = 一帧都没到过。
first_frame: Option<std::time::Duration>,
/// 出这张图的后端(`wgpu` / `tiny-skia`)。图要能**自证来源**——以前它证不了(M97)。
backend: &'static str,
}
#[derive(Debug, Clone)]
enum Message {
Boot,
WindowReady(Option<window::Id>),
/// `window::raw_id` 的回值(原生句柄,OS 侧密闭指标要它)。
RawId(u64),
/// `window::position` 的回值。
Placed(Option<Point>),
/// `window::size` 的回值。
Sized(iced::Size),
Frame,
Captured(window::Screenshot),
/// 收到一个指针事件(只计数,不画;见 [`Harness::subscription`])。
Pointer,
/// 页面里的动作(骨架期只打印,接线后由 app 层处理)。
Page(super::Message),
}
impl Harness {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::Boot => window::latest().map(Message::WindowReady),
Message::WindowReady(id) => {
self.window = id;
// 取证窗口按住**那一刻**就断开指针输入(M100 模块文档「取证密闭」)。
// 交互模式走不到这里:`request.passthrough` 在没有 `--screenshot` 时恒为 false
// (`Request::parse` 里显式给值会直接报错,不给值就是 false)。
let Some(id) = id else {
return Task::none();
};
let mut tasks: Vec<Task<Message>> = Vec::new();
if self.request.passthrough {
eprintln!("[harness] 指针穿透已开:本窗口不再接收鼠标事件(取证专用)");
tasks.push(window::enable_mouse_passthrough(id));
}
tasks.push(window::raw_id::<Message>(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 {
Some(id) => window::size(id).map(Message::Sized),
None => Task::none(),
}
}
Message::Sized(size) => {
match self.placed {
Some(Point { x, y }) => eprintln!(
"[harness] 窗口实测矩形:({x:.0}, {y:.0}) {:.0}×{:.0}(window::position/size 的回值)",
size.width, size.height
),
None => eprintln!("[harness] 窗口落点取不到(window::position 回 None)"),
}
Task::none()
}
Message::Frame => {
if self.frames == 0 {
self.first_frame = Some(self.started.elapsed());
}
self.frames += 1;
// **一次一采样**(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) => {
self.dispatched = true;
window::screenshot(id).map(Message::Captured)
}
None => Task::none(),
}
}
Message::Pointer => {
self.pointer += 1;
Task::none()
}
Message::Captured(screenshot) => {
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={} 落盘次数={} 指针事件={}",
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(
&self.request.slug(),
self.frames,
self.first_frame,
self.started.elapsed(),
);
iced::exit()
}
Message::Page(message) => {
eprintln!("[harness] 页面动作:{message:?}(骨架期不做业务)");
Task::none()
}
}
}
fn subscription(&self) -> Subscription<Message> {
if self.request.screenshot.is_none() {
return Subscription::none();
}
// 帧节拍 + **指针事件计数**两样都订阅。后者是 M100 加的:以前「取证密闭」靠
// 「同一场景连跑几次 hash 一样」证明,而进程压根收不到鼠标时那条恒真(U5 的同族假绿)。
Subscription::batch([
window::frames().map(|_| Message::Frame),
iced::event::listen_with(|event, _status, _window| match event {
iced::Event::Mouse(_) => Some(Message::Pointer),
_ => None,
}),
])
}
fn theme(&self) -> iced::Theme {
self.ui.theme()
}
fn view(&self) -> Element<'_, Message> {
app_with_appearance(&self.ui, &self.model, self.appearance.as_ref()).map(Message::Page)
}
}
#[cfg(test)]
#[path = "harness/tests.rs"]
mod tests;