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

+190
View File
@@ -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(())
}
+53
View File
@@ -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<isize>) -> &'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)"
);
}
}
+52 -4
View File
@@ -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 = 还没拿到窗口"
);
}