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 警告抓出并复原。
This commit is contained in:
AskaEth committed 2026-11-03 15:22:55 +08:00
1 parent 2061296615
commit 380fac0324
13 files changed
+545 -201

No files matched your search

+83 -179
View File
@@ -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<appearance::State>,
window: Option<window::Id>,
/// 窗口原生句柄(`window::raw_id` 的回值)。OS 侧密闭指标要在**真句柄**上读,
/// 拿不到就留 `None` ⇒ 日志记 `unknown`(不是 `off`)。
hwnd: Option<isize>,
/// 窗口落点(`window::position` 的**返回值**,不是请求值)。
placed: Option<Point>,
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::Id>),
/// `window::raw_id` 的回值(原生句柄,OS 侧密闭指标要它)。
RawId(u64),
/// `window::position` 的回值。
Placed(Option<Point>),
/// `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<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 {
@@ -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(