复审在 `d298691` 的导出上实测:交互预览模式(不给 `--screenshot`)关窗会被新判据判成失败, exit 1 + 一句「取证结束但没有任何图落盘(窗口提前消失 / 落盘失败)」的**假消息**。 那是把「从未要求出图」判成「取证失败」,退出码因此失去区分力 (真崩溃 / 真取证失败 / 正常关个预览窗全是 1)。 - `no_evidence(capture_mode, written) = capture_mode && written == 0`;调用点传 `request.screenshot.is_some()`。判据里写明:交互预览模式下 `subscription` 是 `None`、 永远不会有 `Captured`,所以「没写盘」在那里不是失败。 - 注释补一条「为什么用计数器而不是 `path.exists()`」:旧 PNG 会让 exists 骗过判据。 - 断言改名 `finishing_without_a_single_write_is_a_failure_only_while_capturing`, 三条:`(false,0)=false`(这次的红线)/ `(true,0)=true` / `(true,1)=false`。 顺手 p3: - p3-1 `CAPTURE_GIVE_UP_FRAMES` 注释删掉「约 3~4 秒(~120ms/帧)」这个没测准的数字, 改成写明帧间隔量级差异(复审实测 2~3ms/帧 vs 本机 `[perf]` 的 ≈200~300ms/帧), 「要调按第 3 帧派发→第 4~5 帧落盘的结构关系调,别按秒」。 - p3-2 旧字段名 `writes` 的注释改成「这个计数器(旧字段名叫 `writes`)」;顺带把 `capture_stalled` 的形参 `writes` 改名 `written`,与字段对齐。 - p3-3 `examples/pages_demo.rs` 的 usage 漏 `--appearance`/`--passthrough`:该文件**不在本 mission scope**(scope 只有 `pages/harness*`),不扩 scope,欠账仍记在已开的 finding 里。 实测: - 交互预览(WM_CLOSE 关窗,取真实退出码):最终二进制 5/5 有效跑 exit=0、`没有任何图落盘` 行 0; 另 2 次 exit=0xC000013A(STATUS_CONTROL_C_EXIT,控制台 Ctrl 事件打断,日志停在半途)不计。 修前同场景 4/4 exit=1。 - M3 变异(第 3 帧 `window::screenshot` → `window::close`)3/3 exit=1 + 具名行(没被误伤)。 - M4 变异(`CAPTURE_GIVE_UP_FRAMES` 30→0)2/2 exit=1 走「采样超时」(没被误伤)。 - 正常取证 `--appearance=real` ×6:exit=0 / png=yes / `落盘次数=1` / `passthrough_ext=on` / 错误行 0 / sha256 `414a71fcb77e9b38…`(与 r1~r4 基线逐字节相同,像素没动)。
318 lines
17 KiB
Rust
318 lines
17 KiB
Rust
//! 截图 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::sync::atomic::{AtomicU32, Ordering};
|
||
use std::sync::Arc;
|
||
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
|
||
}
|
||
|
||
/// 采样「派发了却回不来」的宽限帧数(M100 r2 p3-b)。
|
||
///
|
||
/// 为什么需要它:一次性闸门(上面那条)把「每帧重试」换成了「只试一次」,于是若
|
||
/// `window::screenshot` 的 Task 回不来,harness 既不再采样也不 `exit()` ⇒ CI 上表现成**超时**,
|
||
/// 而不是红灯。超时会被当成基础设施抖动重跑过去,红才会被人看。
|
||
///
|
||
/// ⚠️ **这条兜底只覆盖「还有帧」的情形**(M100 r3 P2-1 订正,我原来在这里写反了):
|
||
/// `window::frames()` 只在 `Event::Window(RedrawRequested)` 时产消息,**窗口消失 ⇒ 不再有帧
|
||
/// ⇒ 这里的判据根本求值不到**。那一路由 `run()` 里的**终态不变量**兜住(跑完没图 ⇒ 红着退出);
|
||
/// 而后端报错是另一回事:`run()` 直接返回 `Err`,本来就是非 0。
|
||
///
|
||
/// 30 帧**不换算成秒**(M100 r4 P3-1):帧间隔在不同机器/负载下量级差异极大——复审实测 2~3ms/帧,
|
||
/// 而本机 `[perf]` 日志里是 `frames=4 elapsed=1133ms` / `frames=5 elapsed=1605ms`(≈200~300ms/帧)。
|
||
/// 所以这里只承诺「若干帧的宽限」,不写时间预算;要调就按「正常路径是第 3 帧派发、第 4~5 帧落盘」
|
||
/// 这个结构关系调,别按秒。
|
||
const CAPTURE_GIVE_UP_FRAMES: u32 = 30;
|
||
|
||
/// 采样是不是**卡住了**:过了宽限帧数,一次都没落盘。
|
||
///
|
||
/// 不区分「一直没派发」(窗口始终没就绪)与「派发了没回来」——两者对使用者的表现都是
|
||
/// 「没有图」,都该红着退出而不是挂死。
|
||
fn capture_stalled(frames: u32, written: u32) -> bool {
|
||
written == 0 && frames > CAPTURE_AT_FRAME + CAPTURE_GIVE_UP_FRAMES
|
||
}
|
||
|
||
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,
|
||
/// **成功落盘**的次数(共享给 [`run`],终态不变量读它)。
|
||
///
|
||
/// 只在 `shot::save` 返回 `Ok` 之后自增:这是「真的写出去了」,不是「我们打算写」(U1)。
|
||
/// 正常必须恰好 1;打进日志让复审能数。
|
||
written: Arc<AtomicU32>,
|
||
/// 本张图收到过几个鼠标事件(`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 r2 p3-b):采样派发了却回不来时,不许挂死也不许 exit 0。
|
||
// 这里用 `std::process::exit(1)` 而不是 `iced::exit()`:iced 0.14 没有「带退出码
|
||
// 结束」的入口,`exit()` 恒 0 —— 而这条的全部意义就是让 CI 看到**红**而不是超时。
|
||
if capture_stalled(self.frames, self.written.load(Ordering::Relaxed)) {
|
||
eprintln!(
|
||
"[harness] 采样超时:已过 CAPTURE_AT_FRAME+{CAPTURE_GIVE_UP_FRAMES} 帧\
|
||
(当前 {} 帧)仍未收到 Captured,放弃并红着退出",
|
||
self.frames
|
||
);
|
||
std::process::exit(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();
|
||
};
|
||
// 只在真的写成功时计数(U1):这个计数器(旧字段名叫 `writes`)若在 save **之前**
|
||
// 自增,一次落盘失败也会被记成「有图了」,而 `run()` 的终态不变量正是拿它判「有没有证据」。
|
||
match shot::save(&screenshot, &path) {
|
||
Ok(summary) => {
|
||
eprintln!("{}", summary.describe());
|
||
self.written.fetch_add(1, Ordering::Relaxed);
|
||
}
|
||
Err(error) => eprintln!("[harness] 截图失败:{error}"),
|
||
}
|
||
eprintln!(
|
||
"[harness] slug={} backend={} 落盘次数={} 指针事件={}",
|
||
self.request.slug(),
|
||
self.backend,
|
||
self.written.load(Ordering::Relaxed),
|
||
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;
|