fix(m196): GUI 产物 PE Subsystem → WINDOWS_GUI(2),并用 AttachConsole 保住 CLI 输出
群友 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 落点」措辞同步改正。
This commit is contained in:
1 parent
30b61e2684
commit
14f9318e09
15 files changed
+693
-36
No files matched your search
@@ -207,6 +207,18 @@ jobs:
|
|||||||
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
|
# 同上:`--workspace` 不含 `unlucky-app`(M118)。被摘掉的正是 release 档
|
||||||
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
|
# (lto=fat + codegen-units=1)最贵的那棵 slint + usvg 树。
|
||||||
- run: cargo build --workspace --release
|
- 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 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
|
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
|
||||||
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
|
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ name: Guards
|
|||||||
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
|
# 3. design-zero-dep —— crates/design 零第三方依赖(M61 / M87):依赖表(含 dev/build)必须为空 + 依赖树只允许出现自身
|
||||||
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
|
# 4. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束)
|
||||||
# 5. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验
|
# 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 退役树)。逐条核对过本文件,
|
# M118(D26):根 Cargo.toml 已 `exclude = ["crates/app"]`(Slint 退役树)。逐条核对过本文件,
|
||||||
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
|
# **没有**任何一条守卫以 `--workspace` 为前提,所以排除不会让它们空转:
|
||||||
@@ -179,22 +179,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
echo "OK: unlucky-design 依赖树只有自身,零第三方依赖"
|
||||||
|
|
||||||
# D36 定案:整窗 α 是全仓**唯一**需要 `unsafe` 的能力(Win32 的
|
# D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。
|
||||||
# `SetLayeredWindowAttributes` 没有安全替代),落点收在一个文件里。
|
# M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件,
|
||||||
# 判据必须是**集合相等**而不是「黑名单命中」:
|
# 判据是**集合相等**而不是「黑名单命中」:
|
||||||
# - 新增第二个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
|
# - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿);
|
||||||
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
|
# - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径);
|
||||||
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
|
# - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖
|
||||||
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
|
# (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。
|
||||||
unsafe-scope:
|
unsafe-scope:
|
||||||
name: unsafe 落点守门(集合必须恰好一个文件)
|
name: unsafe 落点守门(集合必须恰好等于期望)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: 全仓显式放行 unsafe 的文件集合 == 唯一落点
|
- name: 全仓显式放行 unsafe 的文件集合 == 期望集合
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
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):一个小词法器,而不是正则。
|
# 扫描口径(M91 r3):一个小词法器,而不是正则。
|
||||||
#
|
#
|
||||||
# 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里
|
# 为什么必须用词法器: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
|
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
|
if scan "$f"; then printf '%s\n' "$f"; fi
|
||||||
done | LC_ALL=C sort || true)
|
done | LC_ALL=C sort || true)
|
||||||
echo "期望(恰好这一个):$EXPECTED"
|
echo "期望(恰好这两处,集合相等):$EXPECTED"
|
||||||
echo "实际扫到的集合:"
|
echo "实际扫到的集合:"
|
||||||
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
if [ -n "$actual" ]; then printf '%s\n' "$actual" | sed 's/^/ - /'; else echo " (空)"; fi
|
||||||
if [ "$actual" != "$EXPECTED" ]; then
|
if [ "$actual" != "$EXPECTED" ]; then
|
||||||
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
|
extra=$(printf '%s\n' "$actual" | grep -vxF "$EXPECTED" || true)
|
||||||
if [ -n "$extra" ]; then
|
if [ -n "$extra" ]; then
|
||||||
echo "::error::多出来的 unsafe 落点(D36 只允许一个):"
|
echo "::error::多出来的 unsafe 落点(不在期望集合里):"
|
||||||
printf '%s\n' "$extra" | sed 's/^/ - /'
|
printf '%s\n' "$extra" | sed 's/^/ - /'
|
||||||
fi
|
fi
|
||||||
if ! printf '%s\n' "$actual" | grep -qxF "$EXPECTED"; then
|
if [ -n "$(printf '%s\n' "$EXPECTED" | grep -vxF "$actual" || true)" ]; then
|
||||||
echo "::error file=$EXPECTED::这个文件里没有显式放行 unsafe(allow / expect(unsafe_code))—— 落点被搬走/改名/删了放行?守门路径已失效。"
|
echo "::error::期望集合里的落点没被扫到(放行属性被删 / 文件改名搬走?):"
|
||||||
|
printf '%s\n' "$EXPECTED" | grep -vxF "$actual" | sed 's/^/ - /' || true
|
||||||
fi
|
fi
|
||||||
echo "::error::整窗 α 的 FFI 只允许住在 $EXPECTED(D36)。需要新的 unsafe 能力请先立 mission:改这条守门 + 更新 D36 的定案,不要顺手放行。"
|
echo "::error::unsafe 的 FFI 只允许住在上面的期望集合里(D36 / M196)。新的 unsafe 能力要先立 mission:改守门 + 更新定案,不要顺手放行。"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: 全仓显式放行 unsafe 的文件恰好只有 $EXPECTED"
|
echo "OK: 全仓显式放行 unsafe 的文件恰好等于期望集合"
|
||||||
|
|
||||||
file-size:
|
file-size:
|
||||||
name: 文件行数 ≤400 守门
|
name: 文件行数 ≤400 守门
|
||||||
|
|||||||
@@ -259,6 +259,26 @@ jobs:
|
|||||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
|
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"; exit 1
|
||||||
fi
|
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)
|
- name: 打包(产物 + SHA256SUMS)
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
|
//! | `shot` | `window::screenshot` → PNG(验收通道) | 图像比对 |
|
||||||
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
|
//! | `icons` | 令牌里的 SVG path → iced `svg` 控件 | 画新图标 |
|
||||||
//! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) |
|
//! | [`ui`] | 界面语言的入口层(M106):`LangState` = 用户档位 + 探测结论,目录由它选 | 翻译(那是 `unlucky-i18n`) |
|
||||||
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— **全仓唯一** `unsafe` 落点 | 窗口创建 / 业务开关 |
|
//! | `platform` | 平台窗口能力(D36):整窗 α 的纯判据 + win32 FFI(补分层样式 + 下发 α)—— `unsafe` 落点之一;另一半是 M196 的控制台附着 | 窗口创建 / 业务开关 |
|
||||||
//!
|
//!
|
||||||
//! ## 消费纪律
|
//! ## 消费纪律
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里:
|
//! 入口。所有实际逻辑在 lib(`unlucky_app_iced`)里。
|
||||||
//!
|
//!
|
||||||
//! 一个二进制两种用法(M82),分发在进 UI 之前完成:
|
//! 一个二进制两种用法(M82),分发在进 UI 之前完成:
|
||||||
//!
|
//!
|
||||||
@@ -15,13 +15,49 @@
|
|||||||
//! 给服务器 / CI / Termux 这类没有图形库的机器用。
|
//! 给服务器 / CI / Termux 这类没有图形库的机器用。
|
||||||
//!
|
//!
|
||||||
//! 退出码:`0` 成功 / `1` 截图有差异(diff 模式)或 CLI 用户错误 / `2` 参数或运行错误。
|
//! 退出码:`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 std::process::ExitCode;
|
||||||
|
|
||||||
|
use unlucky_app_iced::platform::win32::console::attach_parent_console;
|
||||||
use unlucky_app_iced::shot;
|
use unlucky_app_iced::shot;
|
||||||
use unlucky_app_iced::Options;
|
use unlucky_app_iced::Options;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
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<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
if let Some(code) = run_cli_if_requested(&args) {
|
if let Some(code) = run_cli_if_requested(&args) {
|
||||||
return code;
|
return code;
|
||||||
@@ -43,7 +79,7 @@ fn main() -> ExitCode {
|
|||||||
);
|
);
|
||||||
// M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。
|
// M98:验收开关写在用法里,不然「日志怎么变详细了」只能靠翻文档。
|
||||||
eprintln!(
|
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 覆盖窗口尺寸"
|
,ULL_WINDOW_SIZE=1520x900 覆盖窗口尺寸"
|
||||||
);
|
);
|
||||||
ExitCode::from(2)
|
ExitCode::from(2)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点,
|
//! 平台相关的窗口能力(P4 / D36 / M116):整窗透明度与窗口圆角的**判据**、两处 `unsafe` 落点,
|
||||||
//! 以及系统语言真值源,三件事分居三处。
|
//! 以及系统语言真值源——每一件各归一处(判据不碰 FFI,FFI 只住落点文件)。
|
||||||
//!
|
//!
|
||||||
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
||||||
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
||||||
//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 →
|
//! - [`win32::corner`] = 圆角的纯判据 [`plan_corner`](M116):用户策略 × 系统版本 × 渲染档 →
|
||||||
//! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明;
|
//! 来源与原因码。两个判据都**不碰** FFI,因此都能被断言证明;
|
||||||
//! - [`win32::transparency`] = 唯一允许 `unsafe` 的文件(补 `WS_EX_LAYERED` +
|
//! - [`win32::transparency`] = 整窗 α 的 FFI 落点(补 `WS_EX_LAYERED` +
|
||||||
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
|
//! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`);
|
||||||
|
//! - [`win32::console`] = M196 的控制台附着落点(`AttachConsole`):GUI 子系统进程的输出
|
||||||
|
//! 为什么在 cmd 里会静默丢失,以及唯一的补救。两个落点的放行范围各自收在一个文件里,
|
||||||
|
//! `unsafe-scope` 门禁的期望集合就是这两个路径(集合相等,不是黑名单);
|
||||||
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
|
//! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。
|
||||||
//!
|
//!
|
||||||
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
|
//! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`]
|
//! 才有「有测试的判据 + 没测试的观感」这条诚实的分界(与 [`super::super::effective_window_alpha`]
|
||||||
//! 同一套分法)。
|
//! 同一套分法)。
|
||||||
//!
|
//!
|
||||||
//! 真正下发给窗口的动作在 [`super::transparency`](全仓唯一 `unsafe` 落点)。
|
//! 真正下发给窗口的动作在 [`super::transparency`](`unsafe` 落点之一,另一个是 [`super::console`])。
|
||||||
//!
|
//!
|
||||||
//! ## 平台矩阵(哪些是实测、哪些是推断)
|
//! ## 平台矩阵(哪些是实测、哪些是推断)
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
//!
|
//!
|
||||||
//! | 子模块 | 是什么 | 有没有 `unsafe` |
|
//! | 子模块 | 是什么 | 有没有 `unsafe` |
|
||||||
//! |---|---|---|
|
//! |---|---|---|
|
||||||
//! | [`transparency`] | D36 定案的整窗 α 唯一实现落点(也是 M116 的 DWM 圆角落点) | **有**(全仓唯一) |
|
//! | [`transparency`] | D36 定案的整窗 α 唯一实现落点(也是 M116 的 DWM 圆角落点) | **有**(落点之一) |
|
||||||
//! | [`corner`] | M116 的圆角**判据**:策略 × 系统版本 × 渲染档 → 来源与原因码 | 没有(纯函数) |
|
//! | [`corner`] | M116 的圆角**判据**:策略 × 系统版本 × 渲染档 → 来源与原因码 | 没有(纯函数) |
|
||||||
|
//! | [`console`] | M196:GUI 子系统进程的输出接回父进程控制台(`AttachConsole`) | **有**(落点之二) |
|
||||||
//!
|
//!
|
||||||
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
|
//! 目录形态(而非 `win32.rs` 单文件)是为了让「每个平台一份实现」在路径上一眼可见:
|
||||||
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
|
//! 将来 macOS / Wayland 的对应能力各自成目录,互不干扰。
|
||||||
//!
|
//!
|
||||||
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
|
//! **非 Windows 平台也在编译**:`transparency` 里的公开函数有同签名的空实现,
|
||||||
//! `corner` 的判据在探测不到系统版本时给出「直角 + `unknown-os`」,所以调用点不需要 `#[cfg]`,
|
//! `corner` 的判据在探测不到系统版本时给出「直角 + `unknown-os`」,`console` 返回
|
||||||
|
//! [`console::ConsoleOutcome::NotWindows`],所以调用点不需要 `#[cfg]`,
|
||||||
//! 行为(返回「本平台不支持」)可被测试断言。
|
//! 行为(返回「本平台不支持」)可被测试断言。
|
||||||
|
|
||||||
|
pub mod console;
|
||||||
pub mod corner;
|
pub mod corner;
|
||||||
pub mod transparency;
|
pub mod transparency;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
//! 整窗透明度(D36):**全仓唯一**允许 `unsafe` 的文件。
|
//! 整窗透明度(D36):整窗 α 的**唯一**实现落点。
|
||||||
//!
|
//!
|
||||||
//! ## 为什么只有这里有 unsafe
|
//! ## 为什么只有这里有 unsafe
|
||||||
//!
|
//!
|
||||||
//! 窗口级 α 没有任何「安全」的 Rust API 可达:`SetLayeredWindowAttributes` 是 Win32 的
|
//! 窗口级 α 没有任何「安全」的 Rust API 可达:`SetLayeredWindowAttributes` 是 Win32 的
|
||||||
//! 唯一路径(D36 逐路核实过 wgpu / iced / winit 都没有代理入口)。所以把**整个仓库**的
|
//! 唯一路径(D36 逐路核实过 wgpu / iced / winit 都没有代理入口)。所以把窗口 FFI 面收在
|
||||||
//! FFI 面收在这一处,其余一层([`super::super::effective_window_alpha`])保持纯函数。
|
//! 这一处,其余一层([`super::super::effective_window_alpha`])保持纯函数。
|
||||||
//! 守门在 `.github/workflows/guards.yml` 的 `unsafe-scope` job:全仓带这个 allow 的文件集合
|
//! 守门在 `.github/workflows/guards.yml` 的 `unsafe-scope` job:全仓带这个 allow 的文件集合必须
|
||||||
//! 必须**恰好等于**本文件的路径(集合相等,不是黑名单命中)。
|
//! **恰好等于**期望集合(M196 起是两处:本文件与 `console.rs`,各自把 FFI 收在自己文件里)。
|
||||||
//!
|
//!
|
||||||
//! ## 只做 LWA_ALPHA
|
//! ## 只做 LWA_ALPHA
|
||||||
//!
|
//!
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
//! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`,
|
//! 同签名的空实现返回 [`AlphaOutcome::Unsupported`]:调用点不需要 `#[cfg]`,
|
||||||
//! 而「这个平台不支持」这件事本身可以被断言。
|
//! 而「这个平台不支持」这件事本身可以被断言。
|
||||||
|
|
||||||
// D36:这是全仓唯一的 unsafe 落点,CI 的 `unsafe-scope` job 会比对文件集合。
|
// D36:整窗 α 的 unsafe 落点。CI 的 `unsafe-scope` job 比对**文件集合**(M196 起是两个:本文件与 console.rs)。
|
||||||
#![allow(unsafe_code)]
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
mod alpha;
|
mod alpha;
|
||||||
@@ -106,12 +106,12 @@ pub(super) fn has_layered(hwnd: isize) -> bool {
|
|||||||
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
|
raw_ex_style(hwnd) & WS_EX_LAYERED != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 句柄是否指向一个**存在**的窗口(全仓唯一 `unsafe` 落点的一部分)。
|
/// 句柄是否指向一个**存在**的窗口(整窗 α 落点的一部分)。
|
||||||
///
|
///
|
||||||
/// 为什么在这里而不是在取证探针那个文件里:`transparency.rs` 是全仓**唯一**允许 `unsafe` 的
|
/// 为什么在这里而不是在取证探针那个文件里:`unsafe-scope` 判据是「带
|
||||||
/// 文件(`unsafe-scope` 判据 = 「带 `#![allow(unsafe_code)]` 的文件集合恰好等于本文件」)。
|
/// `#![allow(unsafe_code)]` 的文件集合恰好等于期望集合」,而窗口探针不在那个集合里。
|
||||||
/// 探针用到 FFI 时就只能**把 FFI 搬进来**,不能让 `unsafe` 溜到没有标记的文件里——
|
/// 探针用到 FFI 时就只能**把 FFI 搬进来**,不能让 `unsafe` 溜到没有标记的文件里——
|
||||||
/// 那会让门禁照样绿、而「唯一落点」这个性质已经破了(M100 r1 复审实测抓到的形状)。
|
/// 那会让门禁照样绿、而「落点受限」这个性质已经破了(M100 r1 复审实测抓到的形状)。
|
||||||
///
|
///
|
||||||
/// `0` / 无效句柄 → `false`(Win32 的 `IsWindow` 对无效句柄返回 0)。
|
/// `0` / 无效句柄 → `false`(Win32 的 `IsWindow` 对无效句柄返回 0)。
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! ## 本文件零 `unsafe`、零 `extern`
|
//! ## 本文件零 `unsafe`、零 `extern`
|
||||||
//!
|
//!
|
||||||
//! `DwmSetWindowAttribute` / `DwmGetWindowAttribute` 的声明与调用点在 [`super`]
|
//! `DwmSetWindowAttribute` / `DwmGetWindowAttribute` 的声明与调用点在 [`super`]
|
||||||
//! (全仓唯一 `unsafe` 落点);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。
|
//! (`unsafe` 落点之一;另一个是 `super::super::console`);这里只做「哪一档 → 哪个属性值」的映射、`HRESULT` 解释与回读。
|
||||||
//!
|
//!
|
||||||
//! ## 为什么「直角」也要真的下发一次
|
//! ## 为什么「直角」也要真的下发一次
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ⚠️ **别把 FFI 写回来**(M100 r1 复审实测抓过一次):lint 级别沿模块树继承,所以在这里写
|
//! ⚠️ **别把 FFI 写回来**(M100 r1 复审实测抓过一次):lint 级别沿模块树继承,所以在这里写
|
||||||
//! 一个 `unsafe` 块 + `extern` 声明也**编得过**,`unsafe-scope` 门禁也照样绿(它只数「带放行
|
//! 一个 `unsafe` 块 + `extern` 声明也**编得过**,`unsafe-scope` 门禁也照样绿(它只数「带放行
|
||||||
//! 属性的文件」)——但「全仓唯一允许 unsafe 的文件」这个性质就破了。
|
//! 属性的文件」)——但「FFI 只住在少数几个落点文件里」这个性质就破了。
|
||||||
//! 属性的作用是可审计性,不是给 CI 看的。
|
//! 属性的作用是可审计性,不是给 CI 看的。
|
||||||
//!
|
//!
|
||||||
//! ## 判据来源
|
//! ## 判据来源
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! `transparency` 的测试(M116 从 `transparency.rs` 搬出来:那个文件要留行数给 DWM 的 FFI)。
|
//! `transparency` 的测试(M116 从 `transparency.rs` 搬出来:那个文件要留行数给 DWM 的 FFI)。
|
||||||
//!
|
//!
|
||||||
//! **本文件零 `unsafe`、零 `extern`**:只调父模块的安全包装。别把 FFI 写进来——
|
//! **本文件零 `unsafe`、零 `extern`**:只调父模块的安全包装。别把 FFI 写进来——
|
||||||
//! 「全仓唯一允许 unsafe 的文件」是 `transparency.rs`,不是它下面的每个文件。
|
//! 带 `#![allow(unsafe_code)]` 的是 `transparency.rs`(以及 M196 的 `console.rs`),不是它下面的每个文件。
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
|
alpha_byte, apply_window_alpha, ensure_layered, AlphaOutcome, AlphaRejection, GWL_EXSTYLE,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
|||||||
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||||
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
|
||||||
| `packaging/check_publish_slots.py` | 发布名单守卫(M164):名单 ⇄ 产物两个方向;`--self-test` 注入 8 条回归 | ≤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/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/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 |
|
||||||
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤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` +
|
`ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` +
|
||||||
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
|
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
|
||||||
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
|
**不验**真 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 <exe> --cli <exe>`,读 `MZ`→`e_lfanew`(`0x3C`)→
|
||||||
|
`PE | ||||||