From 14f9318e092c494e843af4a1bab3c4639e1811fd Mon Sep 17 00:00:00 2001 From: tower-worker Date: Sun, 8 Nov 2026 19:33:56 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(m196):=20GUI=20=E4=BA=A7=E7=89=A9=20PE?= =?UTF-8?q?=20Subsystem=20=E2=86=92=20WINDOWS=5FGUI(2)=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E7=94=A8=20AttachConsole=20=E4=BF=9D=E4=BD=8F=20CLI=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头 Subsystem 仍是 CUI(3)。同一个修复 2026-11-01 做过一次(37d3039),但只改了已被 workspace exclude 的 Slint 树(crates/app),从未到达 iced 产物。 - crates/app-iced/src/main.rs:加 #![cfg_attr(windows, windows_subsystem = "windows")], 并写清为什么放 bin 层而不是 build.rs(PE 子系统是每个 bin 目标的属性)。 - crates/app-iced/src/platform/win32/console.rs(新):AttachConsole(ATTACH_PARENT_PROCESS) 的落点 + 判据 should_attach(纯函数,可断言)。GUI 子系统进程不继承父控制台,实测 从 cmd 启动时三个 std 句柄全是 0;附着后 GetStdHandle 立刻给出控制台句柄(0x0→0x208), 所以不改 std 句柄、不碰用户的重定向(ATTACH_PARENT_PROCESS 只会附到已存在的控制台, 永不新建窗口)。 - packaging/check_pe_subsystem.py(新):读产物 PE 头 Subsystem 字段(GUI 必须 2、 CLI ull 必须 3),非 2 即红;--self-test 注入 5 条合成 PE 回归。 - 接进 release-build.yml(发布侧两槽位)与 ci.yml build job(PR 阶段)。 - guards.yml:unsafe 落点期望集合变成两处(console.rs + transparency.rs),集合相等判据其余不变; 平台层所有「全仓唯一 unsafe 落点」措辞同步改正。 --- .github/workflows/ci.yml | 12 + .github/workflows/guards.yml | 29 +- .github/workflows/release-build.yml | 20 ++ crates/app-iced/src/lib.rs | 2 +- crates/app-iced/src/main.rs | 40 ++- crates/app-iced/src/platform/mod.rs | 9 +- crates/app-iced/src/platform/win32/console.rs | 248 ++++++++++++++ crates/app-iced/src/platform/win32/corner.rs | 2 +- crates/app-iced/src/platform/win32/mod.rs | 7 +- .../src/platform/win32/transparency.rs | 20 +- .../src/platform/win32/transparency/dwm.rs | 2 +- .../platform/win32/transparency/exstyle.rs | 2 +- .../src/platform/win32/transparency/tests.rs | 2 +- packaging/README.md | 24 ++ packaging/check_pe_subsystem.py | 310 ++++++++++++++++++ 15 files changed, 693 insertions(+), 36 deletions(-) create mode 100644 crates/app-iced/src/platform/win32/console.rs create mode 100644 packaging/check_pe_subsystem.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c07bbc5..a9f8391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,18 @@ jobs: # 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档 # (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。 - run: cargo build --workspace --release + # M196:PR 阶段就拦住「产物退回 CUI(3)」。判据是产物 PE 头的 `Subsystem` 字段,不是源码文本—— + # 这个 bug 的原始形态正是「源码写了、打在另一棵树上」,任何只看 `grep windows_subsystem` 的守卫 + # 都抓不住它。发布侧同一份脚本再跑一次(`release-build.yml`),两处判据同一个真源。 + - name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3) + if: runner.os == 'Windows' + shell: bash + run: | + set -euo pipefail + PY=$(command -v python3 || command -v python) + "$PY" packaging/check_pe_subsystem.py \ + --gui target/release/unlucky-launcher-iced.exe \ + --cli target/release/ull.exe # 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。 # 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里 diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml index 0fbc9b1..1feddf2 100644 --- a/.github/workflows/guards.yml +++ b/.github/workflows/guards.yml @@ -10,7 +10,7 @@ name: Guards # 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身 # 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) # 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 -# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好只有一个(D36 / M91) +# 6. unsafe-scope —— 全仓 `allow(unsafe_code)` 的文件集合必须恰好等于期望集合(D36 / M91;M196 起两处) # # M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件, # **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转: @@ -179,22 +179,22 @@ jobs: fi echo "OK: unlucky-design 依赖树只有自身,零第三方依赖" - # D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的 - # `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。 - # 判据必须是**集合相等**而不是「黑名单命中」: - # - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); + # D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。 + # M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件, + # 判据是**集合相等**而不是「黑名单命中」: + # - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 unsafe-scope: - name: unsafe 落点守门(集合必须恰好一个文件) + name: unsafe 落点守门(集合必须恰好等于期望) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: 全仓显式放行 unsafe 的文件集合 == 唯一落点 + - name: 全仓显式放行 unsafe 的文件集合 == 期望集合 run: | set -uo pipefail - EXPECTED='crates/app-iced/src/platform/win32/transparency.rs' + EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs' # 扫描口径(M91 r3):一个小词法器,而不是正则。 # # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 @@ -332,22 +332,23 @@ jobs: actual=$(git grep -l 'unsafe_code' -- '*.rs' 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do if scan "$f"; then printf '%s\n' "$f"; fi done | LC_ALL=C sort || true) - echo "期望(恰好这一个):$EXPECTED" + echo "期望(恰好这两处,集合相等):$EXPECTED" echo "实际扫到的集合:" if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi if [ "$actual" != "$EXPECTED" ]; then extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true) if [ -n "$extra" ]; then - echo "::error::多出来的 unsafe 落点(D36 只允许一个):" + echo "::error::多出来的 unsafe 落点(不在期望集合里):" printf '%s\n' "$extra" | sed 's/^/ - /' fi - if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then - echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。" + if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then + echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):" + printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true fi - echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。" + echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。" exit 1 fi - echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED" + echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合" file-size: name: 文件行数 ≤400 守门 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index d081786..ba58a28 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -259,6 +259,26 @@ jobs: echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1 fi + # M196:**读产物 PE 头的 `Subsystem`**,不读源码。同一个修复 2026-11-01 曾打在已退役的 Slint 树 + # (`37d3039` 只改了 `crates/app/src/main.rs`,而 `crates/app` 自 M118 起被 workspace `exclude`、 + # 不进发布矩阵),于是「代码里写过」与「产物里有没有」彻底脱钩——双击多一个控制台窗口这个 bug + # 就是这么活到 2026-11-08 的。本步只认那 4 个字节:GUI 产物必须是 WINDOWS_GUI(2),非 2 即红。 + # 反方向一并守:CLI 产物 `ull.exe` 必须仍是 CONSOLE(3)——它靠真控制台做 stdout/stderr, + # 谁把属性抄到它头上,`ull --version | …` 会静默丢输出。守门本体在 packaging/check_pe_subsystem.py + # (零第三方依赖,本地同一份代码可跑;`--self-test` 注入 5 条回归证明它会红)。 + - name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3) + if: runner.os == 'Windows' + shell: bash + env: + BUILD_DIR: ${{ inputs.build_dir }} + run: | + set -euo pipefail + PY=$(command -v python3 || command -v python) + # 读数(含文件字节偏移)直接进日志:红的时候要看的就是这个数,而不是「脚本说不对」。 + "$PY" packaging/check_pe_subsystem.py \ + --gui "$BUILD_DIR/unlucky-launcher-iced.exe" \ + --cli "$BUILD_DIR/ull.exe" + - name: 打包(产物 + SHA256SUMS) shell: bash env: diff --git a/crates/app-iced/src/lib.rs b/crates/app-iced/src/lib.rs index 2638a5a..e7cb392 100644 --- a/crates/app-iced/src/lib.rs +++ b/crates/app-iced/src/lib.rs @@ -14,7 +14,7 @@ //! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 | //! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 | //! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) | -//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 | +//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— `unsafe` 落点之一;另一半是 M196 的控制台附着 | 窗口创建 / 业务开关 | //! //! ## 消费纪律 //! diff --git a/crates/app-iced/src/main.rs b/crates/app-iced/src/main.rs index f3dad9c..49ffbf8 100644 --- a/crates/app-iced/src/main.rs +++ b/crates/app-iced/src/main.rs @@ -1,4 +1,4 @@ -//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里: +//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里。 //! //! 一个二进制两种用法(M82),分发在进 UI 之前完成: //! @@ -15,13 +15,49 @@ //! 给服务器 / CI / Termux 这类没有图形库的机器用。 //! //! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。 +//! +//! ## 为什么这里是 WINDOWS GUI 子系统(M196) +//! +//! 下面那条 `#![cfg_attr(windows, windows_subsystem = "windows")]` 直接改的是**产物 PE 头**的 +//! `Subsystem` 字段(CUI=3 → WINDOWS_GUI=2),这是「双击不再多一个常驻控制台宿主窗口」的 +//! 唯一机制:PE 头写 3 时由系统在启动时分配控制台(那个黑框),写 2 时系统不分配。 +//! +//! **为什么放这一层而不是 `build.rs`**:PE 子系统是**每个 bin 目标**的属性,而 `cfg_attr` 就是 +//! 它的原生长相——rustc 读到它就把标志写进链接出来的 PE 里,没有任何别的路径能改到那一行。 +//! `build.rs` 做不到这件事:它只能在编译**之前**跑(`cargo:rustc-link-arg` 之类确实能加链接参数, +//! 但那是「顺手改了链接命令行」的间接法,且在交叉/多 bin 时按 crate 而非按 target 生效), +//! 更麻烦的是它把「这条 bin 是 GUI」这条事实挪到一个**看不见 bin 声明**的地方——本 crate 的 +//! `[[bin]]` 名是 `unlucky-launcher-iced`(不是 crate 名),读 `build.rs` 的人得先在 Cargo.toml +//! 里找回来。放 `main.rs` 顶上,事实与产物一一对应:改这个文件 = 改这个 bin。 +//! +//! 代价与补救见 [`unlucky_app_iced::platform::win32::console`]:GUI 子系统进程**不继承**父进程 +//! 的控制台,所以 `main()` 第一步就把输出接回父控制台(`AttachConsole`),CLI 子命令、 +//! `--diff`、`--screenshot` 的日志在 cmd / 终端里照样看得见;双击时没有父控制台,附着失败、 +//! 也绝不新建窗口。判据是**产物 PE 头的读数**,不是「代码看起来对」: +//! `packaging/check_pe_subsystem.py` 读 `Subsystem` 字段,非 2 即红(`release-build.yml` 里跑)。 +//! +//! 历史:同样的修复曾做过一次,但打在已退役的 Slint 树(`37d3039`,只改了 `crates/app/src/main.rs`), +//! 而 `crates/app` 自 M118 起不在 workspace、不进发布矩阵——所以那一笔从未到这里。 +// M196:PE 头的子系统 = WINDOWS_GUI(2)。没有它,双击产物会多一个控制台宿主窗口。 +// 这一行只对 bin 目标有意义(lib 没有 PE 头),所以放在 main.rs 而不是 lib.rs。 +#![cfg_attr(windows, windows_subsystem = "windows")] use std::process::ExitCode; +use unlucky_app_iced::platform::win32::console::attach_parent_console; use unlucky_app_iced::shot; use unlucky_app_iced::Options; fn main() -> ExitCode { + // M196 第一步:GUI 子系统进程默认没有控制台(stdout/stderr 是 0 号无效句柄), + // 不接回来 CLI 输出与所有 eprintln 都会静默丢失。四种启动情形的实测表在 + // `platform/win32/console.rs` 顶部:重定向/管道两个句柄本来就可用(一个字节都不动), + // cmd 里直接跑要 attach,双击时 attach 必失败且不会新建窗口。 + let console = attach_parent_console(); + if std::env::var_os("ULL_ACCEPTANCE").is_some() { + eprintln!("[console] {}", console.describe()); + } + let args: Vec = std::env::args().skip(1).collect(); if let Some(code) = run_cli_if_requested(&args) { return code; @@ -43,7 +79,7 @@ fn main() -> ExitCode { ); // M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。 eprintln!( - "验收诊断:ULL_ACCEPTANCE=1([env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\ + "验收诊断:ULL_ACCEPTANCE=1([console]/[env]/[backend]/[window]/[font]/[theme]/[perf]/[i18n]/[caps])\ ,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸" ); ExitCode::from(2) diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index 4435113..efe9a9a 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -1,12 +1,15 @@ -//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点, -//! 以及系统语言真值源,三件事分居三处。 +//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、两处 `unsafe` 落点, +//! 以及系统语言真值源——每一件各归一处(判据不碰 FFI,FFI 只住落点文件)。 //! //! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; //! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 → //! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明; -//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` + +//! - [`win32::transparency`] = 整窗 α 的 FFI 落点(补 `WS_EX_LAYERED` + //! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`); +//! - [`win32::console`] = M196 的控制台附着落点(`AttachConsole`):GUI 子系统进程的输出 +//! 为什么在 cmd 里会静默丢失,以及唯一的补救。两个落点的放行范围各自收在一个文件里, +//! `unsafe-scope` 门禁的期望集合就是这两个路径(集合相等,不是黑名单); //! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 diff --git a/crates/app-iced/src/platform/win32/console.rs b/crates/app-iced/src/platform/win32/console.rs new file mode 100644 index 0000000..554ba5b --- /dev/null +++ b/crates/app-iced/src/platform/win32/console.rs @@ -0,0 +1,248 @@ +//! 控制台附着(M196):GUI 子系统进程为什么「参数看得见、输出看不见」,以及唯一的补救。 +//! +//! ## 起因 +//! +//! `crates/app-iced/src/main.rs` 顶上的 `#![cfg_attr(windows, windows_subsystem = "windows")]` +//! 把产物 PE 头的 `Subsystem` 从 CUI(3) 改成 WINDOWS_GUI(2)——双击不再多一个控制台宿主窗口 +//! (群友 2026-11-08 实测「发布构建底下带一个控制台窗口」)。代价是:同一条命令行也仍然要能用。 +//! `unlucky-launcher-iced` 一个二进制两种用法(M82),CLI 子命令与 `--diff` / `--screenshot` +//! 的日志全靠 std 句柄,而 GUI 子系统进程**不继承**父进程的控制台。 +//! +//! ## 四情形实测(2026-11-08,本机 Windows;GUI 子系统探针与最终产物同属性) +//! +//! | 启动方式 | 启动瞬间 `GetStdHandle`(out/err) | 输出去哪了 | 要 attach 吗 | +//! |---|---|---|---| +//! | ① `> file` 重定向 | 磁盘句柄(`GetFileType`=1) | 文件 | 不要 | +//! | ② `\| pipe` 管道 | 管道句柄(=3) | 管道 | 不要 | +//! | ③ cmd 里直接跑 | **0 / 0**(`FILE_TYPE_UNKNOWN`) | **丢了** | **要** | +//! | ④ 双击 | 0 / 0 | 丢了(本来也没人看) | 要(但会失败,见下) | +//! +//! ③ 的补救实测:`AttachConsole(ATTACH_PARENT_PROCESS)` 返回 1 之后 `GetStdHandle` 立刻给出 +//! 控制台句柄(实测 `0x0` → `0xE4` / `0xE8`),随后的 `WriteFile` 与 Rust 的 `eprintln!` +//! 都真的落进了屏幕缓冲区(同一个控制台模型宿主读回的文本见 M196 报告)。所以**不需要**再自己 +//! `CreateFileW("CONOUT$")` + `SetStdHandle`:那一层是多余的,而且它会覆盖用户自己重定向的 +//! 句柄——①②两种情形会被打坏。 +//! +//! ④ 的边界:双击时祖先进程(explorer)没有控制台,`AttachConsole` 返回 0 +//! (`GetLastError` = 6 `ERROR_INVALID_HANDLE`),`GetConsoleWindow` 仍是 0、`CONOUT$` 也打不开。 +//! **`AttachConsole` 只会附到一个已经存在的控制台,永远不会新建窗口**——这正是它比 +//! `AllocConsole` 适合这里的原因(后者凭空多一个黑框,正是本次要修的那个 bug)。 +//! +//! ## 什么时候调、为什么无条件调 +//! +//! `main()` 的**第一件事**(早于 CLI 分发、早于 iced 初始化):一条路径写 CLI 输出,另一条路径 +//! 在 `--diff` / `--screenshot` / 参数报错时写 stderr,两条都要句柄。判据只有一条—— +//! 「out 与 err 至少一个是不可用的就尝试 attach」,可用就绝不碰(那是 ①②)。 +//! +//! 无条件(而不是只在 CLI 分支)的取舍:今天发的产物是 CUI(3),被控制台拉起时**本来就是** +//! 附着状态(收得到 Ctrl+C / 控制台关闭事件);在同一条路径上保持附着语义,等于「除了双击不再 +//! 多黑框」之外**行为不变**。双击那条路 attach 必失败,不会多出任何窗口。 +//! +//! ## 非 Windows +//! +//! 同签名函数返回 [`ConsoleOutcome::NotWindows`]:调用点不需要 `#[cfg]`,而「这个平台没有 +//! Windows 控制台这回事」本身可以被断言。 + +// 本文件是 D36 那条「unsafe 落点」的第二个成员(详见 `transparency.rs` 顶部与 +// `.github/workflows/guards.yml` 的 `unsafe-scope`):控制台附着只有 Win32 一条路, +// 没有安全替代。放行范围仅限本文件。 +#![allow(unsafe_code)] + +/// `GetStdHandle` 的两个 `(DWORD)-n` 参数(同一个值在 u32 与 i32 两种记法下都要能写)。 +#[cfg(target_os = "windows")] +const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11 +#[cfg(target_os = "windows")] +const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12 + +/// `AttachConsole` 的 `ATTACH_PARENT_PROCESS`(`(DWORD)-1`)。 +#[cfg(target_os = "windows")] +const ATTACH_PARENT_PROCESS: u32 = 0xFFFF_FFFF; + +/// `FALSE`/`TRUE` 之外唯一需要认的无效句柄:`(HANDLE)-1`。 +const INVALID_HANDLE_VALUE: isize = -1; + +#[cfg(target_os = "windows")] +#[link(name = "kernel32")] +extern "system" { + fn GetStdHandle(which: u32) -> isize; + fn GetFileType(handle: isize) -> u32; + fn AttachConsole(process_id: u32) -> i32; + fn GetLastError() -> u32; +} + +/// 一次附着的结论。带着前后两对句柄值走,报告里贴的就是运行时真读数,不是「应该没问题」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsoleOutcome { + /// 非 Windows:没有这回事。 + NotWindows, + /// `out` 与 `err` 都已经是可用句柄(①②):**一个字节都没动**。 + AlreadyUsable { + /// `(stdout, stderr)` 的 `GetStdHandle` 读数。 + handles: (isize, isize), + }, + /// 附着成功:`after` 是附着后的读数(控制台句柄)。 + Attached { + /// 附着前 `(stdout, stderr)`。 + before: (isize, isize), + /// 附着后 `(stdout, stderr)`。 + after: (isize, isize), + }, + /// 祖先进程没有控制台(④,或者服务/计划任务):附着失败,**也没有新建任何窗口**。 + NoParentConsole { + /// 附着前 `(stdout, stderr)`。 + before: (isize, isize), + /// 附着后 `(stdout, stderr)`。 + after: (isize, isize), + /// `AttachConsole` 之后的 `GetLastError`(双击时实测 6 = `ERROR_INVALID_HANDLE`)。 + last_error: u32, + }, +} + +impl ConsoleOutcome { + /// 单行可 grep 的验收诊断(`ULL_ACCEPTANCE=1` 时由 `main` 打到 stderr,前缀 `[console]`)。 + /// + /// 句柄按 `0x` 十六进制打印:`0x0` / `0xFFFFFFFFFFFFFFFF` 一眼就是「没有」, + /// 而「附着了但等于没附」这种半吊子状态(attach 成功、句柄仍是 0)也就藏不住。 + #[must_use] + pub fn describe(self) -> String { + match self { + Self::NotWindows => "非 Windows:无控制台可附着(不适用)".to_owned(), + Self::AlreadyUsable { handles } => format!( + "std 句柄本来就可用(out=0x{:X} err=0x{:X}),未 attach", + handles.0, handles.1 + ), + Self::Attached { before, after } => format!( + "已附着父进程控制台:out 0x{:X}→0x{:X},err 0x{:X}→0x{:X}", + before.0, after.0, before.1, after.1 + ), + Self::NoParentConsole { + before, + after, + last_error, + } => format!( + "父进程没有控制台(attach 失败,GetLastError={}):out 0x{:X}→0x{:X},err 0x{:X}→0x{:X};未新建窗口", + last_error, before.0, after.0, before.1, after.1 + ), + } + } + + /// 是否真的(或本来就已经)有可用的输出通道。仅供诊断/日志判断,不影响控制流。 + #[must_use] + pub fn output_visible(self) -> bool { + match self { + Self::NotWindows | Self::NoParentConsole { .. } => false, + Self::AlreadyUsable { .. } | Self::Attached { .. } => true, + } + } +} + +/// 判据(纯函数):两个句柄都可用就**不**碰,否则尝试 attach。 +/// +/// 单拎出来的理由与 `platform/mod.rs` 里那两条判据一样:这一半可以不碰 FFI 就被断言证明, +/// 而 FFI 的实际效果(窗口有没有多出来)在本仓库的验证环境里没有参考价值。 +#[must_use] +pub fn should_attach(stdout_usable: bool, stderr_usable: bool) -> bool { + !(stdout_usable && stderr_usable) +} + +/// 句柄是否指向一个能写的东西。 +/// +/// `GetFileType` 的返回值:1 磁盘 / 2 控制台 / 3 管道 / 4 remote,**0 = `FILE_TYPE_UNKNOWN`** +/// ——GUI 子系统进程从控制台启动时 `GetStdHandle` 给的就是 0(实测,见文件头表格 ③)。 +#[cfg(target_os = "windows")] +fn usable(handle: isize) -> bool { + handle != 0 && handle != INVALID_HANDLE_VALUE && { + // SAFETY: `GetFileType` 只按值收一个句柄、不持有指针、无所有权转移;无效句柄返回 0。 + (unsafe { GetFileType(handle) }) != 0 + } +} + +#[cfg(target_os = "windows")] +fn std_handles() -> (isize, isize) { + // SAFETY: `GetStdHandle` 只按值收一个索引常量,不持有指针、不改任何状态。 + (unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, unsafe { + GetStdHandle(STD_ERROR_HANDLE) + }) +} + +/// 让 GUI 子系统进程的输出接回父进程控制台(详见模块文档的四情形表)。 +/// +/// 幂等、不可失败(失败也是一种结论):`out`/`err` 都可用时**一个字节都不动**, +/// 否则调一次 `AttachConsole(ATTACH_PARENT_PROCESS)` 再回读。 +#[must_use] +pub fn attach_parent_console() -> ConsoleOutcome { + #[cfg(not(target_os = "windows"))] + { + ConsoleOutcome::NotWindows + } + #[cfg(target_os = "windows")] + { + let before = std_handles(); + if !should_attach(usable(before.0), usable(before.1)) { + return ConsoleOutcome::AlreadyUsable { handles: before }; + } + // SAFETY: `AttachConsole` 只按值收一个 pid 常量(-1 = 父进程);它不返回指针、 + // 不转移所有权,失败就是失败(父子关系不变、也没有窗口被创建)。 + let attached = (unsafe { AttachConsole(ATTACH_PARENT_PROCESS) }) != 0; + // 失败原因只进诊断文本(不改控制流):返回值已经说明成败,这个码解释「为什么」。 + let last_error = unsafe { GetLastError() }; + let after = std_handles(); + if attached && usable(after.0) && usable(after.1) { + ConsoleOutcome::Attached { before, after } + } else { + // 附着成功但句柄仍不可用,与「父进程根本没有控制台」在调用点上是同一件事: + // 都没有可写的输出通道,所以合并成一个结论(`describe` 里照样带着读数)。 + ConsoleOutcome::NoParentConsole { + before, + after, + last_error, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{should_attach, ConsoleOutcome}; + + #[test] + fn truth_table_attach_only_when_a_stream_is_missing() { + // ①②:两个都在 → 绝不 attach(否则用户的重定向会被打坏)。 + assert!(!should_attach(true, true)); + // ③④:任意一个缺失 → 尝试 attach。三条都写出来,防止谁把它简化成 `!stdout_usable`。 + assert!(should_attach(false, true)); + assert!(should_attach(true, false)); + assert!(should_attach(false, false)); + } + + #[test] + fn describe_carries_the_runtime_readings() { + // 报告里贴的是这些行:句柄值必须真的出现在文本里,不然「已附着」就只是一句话。 + let attached = ConsoleOutcome::Attached { + before: (0, 0), + after: (0xE4, 0xE8), + }; + let text = attached.describe(); + assert!(text.contains("0x0→0xE4"), "{text}"); + assert!(text.contains("0xE8"), "{text}"); + assert!(attached.output_visible()); + + let missing = ConsoleOutcome::NoParentConsole { + before: (0, 0), + after: (0, 0), + last_error: 6, + }; + assert!(missing.describe().contains("GetLastError=6")); + assert!(!missing.output_visible()); + } + + #[test] + fn already_usable_says_nothing_was_touched() { + let outcome = ConsoleOutcome::AlreadyUsable { + handles: (0x20C, 0x1F0), + }; + assert!(outcome.describe().contains("未 attach")); + assert!(outcome.output_visible()); + assert!(!ConsoleOutcome::NotWindows.output_visible()); + } +} diff --git a/crates/app-iced/src/platform/win32/corner.rs b/crates/app-iced/src/platform/win32/corner.rs index b4a8c87..fb8584d 100644 --- a/crates/app-iced/src/platform/win32/corner.rs +++ b/crates/app-iced/src/platform/win32/corner.rs @@ -7,7 +7,7 @@ //! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`] //! 同一套分法)。 //! -//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。 +//! 真正下发给窗口的动作在 [`super::transparency`](`unsafe` 落点之一,另一个是 [`super::console`])。 //! //! ## 平台矩阵(哪些是实测、哪些是推断) //! diff --git a/crates/app-iced/src/platform/win32/mod.rs b/crates/app-iced/src/platform/win32/mod.rs index 85239d3..7fca34b 100644 --- a/crates/app-iced/src/platform/win32/mod.rs +++ b/crates/app-iced/src/platform/win32/mod.rs @@ -2,15 +2,18 @@ //! //! | 子模块 | 是什么 | 有没有 `unsafe` | //! |---|---|---| -//! | [`transparency`] | D36 定案的整窗 α 唯一实现落点(也是 M116 的 DWM 圆角落点) | **有**(全仓唯一) | +//! | [`transparency`] | D36 定案的整窗 α 唯一实现落点(也是 M116 的 DWM 圆角落点) | **有**(落点之一) | //! | [`corner`] | M116 的圆角**判据**:策略 × 系统版本 × 渲染档 → 来源与原因码 | 没有(纯函数) | +//! | [`console`] | M196:GUI 子系统进程的输出接回父进程控制台(`AttachConsole`) | **有**(落点之二) | //! //! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见: //! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。 //! //! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现, -//! `corner` 的判据在探测不到系统版本时给出「直角 + `unknown-os`」,所以调用点不需要 `#[cfg]`, +//! `corner` 的判据在探测不到系统版本时给出「直角 + `unknown-os`」,`console` 返回 +//! [`console::ConsoleOutcome::NotWindows`],所以调用点不需要 `#[cfg]`, //! 行为(返回「本平台不支持」)可被测试断言。 +pub mod console; pub mod corner; pub mod transparency; diff --git a/crates/app-iced/src/platform/win32/transparency.rs b/crates/app-iced/src/platform/win32/transparency.rs index 4458a5f..073ab94 100644 --- a/crates/app-iced/src/platform/win32/transparency.rs +++ b/crates/app-iced/src/platform/win32/transparency.rs @@ -1,12 +1,12 @@ -//! 整窗透明度(D36):**全仓唯一**允许 `unsafe` 的文件。 +//! 整窗透明度(D36):整窗 α 的**唯一**实现落点。 //! //! ## 为什么只有这里有 unsafe //! //! 窗口级 α 没有任何「安全」的 Rust API 可达:`SetLayeredWindowAttributes` 是 Win32 的 -//! 唯一路径(D36 逐路核实过 wgpu / iced / winit 都没有代理入口)。所以把**整个仓库**的 -//! FFI 面收在这一处,其余一层([`super::super::effective_window_alpha`])保持纯函数。 -//! 守门在 `.github/workflows/guards.yml` 的 `unsafe-scope` job:全仓带这个 allow 的文件集合 -//! 必须**恰好等于**本文件的路径(集合相等,不是黑名单命中)。 +//! 唯一路径(D36 逐路核实过 wgpu / iced / winit 都没有代理入口)。所以把窗口 FFI 面收在 +//! 这一处,其余一层([`super::super::effective_window_alpha`])保持纯函数。 +//! 守门在 `.github/workflows/guards.yml` 的 `unsafe-scope` job:全仓带这个 allow 的文件集合必须 +//! **恰好等于**期望集合(M196 起是两处:本文件与 `console.rs`,各自把 FFI 收在自己文件里)。 //! //! ## 只做 LWA_ALPHA //! @@ -41,7 +41,7 @@ //! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`, //! 而「这个平台不支持」这件事本身可以被断言。 -// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。 +// D36:整窗 α 的 unsafe 落点。CI 的 `unsafe-scope` job 比对**文件集合**(M196 起是两个:本文件与 console.rs)。 #![allow(unsafe_code)] mod alpha; @@ -106,12 +106,12 @@ pub(super) fn has_layered(hwnd: isize) -> bool { raw_ex_style(hwnd) & WS_EX_LAYERED != 0 } -/// 句柄是否指向一个**存在**的窗口(全仓唯一 `unsafe` 落点的一部分)。 +/// 句柄是否指向一个**存在**的窗口(整窗 α 落点的一部分)。 /// -/// 为什么在这里而不是在取证探针那个文件里:`transparency.rs` 是全仓**唯一**允许 `unsafe` 的 -/// 文件(`unsafe-scope` 判据 = 「带 `#![allow(unsafe_code)]` 的文件集合恰好等于本文件」)。 +/// 为什么在这里而不是在取证探针那个文件里:`unsafe-scope` 判据是「带 +/// `#![allow(unsafe_code)]` 的文件集合恰好等于期望集合」,而窗口探针不在那个集合里。 /// 探针用到 FFI 时就只能**把 FFI 搬进来**,不能让 `unsafe` 溜到没有标记的文件里—— -/// 那会让门禁照样绿、而「唯一落点」这个性质已经破了(M100 r1 复审实测抓到的形状)。 +/// 那会让门禁照样绿、而「落点受限」这个性质已经破了(M100 r1 复审实测抓到的形状)。 /// /// `0` / 无效句柄 → `false`(Win32 的 `IsWindow` 对无效句柄返回 0)。 #[cfg(target_os = "windows")] diff --git a/crates/app-iced/src/platform/win32/transparency/dwm.rs b/crates/app-iced/src/platform/win32/transparency/dwm.rs index a8707b2..e2ec166 100644 --- a/crates/app-iced/src/platform/win32/transparency/dwm.rs +++ b/crates/app-iced/src/platform/win32/transparency/dwm.rs @@ -3,7 +3,7 @@ //! ## 本文件零 `unsafe`、零 `extern` //! //! `DwmSetWindowAttribute` / `DwmGetWindowAttribute` 的声明与调用点在 [`super`] -//! (全仓唯一 `unsafe` 落点);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。 +//! (`unsafe` 落点之一;另一个是 `super::super::console`);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。 //! //! ## 为什么「直角」也要真的下发一次 //! diff --git a/crates/app-iced/src/platform/win32/transparency/exstyle.rs b/crates/app-iced/src/platform/win32/transparency/exstyle.rs index ec1ce34..32d5104 100644 --- a/crates/app-iced/src/platform/win32/transparency/exstyle.rs +++ b/crates/app-iced/src/platform/win32/transparency/exstyle.rs @@ -9,7 +9,7 @@ //! //! ⚠️ **别把 FFI 写回来**(M100 r1 复审实测抓过一次):lint 级别沿模块树继承,所以在这里写 //! 一个 `unsafe` 块 + `extern` 声明也**编得过**,`unsafe-scope` 门禁也照样绿(它只数「带放行 -//! 属性的文件」)——但「全仓唯一允许 unsafe 的文件」这个性质就破了。 +//! 属性的文件」)——但「FFI 只住在少数几个落点文件里」这个性质就破了。 //! 属性的作用是可审计性,不是给 CI 看的。 //! //! ## 判据来源 diff --git a/crates/app-iced/src/platform/win32/transparency/tests.rs b/crates/app-iced/src/platform/win32/transparency/tests.rs index 4f55898..167ce14 100644 --- a/crates/app-iced/src/platform/win32/transparency/tests.rs +++ b/crates/app-iced/src/platform/win32/transparency/tests.rs @@ -1,7 +1,7 @@ //! `transparency` 的测试(M116 从 `transparency.rs` 搬出来:那个文件要留行数给 DWM 的 FFI)。 //! //! **本文件零 `unsafe`、零 `extern`**:只调父模块的安全包装。别把 FFI 写进来—— -//! 「全仓唯一允许 unsafe 的文件」是 `transparency.rs`,不是它下面的每个文件。 +//! 带 `#![allow(unsafe_code)]` 的是 `transparency.rs`(以及 M196 的 `console.rs`),不是它下面的每个文件。 use super::{ alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE, diff --git a/packaging/README.md b/packaging/README.md index 3c3959e..f3c145b 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -65,6 +65,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 | +| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 5 条回归(见 §15) | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | @@ -141,3 +142,26 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) `ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + `portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。 **不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。 + +## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2) + +群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头的 `Subsystem` 仍是 +CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 2026-11-01 做过一次(`37d3039`), +但只改了 `crates/app/src/main.rs`——那棵树自 M118 起被 workspace `exclude`、不进发布矩阵,所以那一笔 +**从未到达产物**。教训不是「忘了写属性」,而是没有一步去看产物本身。 + +- 修在哪:`crates/app-iced/src/main.rs` 顶上的 `#![cfg_attr(windows, windows_subsystem = "windows")]`。 + PE 子系统是**每个 bin 目标**的属性,`cfg_attr` 是它的原生长相;不放 `build.rs` 是因为那里要绕 + `cargo:rustc-link-arg` 间接改链接命令行,且会把「这条 bin 是 GUI」挪到看不见 `[[bin]]` 声明的地方。 +- 代价与补救:GUI 子系统进程**不继承**父进程控制台,stdout/stderr 在 cmd 里是 0 号无效句柄。所以 + `main()` 第一步调 `AttachConsole(ATTACH_PARENT_PROCESS)`(`crates/app-iced/src/platform/win32/console.rs`, + 四种启动情形的实测表在该文件顶部):重定向/管道两个句柄本来就可用(一个字节都不动)、cmd 里直接跑 + 附着后输出落进控制台、双击时附着失败且**绝不新建窗口**。 +- 守门:`python3 packaging/check_pe_subsystem.py --gui --cli `,读 `MZ`→`e_lfanew`(`0x3C`)→ + `PE`→COFF 头→可选头 `+0x44` 的 2 个字节,打印**文件偏移**(例:`Subsystem=2 (WINDOWS_GUI) @ + 文件偏移 0x1E6`)。两向断言:GUI 产物必须 2,CLI 产物 `ull.exe` 必须仍是 3(CONSOLE)——反方向 + 不守时,把属性贴错 crate 会被漏掉,而 `ull --version | …` 会静默丢输出。 +- 接在哪:`.github/workflows/release-build.yml`(发布侧,两个 Windows 槽位各跑一次)与 + `.github/workflows/ci.yml` 的 `build` job(PR 阶段就红,不必等到 tag)。 +- 抓不住(如实记):只看那 4 个字节,不看 GUI 双击的实际观感——「不再多一个黑框」在本仓库的验证环境 + (RDP / 无干净无控制台桌面)只能由 PE 头 + 进程/窗口枚举间接证明,人眼验收归真机。 diff --git a/packaging/check_pe_subsystem.py b/packaging/check_pe_subsystem.py new file mode 100644 index 0000000..474145a --- /dev/null +++ b/packaging/check_pe_subsystem.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem` 字段**,不许用「代码看起来对」代替。 + +起因:群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因是产物 PE 头的 +`Subsystem` 仍是 CUI(3)——`grep -rn windows_subsystem crates/` 当时**零命中**。这个修复在 +2026-11-01 做过一次(`37d3039`),但打在已退役的 Slint 树(`crates/app`,M118 起被 workspace +`exclude`、不进发布矩阵),所以从未到达 iced 产物。教训不是「忘了属性」,而是**没有任何一步 +去看产物本身**:源码层面怎么写都可能被别的树顶替,只有读那 4 个字节是事实。 + +两向断言(一条也不许静默): + +1. **GUI 产物必须是 `Subsystem == 2`(`WINDOWS_GUI`)**:`unlucky-launcher-iced.exe`。 + 是 3 就意味着双击会多一个控制台宿主窗口——正是本 mission 要修的那个 bug。 +2. **CLI 产物必须是 `Subsystem == 3`(`CONSOLE`)**:`ull.exe`。它靠真控制台做 stdout/stderr, + 谁把 `windows_subsystem = "windows"` 抄到它头上,`ull --version | …` 就会静默丢输出。 + 反方向同样要红:只守一个方向时,把属性贴错 crate 会被漏掉。 + +读法(零第三方依赖,PE32 与 PE32+ 都吃):`MZ` → `e_lfanew`(偏移 `0x3C`) → `PE\\0\\0` → +COFF 头 20 字节 → 可选头起点 → `Subsystem` 在可选头 `+0x44`(两种 magic 一致)。 +读数含**文件字节偏移**,报告里贴的是真实位置,不是「我读过了」。 + +用法(本地与 CI 同一份代码): + + python3 packaging/check_pe_subsystem.py --gui target/release/unlucky-launcher-iced.exe \\ + --cli target/release/ull.exe + python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红 + +`--self-test` 是这条守门的自测证据:它造合成 PE 头(GUI / CUI / 非 MZ / 截断 / 缺文件), +断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」。守门抓不住回归时它自己先红。 +""" + +from __future__ import annotations + +import struct +import sys +import tempfile +from pathlib import Path + +#: PE 签名(`e_lfanew` 处那四个字节)。 +PE_SIGNATURE = b"PE\0\0" + +#: `IMAGE_OPTIONAL_HEADER.Subsystem` 在可选头里的偏移(PE32 与 PE32+ 同一个位置)。 +SUBSYSTEM_OFFSET = 0x44 +#: 想要的两种取值。 +WINDOWS_GUI = 2 +CONSOLE = 3 + +SUBSYSTEM_NAMES = { + 0: "UNKNOWN", + 1: "NATIVE", + 2: "WINDOWS_GUI", + 3: "CONSOLE", + 5: "OS2_CUI", + 7: "POSIX_CUI", + 9: "WINDOWS_CE_GUI", + 10: "EFI_APPLICATION", + 11: "EFI_BOOT_SERVICE_DRIVER", + 12: "EFI_RUNTIME_DRIVER", + 14: "XBOX", + 16: "WINDOWS_BOOT_APPLICATION", +} + +#: 可选头 magic → 人话(只用于打印,判据不看它)。 +MAGICS = {0x10B: "PE32", 0x20B: "PE32+", 0x107: "ROM"} + + +class PeError(Exception): + """读 PE 头失败(不是 PE / 被截断)—— 同样是红的理由,不许当成「跳过」。""" + + +class PeInfo: + """一次读数:值 + 它在文件里的位置(位置进报告,是「真读过」的凭据)。""" + + __slots__ = ("path", "subsystem", "file_offset", "e_lfanew", "magic", "machine") + + def __init__(self, path: Path, subsystem: int, file_offset: int, e_lfanew: int, magic: int, machine: int): + self.path = path + self.subsystem = subsystem + self.file_offset = file_offset + self.e_lfanew = e_lfanew + self.magic = magic + self.machine = machine + + @property + def name(self) -> str: + return self.path.name + + def describe(self) -> str: + return ( + f"[pe] {self.name}: Subsystem={self.subsystem} " + f"({SUBSYSTEM_NAMES.get(self.subsystem, '?')}) @ 文件偏移 0x{self.file_offset:X}" + f"(e_lfanew=0x{self.e_lfanew:X},{MAGICS.get(self.magic, f'0x{self.magic:X}')}," + f"可选头+0x{SUBSYSTEM_OFFSET:X},machine=0x{self.machine:X})" + ) + + +def read_subsystem(path: Path) -> PeInfo: + """读 PE 可选头的 `Subsystem`。任何一步对不上都抛 [`PeError`],绝不返回「猜的值」。""" + if not path.is_file(): + raise PeError(f"{path} 不存在(构建步没跑?还是产物名改了?)") + with path.open("rb") as handle: + dos = handle.read(0x40) + if len(dos) < 0x40: + raise PeError(f"{path} 只有 {len(dos)} 字节,连 DOS 头都不够(文件被截断?)") + if dos[0:2] != b"MZ": + raise PeError(f"{path} 不是 PE:开头是 {dos[0:2]!r},不是 b'MZ'") + e_lfanew = struct.unpack_from(" PeInfo | None: + """按角色断言一个产物的子系统;不匹配就往 `errors` 里写一条带文件注解的 error。""" + try: + info = read_subsystem(path) + except PeError as exc: + errors.append(f"::error::{role} 读数失败:{exc}") + return None + print(info.describe()) + if info.subsystem != expected: + want = SUBSYSTEM_NAMES.get(expected, str(expected)) + got = SUBSYSTEM_NAMES.get(info.subsystem, str(info.subsystem)) + hint = { + WINDOWS_GUI: "双击会多一个控制台宿主窗口(M196 要修的就是这个)", + CONSOLE: "CLI 的 stdout/stderr 会静默丢失(ull 靠真控制台)", + }.get(expected, "") + errors.append( + f"::error file={path}::{role} 的 PE 子系统应为 {expected}({want})," + f"实际读到 {info.subsystem}({got})@ 偏移 0x{info.file_offset:X};{hint}" + ) + return info + + +def main(argv: list[str]) -> int: + gui: Path | None = None + cli: Path | None = None + args = list(argv) + while args: + arg = args.pop(0) + if arg == "--gui" and args: + gui = Path(args.pop(0)) + elif arg == "--cli" and args: + cli = Path(args.pop(0)) + elif arg == "--self-test": + return self_test() + elif arg in ("-h", "--help"): + print(__doc__) + return 0 + else: + print(f"::error::不认识的参数(或缺少取值):{arg}", file=sys.stderr) + return 2 + if gui is None and cli is None: + print("::error::至少要给 --gui 或 --cli ", file=sys.stderr) + return 2 + + errors: list[str] = [] + if gui is not None: + check("GUI", gui, WINDOWS_GUI, errors) + if cli is not None: + check("CLI", cli, CONSOLE, errors) + for error in errors: + print(error, file=sys.stderr) + if errors: + return 1 + print( + f"OK: PE 子系统符合预期(GUI={WINDOWS_GUI} {SUBSYSTEM_NAMES[WINDOWS_GUI]}" + f" / CLI={CONSOLE} {SUBSYSTEM_NAMES[CONSOLE]})" + ) + return 0 + + +# ────────────────────────── 自测:合成 PE 桩 ────────────────────────── + + +def synth_pe(subsystem: int, *, pe32_plus: bool = True, signature: bytes = b"PE\0\0", mz: bytes = b"MZ") -> bytes: + """造一个只够本脚本读的 PE 头(不执行、不链接,只让 `Subsystem` 落在正确位置)。""" + e_lfanew = 0x80 + buf = bytearray(0x200) + buf[0:2] = mz + struct.pack_into(" int: + failures = 0 + with tempfile.TemporaryDirectory(prefix="ull-pe-subsystem-") as tmp: + root = Path(tmp) + + def write(name: str, data: bytes) -> Path: + path = root / name + path.write_bytes(data) + return path + + cases: list[tuple[str, Path, int, bool]] = [ + ( + "基线:GUI 产物 Subsystem=2", + write("gui_ok.exe", synth_pe(WINDOWS_GUI)), + WINDOWS_GUI, + False, + ), + ( + "回归:GUI 产物退回 CUI(3) —— 就是本次 bug 的形状", + write("gui_cui.exe", synth_pe(CONSOLE)), + WINDOWS_GUI, + True, + ), + ( + "基线:CLI 产物 Subsystem=3", + write("cli_ok.exe", synth_pe(CONSOLE, pe32_plus=False)), + CONSOLE, + False, + ), + ( + "回归:CLI 产物被贴成 WINDOWS_GUI(2)", + write("cli_gui.exe", synth_pe(WINDOWS_GUI)), + CONSOLE, + True, + ), + ( + "回归:不是 PE(开头不是 MZ)", + write("not_pe.exe", synth_pe(WINDOWS_GUI, mz=b"ZZ")), + WINDOWS_GUI, + True, + ), + ( + "回归:PE 头被截断", + write("truncated.exe", synth_pe(WINDOWS_GUI)[: optional_start_of(synth_pe(WINDOWS_GUI)) + 0x10]), + WINDOWS_GUI, + True, + ), + ( + "回归:签名不是 PE\\0\\0", + write("bad_sig.exe", synth_pe(WINDOWS_GUI, signature=b"PX\0\0")), + WINDOWS_GUI, + True, + ), + ( + "回归:文件根本不存在(构建步没跑)", + root / "missing.exe", + WINDOWS_GUI, + True, + ), + ] + + for label, path, expected, want_red in cases: + errors: list[str] = [] + check("SELFTEST", path, expected, errors) + got_red = bool(errors) + if got_red != want_red: + want = "红" if want_red else "绿" + print( + f"::error::自测失败:{label} 应该{want},实际 {'红' if got_red else '绿'}", + file=sys.stderr, + ) + failures += 1 + else: + print(f"OK: {label} → {'红' if got_red else '绿'}") + + # 位置读数本身也要对:GUI 桩的 Subsystem 必须正好落在报告的那个文件偏移上。 + gui = write("offset.exe", synth_pe(WINDOWS_GUI)) + info = read_subsystem(gui) + if gui.read_bytes()[info.file_offset : info.file_offset + 2] != struct.pack(" int: + """合成桩里可选头的起点(给「截断」用例算切点用)。""" + return struct.unpack_from(" Date: Sun, 8 Nov 2026 20:12:36 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(m196):=20r1=20=E5=A4=8D=E5=AE=A1=20P1?= =?UTF-8?q?=EF=BC=88=E9=9D=9E=20Windows=20dead=5Fcode=EF=BC=89+=20P2=20?= =?UTF-8?q?=E5=9B=9B=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1(阻断):console.rs 的 `INVALID_HANDLE_VALUE` 只被 `#[cfg(target_os = "windows")]` 的 usable() 用到,而这个模块在 Linux/macOS 上也编译(platform/mod.rs 的 pub mod win32; 没有 cfg)⇒ ubuntu 的 `clippy -- -D warnings` 会把它升级成 dead_code error。 按「精确 cfg」修(不用 dwm.rs 那种文件级 allow(dead_code):那会永久掩掉本文件在非 Windows 上的所有死项)。自证方式是真实文件翻转 cfg 的 rustc probe:修前 exit 1 (`constant INVALID_HANDLE_VALUE is never used`)、修后 exit 0 零告警;同一条命令写进 了注释,下一个人可以照着跑。 P2-1:自测条数按实现改成 8 条(红 6 / 绿 2),三处(脚本 docstring、release-build.yml 注释、README §12)一致。 P2-2:文件头那组句柄读数换成 release 产物的实测值(out 0x0→0x1F0 / err 0x0→0x1F4), 并说明数字随实例变化、单测里的 0xE4/0xE8 是示例。 P2-3:README §15 补这段边角语义——但**按实测改写了复审的结论**:两种「既无控制台也不 重定向」的宿主形态(父有句柄不继承 / 父子都无句柄)× 两条命令,child_exit_code 全是 0, 不是 EXIT_INTERNAL;原因是 Rust std 在 Windows 上对取不到的句柄直接返回 Ok(独立探针 实测 SetStdHandle(out)=0x0 / 0x1234 / (HANDLE)-1 三种取值 write/flush 都是 Ok), 所以 runner.rs 那条「写失败 ⇒ EXIT_INTERNAL」的分支不会被触发。 P2-4:NoParentConsole → NoConsole(这个名字同时覆盖「attach 成功但句柄仍不可用」), 诊断文案与注释同步。 --- .github/workflows/release-build.yml | 2 +- crates/app-iced/src/platform/win32/console.rs | 43 ++++++++++++++----- packaging/README.md | 20 +++++++-- packaging/check_pe_subsystem.py | 5 ++- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index ba58a28..e52c596 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -265,7 +265,7 @@ jobs: # 就是这么活到 2026-11-08 的。本步只认那 4 个字节:GUI 产物必须是 WINDOWS_GUI(2),非 2 即红。 # 反方向一并守:CLI 产物 `ull.exe` 必须仍是 CONSOLE(3)——它靠真控制台做 stdout/stderr, # 谁把属性抄到它头上,`ull --version | …` 会静默丢输出。守门本体在 packaging/check_pe_subsystem.py - # (零第三方依赖,本地同一份代码可跑;`--self-test` 注入 5 条回归证明它会红)。 + # (零第三方依赖,本地同一份代码可跑;`--self-test` 注入 8 条回归(红 6 / 绿 2)证明它会红)。 - name: PE 子系统门禁(GUI 产物必须 WINDOWS_GUI/2,CLI 产物必须 CONSOLE/3) if: runner.os == 'Windows' shell: bash diff --git a/crates/app-iced/src/platform/win32/console.rs b/crates/app-iced/src/platform/win32/console.rs index 554ba5b..0c2f7b8 100644 --- a/crates/app-iced/src/platform/win32/console.rs +++ b/crates/app-iced/src/platform/win32/console.rs @@ -18,10 +18,11 @@ //! | ④ 双击 | 0 / 0 | 丢了(本来也没人看) | 要(但会失败,见下) | //! //! ③ 的补救实测:`AttachConsole(ATTACH_PARENT_PROCESS)` 返回 1 之后 `GetStdHandle` 立刻给出 -//! 控制台句柄(实测 `0x0` → `0xE4` / `0xE8`),随后的 `WriteFile` 与 Rust 的 `eprintln!` -//! 都真的落进了屏幕缓冲区(同一个控制台模型宿主读回的文本见 M196 报告)。所以**不需要**再自己 -//! `CreateFileW("CONOUT$")` + `SetStdHandle`:那一层是多余的,而且它会覆盖用户自己重定向的 -//! 句柄——①②两种情形会被打坏。 +//! 控制台句柄(release 产物在 cmd 模型下实测 `out 0x0→0x1F0` / `err 0x0→0x1F4`),随后 +//! `WriteFile` 与 Rust 的 `eprintln!` 都真的落进了屏幕缓冲区(同一模型宿主读回的文本见 M196 +//! 报告)。具体数字随进程与控制台实例变化(调试构建里是 `0x208`/`0x20C`),**能跨实例复现的 +//! 是「从 0 变成非 0」这件事本身**。所以**不需要**再自己 `CreateFileW("CONOUT$")` + +//! `SetStdHandle`:那一层是多余的,而且它会覆盖用户自己重定向的句柄——①②两种情形会被打坏。 //! //! ④ 的边界:双击时祖先进程(explorer)没有控制台,`AttachConsole` 返回 0 //! (`GetLastError` = 6 `ERROR_INVALID_HANDLE`),`GetConsoleWindow` 仍是 0、`CONOUT$` 也打不开。 @@ -59,6 +60,22 @@ const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12 const ATTACH_PARENT_PROCESS: u32 = 0xFFFF_FFFF; /// `FALSE`/`TRUE` 之外唯一需要认的无效句柄:`(HANDLE)-1`。 +/// +/// **必须带下面那行 `cfg`**:唯一使用点 [`usable`] 只活在 Windows 分支里,而本模块在 +/// Linux/macOS 上也编译(`platform/mod.rs` 的 `pub mod win32;` 没有 cfg)。漏了它, +/// `ci.yml` 的 ubuntu clippy(`-- -D warnings`)会把它升级成 `dead_code` error——而本机只有 +/// windows-gnu 工具链,非 Windows 的 cfg 一次都编不到,本地抓不住(M196 r1 复审 P1)。 +/// 同目录 `transparency/dwm.rs` 用的是文件级 `#![cfg_attr(not(windows), allow(dead_code))]`; +/// 这里刻意**不用**那种写法:本文件的 Windows-only 面很小,每一项各自带 cfg 才能让编译器 +/// 继续在非 Windows 上替我们盯着「又漏了一个」。 +/// +/// 自证办法(本机只有 Windows triple、交叉编 iced 会卡在 x11/fontconfig 时):把本文件的 +/// `target_os = "windows"` 全换成不存在的目标名,再 +/// `rustc --edition 2021 --crate-type lib -D warnings --crate-name console_probe <那份拷贝>` +/// ——漏了上面那行 cfg 时它是 exit 1 + `constant INVALID_HANDLE_VALUE is never used`, +/// 补齐后 exit 0 且零告警(**其他**任何非 Windows 死项也会一起报出来,所以这一个 probe 就是 +/// 「本文件在 Linux 上能不能过 -D warnings」的整份答案)。 +#[cfg(target_os = "windows")] const INVALID_HANDLE_VALUE: isize = -1; #[cfg(target_os = "windows")] @@ -87,8 +104,11 @@ pub enum ConsoleOutcome { /// 附着后 `(stdout, stderr)`。 after: (isize, isize), }, - /// 祖先进程没有控制台(④,或者服务/计划任务):附着失败,**也没有新建任何窗口**。 - NoParentConsole { + /// 没有可写的控制台(④ / 服务 / 计划任务):附着失败**或**附着成功但句柄仍不可用。 + /// + /// 两种情况在调用点上是同一件事(都没有输出通道),所以合并成一个结论;`last_error` 与 + /// 两对句柄一起进诊断文本,读的人能自己分清是哪一种。**没有新建任何窗口**。 + NoConsole { /// 附着前 `(stdout, stderr)`。 before: (isize, isize), /// 附着后 `(stdout, stderr)`。 @@ -115,12 +135,12 @@ impl ConsoleOutcome { "已附着父进程控制台:out 0x{:X}→0x{:X},err 0x{:X}→0x{:X}", before.0, after.0, before.1, after.1 ), - Self::NoParentConsole { + Self::NoConsole { before, after, last_error, } => format!( - "父进程没有控制台(attach 失败,GetLastError={}):out 0x{:X}→0x{:X},err 0x{:X}→0x{:X};未新建窗口", + "没有可用的控制台(attach 失败或句柄仍不可用,GetLastError={}):out 0x{:X}→0x{:X},err 0x{:X}→0x{:X};未新建窗口", last_error, before.0, after.0, before.1, after.1 ), } @@ -130,7 +150,7 @@ impl ConsoleOutcome { #[must_use] pub fn output_visible(self) -> bool { match self { - Self::NotWindows | Self::NoParentConsole { .. } => false, + Self::NotWindows | Self::NoConsole { .. } => false, Self::AlreadyUsable { .. } | Self::Attached { .. } => true, } } @@ -192,7 +212,7 @@ pub fn attach_parent_console() -> ConsoleOutcome { } else { // 附着成功但句柄仍不可用,与「父进程根本没有控制台」在调用点上是同一件事: // 都没有可写的输出通道,所以合并成一个结论(`describe` 里照样带着读数)。 - ConsoleOutcome::NoParentConsole { + ConsoleOutcome::NoConsole { before, after, last_error, @@ -218,6 +238,7 @@ mod tests { #[test] fn describe_carries_the_runtime_readings() { // 报告里贴的是这些行:句柄值必须真的出现在文本里,不然「已附着」就只是一句话。 + // 下面的 0xE4/0xE8 是**示例值**(真实读数随进程与控制台实例变化,见模块文档 ③)。 let attached = ConsoleOutcome::Attached { before: (0, 0), after: (0xE4, 0xE8), @@ -227,7 +248,7 @@ mod tests { assert!(text.contains("0xE8"), "{text}"); assert!(attached.output_visible()); - let missing = ConsoleOutcome::NoParentConsole { + let missing = ConsoleOutcome::NoConsole { before: (0, 0), after: (0, 0), last_error: 6, diff --git a/packaging/README.md b/packaging/README.md index f3c145b..79e9666 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -65,7 +65,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤400 | -| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 5 条回归(见 §15) | ≤400 | +| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 8 条回归(红 6 / 绿 2)(见 §15) | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | @@ -158,10 +158,22 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 四种启动情形的实测表在该文件顶部):重定向/管道两个句柄本来就可用(一个字节都不动)、cmd 里直接跑 附着后输出落进控制台、双击时附着失败且**绝不新建窗口**。 - 守门:`python3 packaging/check_pe_subsystem.py --gui --cli `,读 `MZ`→`e_lfanew`(`0x3C`)→ - `PE`→COFF 头→可选头 `+0x44` 的 2 个字节,打印**文件偏移**(例:`Subsystem=2 (WINDOWS_GUI) @ - 文件偏移 0x1E6`)。两向断言:GUI 产物必须 2,CLI 产物 `ull.exe` 必须仍是 3(CONSOLE)——反方向 - 不守时,把属性贴错 crate 会被漏掉,而 `ull --version | …` 会静默丢输出。 + `PE\0\0`→COFF 头→可选头 `+0x44` 的 2 个字节,打印**文件偏移**(release 产物实测:`Subsystem=2 + (WINDOWS_GUI) @ 文件偏移 0xDC`,`e_lfanew=0x80`、PE32+)。两向断言:GUI 产物必须 2,CLI 产物 + `ull.exe` 必须仍是 3(CONSOLE)——反方向不守时,把属性贴错 crate 会被漏掉,而 `ull --version | …` + 会静默丢输出。 - 接在哪:`.github/workflows/release-build.yml`(发布侧,两个 Windows 槽位各跑一次)与 `.github/workflows/ci.yml` 的 `build` job(PR 阶段就红,不必等到 tag)。 - 抓不住(如实记):只看那 4 个字节,不看 GUI 双击的实际观感——「不再多一个黑框」在本仓库的验证环境 (RDP / 无干净无控制台桌面)只能由 PE 头 + 进程/窗口枚举间接证明,人眼验收归真机。 +- **既无控制台也不重定向的宿主(快捷方式 / 服务 / 计划任务):输出照旧无处可去,退出码不变** + (M196 r2 实测,覆盖 r1 复审 P2-3 提出的「退出码会变成 `EXIT_INTERNAL`(2)」——那条实测不成立)。 + 两种宿主形态各测两条命令,`child_exit_code` 全是 **0**:①父进程有 std 句柄但子进程不继承; + ②父子都没有 std 句柄(服务形状,子进程实测 `stdin/stdout/stderr = 0x0`、`AttachConsole` 返回 0 / + `GetLastError=6`)。原因在 Rust std 的 Windows 实现:句柄取不到时 `Write`/`flush` 直接返回 `Ok` + (独立探针实测 `SetStdHandle(out)` = `0x0` / `0x1234` / `(HANDLE)-1` 三种取值,`write=Ok(())`、 + `flush=Ok(())` 全成立),所以 `crates/cli/src/runner.rs` 那条「写失败 ⇒ `EXIT_INTERNAL`」的分支不会 + 被触发。结论:本 diff **没有**给 CLI 退出码引入可观察差异;真正的差异只有「没控制台就没人看得见输出」 + 这件事,而 CUI 时代在服务里同样是隐形的。想让这种情形有专属退出码/文案,得在 CLI 侧显式判一次能力 + (`platform::win32::console` 的 `output_visible()` 就是现成的判据),那在 `crates/cli/**`、不在 M196 的 + scope 内——这里只如实记录形态,不改结构。 diff --git a/packaging/check_pe_subsystem.py b/packaging/check_pe_subsystem.py index 474145a..7027950 100644 --- a/packaging/check_pe_subsystem.py +++ b/packaging/check_pe_subsystem.py @@ -23,10 +23,11 @@ COFF 头 20 字节 → 可选头起点 → `Subsystem` 在可选头 `+0x44`( python3 packaging/check_pe_subsystem.py --gui target/release/unlucky-launcher-iced.exe \\ --cli target/release/ull.exe - python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红 + python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红(8 条:红 6 / 绿 2) `--self-test` 是这条守门的自测证据:它造合成 PE 头(GUI / CUI / 非 MZ / 截断 / 缺文件), -断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」。守门抓不住回归时它自己先红。 +断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」——共 **8 条用例(红 6 / 绿 2)**, +另加一条「Subsystem 真落在报出来的那个偏移上」的偏移自证。守门抓不住回归时它自己先红。 """ from __future__ import annotations From 26ba1579ab9bae5b03980c35a2107181cc5be9e8 Mon Sep 17 00:00:00 2001 From: tower-worker Date: Sun, 8 Nov 2026 20:22:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(m196):=20r2=20=E5=A4=8D=E5=AE=A1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E7=BB=99=E3=80=8C=E6=97=A0=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=AE=BF=E4=B8=BB=E3=80=8D=E9=82=A3=E6=9D=A1?= =?UTF-8?q?=E4=B8=8B=E8=BE=B9=E7=95=8C=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审指出 README §15 把两个**近似**形态(父进程句柄不继承 / 父子都无句柄)直接叫「服务形状」, 顶层又写「服务 / 计划任务」,读者会把它当成「真服务也测过」。补一段边界:① ② 都是近似, 真 SCM 服务与计划任务(session 0、no window station)、start /b、ConPTY 全部未测,结论靠 「句柄无效时 std 写返回 Ok」这条机制外推;第三方交叉读数(复审 r2 那组)单独标注来源, 不混进我自己的读数。 同一处过度概括也出现在 console.rs 的 NoConsole 文档(原文「④ / 服务 / 计划任务」), 按同一口径改成「④ 双击,以及其他没有控制台的宿主」并指向 README §15 的边界段。 只改文档,不动代码。 --- crates/app-iced/src/platform/win32/console.rs | 3 +- packaging/README.md | 28 +++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/app-iced/src/platform/win32/console.rs b/crates/app-iced/src/platform/win32/console.rs index 0c2f7b8..8a90ee7 100644 --- a/crates/app-iced/src/platform/win32/console.rs +++ b/crates/app-iced/src/platform/win32/console.rs @@ -104,7 +104,8 @@ pub enum ConsoleOutcome { /// 附着后 `(stdout, stderr)`。 after: (isize, isize), }, - /// 没有可写的控制台(④ / 服务 / 计划任务):附着失败**或**附着成功但句柄仍不可用。 + /// 没有可写的控制台(④ 双击,以及其他没有控制台的宿主):附着失败**或**附着成功但 + /// 句柄仍不可用。服务 / 计划任务只是**近似**形态、未亲测,边界见 `packaging/README.md` §15。 /// /// 两种情况在调用点上是同一件事(都没有输出通道),所以合并成一个结论;`last_error` 与 /// 两对句柄一起进诊断文本,读的人能自己分清是哪一种。**没有新建任何窗口**。 diff --git a/packaging/README.md b/packaging/README.md index 79e9666..ea47f35 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -166,14 +166,20 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 `.github/workflows/ci.yml` 的 `build` job(PR 阶段就红,不必等到 tag)。 - 抓不住(如实记):只看那 4 个字节,不看 GUI 双击的实际观感——「不再多一个黑框」在本仓库的验证环境 (RDP / 无干净无控制台桌面)只能由 PE 头 + 进程/窗口枚举间接证明,人眼验收归真机。 -- **既无控制台也不重定向的宿主(快捷方式 / 服务 / 计划任务):输出照旧无处可去,退出码不变** - (M196 r2 实测,覆盖 r1 复审 P2-3 提出的「退出码会变成 `EXIT_INTERNAL`(2)」——那条实测不成立)。 - 两种宿主形态各测两条命令,`child_exit_code` 全是 **0**:①父进程有 std 句柄但子进程不继承; - ②父子都没有 std 句柄(服务形状,子进程实测 `stdin/stdout/stderr = 0x0`、`AttachConsole` 返回 0 / - `GetLastError=6`)。原因在 Rust std 的 Windows 实现:句柄取不到时 `Write`/`flush` 直接返回 `Ok` - (独立探针实测 `SetStdHandle(out)` = `0x0` / `0x1234` / `(HANDLE)-1` 三种取值,`write=Ok(())`、 - `flush=Ok(())` 全成立),所以 `crates/cli/src/runner.rs` 那条「写失败 ⇒ `EXIT_INTERNAL`」的分支不会 - 被触发。结论:本 diff **没有**给 CLI 退出码引入可观察差异;真正的差异只有「没控制台就没人看得见输出」 - 这件事,而 CUI 时代在服务里同样是隐形的。想让这种情形有专属退出码/文案,得在 CLI 侧显式判一次能力 - (`platform::win32::console` 的 `output_visible()` 就是现成的判据),那在 `crates/cli/**`、不在 M196 的 - scope 内——这里只如实记录形态,不改结构。 +- **既无控制台也不重定向的宿主:输出照旧无处可去,退出码不变**(M196 r2 实测,反驳 r1 复审 P2-3 的 + 「退出码会变成 `EXIT_INTERNAL`(2)」——那条是**纯推理、无实测**)。两个**近似**形态 × 两条命令, + `child_exit_code` 全是 **0**:① 父进程有 std 句柄、子进程不继承(`dwFlags=0` + `bInheritHandles=FALSE`); + ② 父进程 `FreeConsole()` 后把 `in/out/err` 三个 std 句柄全置 0,再起子进程(子进程侧实测 + `stdin/stdout/stderr = 0x0`、`AttachConsole` 返回 0 / `GetLastError=6`)。原因在 Rust std 的 Windows + 实现:句柄取不到时 `Write`/`flush` 直接返回 `Ok`(独立探针实测 `SetStdHandle(out)` = `0x0` / `0x1234` / + `(HANDLE)-1` 三种取值,`write=Ok(())`、`flush=Ok(())` 全成立),所以 `crates/cli/src/runner.rs` 那条 + 「写失败 ⇒ `EXIT_INTERNAL`」的分支不会被触发。结论:本 diff **没有**给 CLI 退出码引入可观察差异; + 真正的差异只有「没控制台就没人看得见输出」这件事本身。 + **边界(别把上面读成「真服务也测过」)**:①② 都只是**近似**形态——真 SCM 服务与计划任务(session 0、 + no window station)、`start /b`、Windows Terminal / ConPTY 全部**未测**;对它们的结论只能靠「句柄无效 + 时 std 写返回 `Ok`」这条机制外推,不是在那些形态里亲测。第三方交叉(**复审的读数,不是我跑的**): + r1 复审在 r2 用更干净的形态(父 `FreeConsole()` + 三句柄置 0 + 子进程 + `DETACHED_PROCESS | dwFlags=0 | bInheritHandles=FALSE`)独立复现,产品二进制 `--version` / + `instances list` / `--help` 全 0(`logs list` 是 1,但重定向跑同一条也是 1 ⇒ 用户错误,不是写失败)。 + 想让这种情形有专属退出码/文案,得在 CLI 侧显式判一次能力(`platform::win32::console` 的 + `output_visible()` 就是现成判据),那在 `crates/cli/**`、不在 M196 的 scope 内——这里只如实记录形态。