From 40a296471d706c176d386c24ffcdb3c2871a628f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 14:30:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E6=89=80=E6=9C=89=E5=AD=90?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=BB=9F=E4=B8=80=E8=B5=B0=20platform::child?= =?UTF-8?q?::quiet=20+=20=E8=A7=92=E8=89=B2=E5=AE=88=E5=8D=AB=EF=BC=88M213?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GUI 父进程(PE Subsystem = 2)spawn 控制台子进程时,Windows 会为子进程新建一块 控制台——桌面上就是「黑框闪一下」。改前 crates/core/src 有 21 处 Command::new, 只有 tree_kill 那一处带了 CREATE_NO_WINDOW,于是探 N 个 JDK 就闪 N 次 java -version。 - 新增 crates/core/src/platform/child.rs:唯一构造器 quiet(program),Windows 上统一 .creation_flags(CREATE_NO_WINDOW),其它平台原样返回。tree_kill 里那份就地合并进来, 不再保留第二份常量。 - 20 处 spawn 全部改走它(region_system / java{discover,manual,probe,proc,termux} / addon exec / platform{linux_caps,macos_version,theme_probe,vk_probe,windows_version, wallpaper/io})。 - launch/process.rs 零改动:游戏进程要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T, 带不带控制台窗口是产品决定。 - crates/core/tests/child_quiet.rs:按角色判「有没有绕开构造器」的守卫,外加构造器 本身的 4 条行为用例。三条变异(裸 Command::new / 别名 Spawn::new / 删掉 creation_flags) 都实测变红并已还原。 --- .../core/src/addon/loader/processors/exec.rs | 4 +- crates/core/src/download/region_system.rs | 6 +- crates/core/src/game/java/discover.rs | 4 +- crates/core/src/game/java/manual.rs | 2 +- crates/core/src/game/java/probe.rs | 4 +- crates/core/src/game/java/proc.rs | 8 +- crates/core/src/game/java/termux.rs | 4 +- crates/core/src/launch/tree_kill.rs | 15 +- crates/core/src/platform/child.rs | 55 +++ crates/core/src/platform/linux_caps.rs | 3 +- crates/core/src/platform/macos_version.rs | 3 +- crates/core/src/platform/mod.rs | 4 + crates/core/src/platform/theme_probe.rs | 3 +- crates/core/src/platform/vk_probe.rs | 3 +- crates/core/src/platform/wallpaper/io.rs | 6 +- crates/core/src/platform/windows_version.rs | 3 +- crates/core/tests/child_quiet.rs | 329 ++++++++++++++++++ 17 files changed, 417 insertions(+), 39 deletions(-) create mode 100644 crates/core/src/platform/child.rs create mode 100644 crates/core/tests/child_quiet.rs diff --git a/crates/core/src/addon/loader/processors/exec.rs b/crates/core/src/addon/loader/processors/exec.rs index 0c2029c..62ea5b3 100644 --- a/crates/core/src/addon/loader/processors/exec.rs +++ b/crates/core/src/addon/loader/processors/exec.rs @@ -16,7 +16,6 @@ //! 既不误杀真实装,也不假装校验通过。 use std::path::Path; -use std::process::Command; use std::sync::Arc; use std::time::Instant; @@ -31,6 +30,7 @@ use crate::download::verify; use crate::download::HashAlgo; use crate::game::java::{self, JavaRequirement, JavaRuntime}; use crate::launch::redact::Redactor; +use crate::platform::child::quiet; /// 一步的结局。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -227,7 +227,7 @@ fn run_step( let classpath = step.classpath_join()?; let started = Instant::now(); - let output = Command::new(&runtime.binary) + let output = quiet(&runtime.binary) .arg("-cp") .arg(&classpath) .arg(&main_class) diff --git a/crates/core/src/download/region_system.rs b/crates/core/src/download/region_system.rs index eb60e48..e01c8fe 100644 --- a/crates/core/src/download/region_system.rs +++ b/crates/core/src/download/region_system.rs @@ -14,7 +14,7 @@ //! 两个纪律:①**不引依赖**——`tzutil` / `reg` 都是系统自带,`discover.rs` 早就有 `reg query` //! 的先例;②**不做网络探测**——探测(google 可达性)仍由调用方决定开不开。 -use std::process::Command; +use crate::platform::child::quiet; use super::region::{Signals, GREATER_CHINA_TIMEZONES}; @@ -64,7 +64,7 @@ pub fn detect_signals() -> Signals { /// `tzutil /g` 的输出(失败为 `None`)。 #[must_use] pub fn windows_timezone() -> Option { - let output = Command::new("tzutil").arg("/g").output().ok()?; + let output = quiet("tzutil").arg("/g").output().ok()?; if !output.status.success() { return None; } @@ -75,7 +75,7 @@ pub fn windows_timezone() -> Option { /// 系统的 locale 名(`HKCU\Control Panel\International` 的 `LocaleName`)。 #[must_use] pub fn windows_locale() -> Option { - let output = Command::new("reg") + let output = quiet("reg") .args([ "query", "HKCU\\Control Panel\\International", diff --git a/crates/core/src/game/java/discover.rs b/crates/core/src/game/java/discover.rs index bfd9d01..47ee23e 100644 --- a/crates/core/src/game/java/discover.rs +++ b/crates/core/src/game/java/discover.rs @@ -21,8 +21,8 @@ //! (别为了一次启动白跑 N 次 `java -version`)。 use std::path::{Path, PathBuf}; -use std::process::Command; +use crate::platform::child::quiet; use crate::platform::paths::{EnvSource, ProcessEnv}; use crate::platform::termux; @@ -153,7 +153,7 @@ pub fn registry_homes() -> Vec { } let mut homes = Vec::new(); for view in ["64", "32"] { - let Ok(output) = Command::new("reg") + let Ok(output) = quiet("reg") .args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"]) .arg(view) .output() diff --git a/crates/core/src/game/java/manual.rs b/crates/core/src/game/java/manual.rs index 7bb390e..ba100ba 100644 --- a/crates/core/src/game/java/manual.rs +++ b/crates/core/src/game/java/manual.rs @@ -105,7 +105,7 @@ pub fn probe_with_timeout( cancelled: &dyn Fn() -> bool, ) -> (JavaRuntime, String) { let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); - let mut command = std::process::Command::new(binary); + let mut command = crate::platform::child::quiet(binary); command.arg("-version"); let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else { return (runtime, String::new()); diff --git a/crates/core/src/game/java/probe.rs b/crates/core/src/game/java/probe.rs index 5dee6eb..2d3430b 100644 --- a/crates/core/src/game/java/probe.rs +++ b/crates/core/src/game/java/probe.rs @@ -8,10 +8,10 @@ //! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。 use std::path::{Path, PathBuf}; -use std::process::Command; use super::extract::{java_binary_name, read_stamp}; use super::vendor::JdkVendor; +use crate::platform::child::quiet; /// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。 pub const DEFAULT_MAJOR: u32 = 17; @@ -119,7 +119,7 @@ impl JavaRuntime { #[must_use] pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime { let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); - let Ok(output) = Command::new(binary).arg("-version").output() else { + let Ok(output) = quiet(binary).arg("-version").output() else { return runtime; }; let mut text = String::from_utf8_lossy(&output.stderr).into_owned(); diff --git a/crates/core/src/game/java/proc.rs b/crates/core/src/game/java/proc.rs index 1716e8f..e43e828 100644 --- a/crates/core/src/game/java/proc.rs +++ b/crates/core/src/game/java/proc.rs @@ -236,7 +236,7 @@ mod tests { seconds: u32, ) -> std::process::Command { let mut command = if cfg!(windows) { - let mut command = std::process::Command::new("powershell"); + let mut command = crate::platform::child::quiet("powershell"); command.args([ "-NoProfile", "-Command", @@ -248,7 +248,7 @@ mod tests { ]); command } else { - let mut command = std::process::Command::new("sh"); + let mut command = crate::platform::child::quiet("sh"); command.args([ "-c", &format!( @@ -267,11 +267,11 @@ mod tests { /// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。 fn sleeping_command(seconds: u32) -> std::process::Command { if cfg!(windows) { - let mut command = std::process::Command::new("cmd"); + let mut command = crate::platform::child::quiet("cmd"); command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]); command } else { - let mut command = std::process::Command::new("sleep"); + let mut command = crate::platform::child::quiet("sleep"); command.arg(seconds.to_string()); command } diff --git a/crates/core/src/game/java/termux.rs b/crates/core/src/game/java/termux.rs index fa471c8..03c1661 100644 --- a/crates/core/src/game/java/termux.rs +++ b/crates/core/src/game/java/termux.rs @@ -10,8 +10,8 @@ //! 镜像顺序是实测可用的国内源;全部失败才报错,错误里带每一跳的原因。 use std::path::{Path, PathBuf}; -use std::process::Command; +use crate::platform::child::quiet; use crate::platform::paths::ProcessEnv; use crate::platform::termux as host; @@ -183,7 +183,7 @@ fn apt_install(prefix: &Path, workspace: &Path, mirror: &str, package: &str) -> /// 执行一次 `apt-get`(`-o` 选项在前,子命令在后)。 fn run_apt(prefix: &Path, options: &[String], args: &[&str]) -> Result<(), String> { let apt = prefix.join("bin").join("apt-get"); - let mut command = Command::new(&apt); + let mut command = quiet(&apt); command.env("DEBIAN_FRONTEND", "noninteractive"); for option in options { command.arg("-o").arg(option); diff --git a/crates/core/src/launch/tree_kill.rs b/crates/core/src/launch/tree_kill.rs index 278a0ba..7fc1a1b 100644 --- a/crates/core/src/launch/tree_kill.rs +++ b/crates/core/src/launch/tree_kill.rs @@ -28,7 +28,8 @@ //! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。 use std::io; -use std::process::Command; + +use crate::platform::child::quiet; /// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。 pub fn terminate(pid: u32) -> io::Result<()> { @@ -100,7 +101,7 @@ fn pgid_of(pid: u32) -> Option { #[cfg(all(unix, not(target_os = "linux")))] fn pgid_of(pid: u32) -> Option { - let output = Command::new("ps") + let output = quiet("ps") .args(["-o", "pgid=", "-p", &pid.to_string()]) .output() .ok()?; @@ -113,18 +114,14 @@ fn pgid_of(pid: u32) -> Option { #[cfg(windows)] mod imp { use super::*; - use std::os::windows::process::CommandExt; - - /// 不弹控制台窗口(否则 kill 子进程会闪一个黑框)。 - const CREATE_NO_WINDOW: u32 = 0x0800_0000; pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> { - let mut command = Command::new("taskkill"); + // `CREATE_NO_WINDOW` 由 `platform::child::quiet` 统一带(否则 kill 子进程会闪一个黑框)。 + let mut command = quiet("taskkill"); command.arg("/PID").arg(pid.to_string()).arg("/T"); if force { command.arg("/F"); } - command.creation_flags(CREATE_NO_WINDOW); let status = command .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -168,7 +165,7 @@ mod imp { /// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”, /// 排查方向会被带偏(M48 之前的红灯就是这么来的)。 fn run_kill(flag: &str, args: &[&str]) -> io::Result { - match Command::new("kill") + match quiet("kill") .arg(flag) .args(args) .stdout(std::process::Stdio::null()) diff --git a/crates/core/src/platform/child.rs b/crates/core/src/platform/child.rs new file mode 100644 index 0000000..080e885 --- /dev/null +++ b/crates/core/src/platform/child.rs @@ -0,0 +1,55 @@ +//! 子进程构造器:本 crate 里**唯一**允许直接 `std::process::Command::new` 的地方(M213)。 +//! +//! ## 为什么需要它(这不是风格问题,是窗口问题) +//! +//! Windows 上「某个子进程要不要一块控制台」由**创建者的可执行文件类型**决定:一个**自己没有 +//! 控制台**的进程(GUI 程序的 PE Subsystem = 2;本仓库的 `unlucky-launcher.exe` 就是,M196 已改) +//! 去 spawn 一个**控制台程序**(`java.exe` / `reg.exe` / `tzutil.exe` / `cmd.exe` / +//! `powershell.exe` …)时,Windows 会为这个子进程**新建一块控制台**——桌面上看到的就是 +//! 「黑框闪一下」。所以探 N 个 JDK 就闪 N 次 `java -version`,用户的原话是「疯狂闪」。 +//! +//! 抑制手段只有一个:创建时带 `CREATE_NO_WINDOW`(`0x0800_0000`)。 +//! **`Stdio::null()` 不管这件事**——它只把子进程的 stdout/stderr 指到空设备,控制台窗口照样会被 +//! 创建。所以到处补 `.stdout(Stdio::null())` 是无效修法。 +//! +//! ## 谁**不许**走这里 +//! +//! **游戏进程**([`crate::launch::process::RunningProcess::spawn`])不走这里:它要 +//! `CREATE_NEW_PROCESS_GROUP` 才能用 `taskkill /T` 回收整棵进程树,两者是同一枚 +//! `creation_flags` 参数上的不同取值,不能简单叠加。「游戏要不要带自己的控制台窗口」是 +//! **产品决定**(玩家要看游戏自己的输出窗口),不是顺手抑制的事——所以 M213 一个字没动它。 +//! +//! 那边是唯一另一处合法的直接构造点,两处都由 `crates/core/tests/child_quiet.rs` 的守卫钉住。 + +use std::ffi::OsStr; +use std::process::Command; + +/// `CREATE_NO_WINDOW`:子进程不获得控制台窗口。 +/// +/// 见 。 +/// 别和 `CREATE_NEW_CONSOLE`(`0x0000_0010`,意思正好相反)或 +/// `DETACHED_PROCESS`(`0x0000_0008`,连标准句柄都不给)搞混。 +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// 起一个**不该在桌面上留下窗口**的子进程。 +/// +/// - Windows:`.creation_flags(CREATE_NO_WINDOW)`; +/// - 其它平台:原样返回,一个字不改(POSIX 没有「spawn 就新开一个终端」这回事,子进程的 +/// 窗口从来不由我们造)。 +/// +/// 返回值就是普通 [`std::process::Command`],调用方照旧 `.arg(..)` / `.env(..)` / +/// `.output()` / `.status()`,或者整个交给 `game::java::proc::run_with_timeout` 去带超时跑。 +/// 本 crate 是 `#![forbid(unsafe_code)]`,而 `CommandExt::creation_flags` 是 std 的安全 API, +/// 所以这里不需要任何 `unsafe`。 +#[must_use] +pub fn quiet>(program: S) -> Command { + #[allow(unused_mut)] + let mut command = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(CREATE_NO_WINDOW); + } + command +} diff --git a/crates/core/src/platform/linux_caps.rs b/crates/core/src/platform/linux_caps.rs index 3397e9d..fe5c335 100644 --- a/crates/core/src/platform/linux_caps.rs +++ b/crates/core/src/platform/linux_caps.rs @@ -216,8 +216,7 @@ pub fn format_version(version: (u32, u32)) -> String { #[cfg(target_os = "linux")] fn run(program: &str, args: &[&str]) -> Option { - use std::process::Command; - let output = Command::new(program).args(args).output().ok()?; + let output = super::child::quiet(program).args(args).output().ok()?; if output.stdout.is_empty() { return None; } diff --git a/crates/core/src/platform/macos_version.rs b/crates/core/src/platform/macos_version.rs index bc18447..cb64bb4 100644 --- a/crates/core/src/platform/macos_version.rs +++ b/crates/core/src/platform/macos_version.rs @@ -91,8 +91,7 @@ pub fn parse_sysctl_bool(text: &str) -> bool { #[cfg(target_os = "macos")] fn run(program: &str, args: &[&str]) -> Option { - use std::process::Command; - let output = Command::new(program).args(args).output().ok()?; + let output = super::child::quiet(program).args(args).output().ok()?; if !output.status.success() || output.stdout.is_empty() { return None; } diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs index 964d0a4..42cf359 100644 --- a/crates/core/src/platform/mod.rs +++ b/crates/core/src/platform/mod.rs @@ -24,11 +24,15 @@ //! - [`wallpaper`]:桌面壁纸**路径探测**(D38 配色三级优先里第②级的输入)。本模块只做路径 //! 探测——不解码图片、不取色、不生成调色板,莫奈取色与三级降级在 `crates/design` 的 //! 后续 mission。 +//! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带 +//! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上 +//! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。 //! //! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见 //! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。 pub mod atomic; +pub mod child; pub mod data_dir; pub mod dir_migration; pub mod error; diff --git a/crates/core/src/platform/theme_probe.rs b/crates/core/src/platform/theme_probe.rs index e1216de..5a8b62a 100644 --- a/crates/core/src/platform/theme_probe.rs +++ b/crates/core/src/platform/theme_probe.rs @@ -173,8 +173,7 @@ fn parse_dword(raw: &str) -> Option { /// 跑一个外部命令,取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。 fn run(program: &str, args: &[&str]) -> Option { - use std::process::Command; - let output = Command::new(program).args(args).output().ok()?; + let output = super::child::quiet(program).args(args).output().ok()?; if output.stdout.is_empty() { return None; } diff --git a/crates/core/src/platform/vk_probe.rs b/crates/core/src/platform/vk_probe.rs index 25a817e..10581ea 100644 --- a/crates/core/src/platform/vk_probe.rs +++ b/crates/core/src/platform/vk_probe.rs @@ -296,8 +296,7 @@ pub fn ldconfig_has_libvulkan(text: &str) -> bool { /// 跑一个只读命令取 stdout(`reg query` / `ldconfig -p`;失败返回 `None`)。 fn run(program: &str, args: &[&str]) -> Option { - use std::process::Command; - let output = Command::new(program).args(args).output().ok()?; + let output = super::child::quiet(program).args(args).output().ok()?; if output.stdout.is_empty() { return None; } diff --git a/crates/core/src/platform/wallpaper/io.rs b/crates/core/src/platform/wallpaper/io.rs index 3655957..70f9d47 100644 --- a/crates/core/src/platform/wallpaper/io.rs +++ b/crates/core/src/platform/wallpaper/io.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; +use crate::platform::child::quiet; use crate::platform::paths::EnvSource; /// 探测需要的外部世界:四个方法覆盖三平台的全部读法。 @@ -62,10 +63,7 @@ impl ProbeIo for HostIo { } fn run(&self, program: &str, args: &[&str]) -> Option { - let output = std::process::Command::new(program) - .args(args) - .output() - .ok()?; + let output = quiet(program).args(args).output().ok()?; // `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令 // (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把 // 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。 diff --git a/crates/core/src/platform/windows_version.rs b/crates/core/src/platform/windows_version.rs index bc9fd9b..3588ec3 100644 --- a/crates/core/src/platform/windows_version.rs +++ b/crates/core/src/platform/windows_version.rs @@ -206,8 +206,7 @@ fn parse_version_pair(value: &str) -> Option<(u32, u32)> { #[cfg(target_os = "windows")] fn run(program: &str, args: &[&str]) -> Option { - use std::process::Command; - let output = Command::new(program).args(args).output().ok()?; + let output = super::child::quiet(program).args(args).output().ok()?; if output.stdout.is_empty() { return None; } diff --git a/crates/core/tests/child_quiet.rs b/crates/core/tests/child_quiet.rs new file mode 100644 index 0000000..8880ad1 --- /dev/null +++ b/crates/core/tests/child_quiet.rs @@ -0,0 +1,329 @@ +//! M213 守卫:`crates/core/src` 里**所有**子进程都必须经 `platform::child::quiet` 起。 +//! +//! ## 它判什么(按角色,不按次数) +//! +//! 唯一的问题是「这一处直接构造子进程**属于哪种角色**、是不是绕开了构造器」。所以判据是: +//! 每一处直接构造的**文件**必须命中 [`ALLOWED_ROLES`] 里的某一条角色;不在其中就是绕开了 +//! `platform::child::quiet`。新加一处裸构造时,红的信息是「某文件某行不属于任何允许角色」, +//! 而不是「总数对不上 N」——所以它咬得住「随手再 spawn 一个命令」这种增量变异。 +//! +//! 另外每个允许角色还钉了**恰好一处**:角色内部长出第二处裸构造同样会红(例如有人在 +//! `platform/child.rs` 里绕开 `quiet` 再写一个 `Command::new`)。 +//! +//! ## 它抓不住什么(写在前面,别把它当完备保证) +//! +//! - **别名类型**:`type Cmd = std::process::Command;` 之后 `Cmd::new(..)`。本文件只认 +//! `Command as <别名>` 与 `type <别名> = ...Command` 这两种写法导出的别名(认得的会拦), +//! 但 `type A = B; type B = Command` 这种两跳的、或从别的模块 `pub use` 进来的类型别名抓不住。 +//! - **不经过 `::new` 的构造**:`std::process::Command` 目前只有 `new` / `default`, +//! 没有 `From`,所以实际风险低;真出现了本文件看不见。 +//! - **行尾注释**:只看「整行是注释」的行。一条行尾注释里写 `Command::new` 会**误报** +//! (方向安全:宁可红),因为这仓库的文档注释都是整行 `//` 风格,没必要为它写字符串解析。 +//! - **别的 crate**:`crates/cli` / `crates/presenter` / `crates/app-iced` 也可能 spawn 进程, +//! 本守卫的射程只有 `crates/core/src`(M213 的 scope)。 +//! - **行为**:它只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」。 +//! 窗口本身的目视验证不在本会话能力内,见 mission 报告。 +//! +//! ## 变异验证(三条,全部实测过并已还原) +//! +//! 1. 往 `download/region_system.rs` 加一处裸 `std::process::Command::new("tzutil")` +//! ⇒ [`no_spawn_site_bypasses_the_quiet_constructor`] 红,报文点名 +//! `src/download/region_system.rs:163`(退出码 101)。 +//! 2. 同一处改写成别名形式(`use std::process::Command as Spawn;` + `Spawn::new("tzutil")`) +//! ⇒ 同样红,报文点名 `Spawn::new(...)`。 +//! 3. 删掉 `platform/child.rs` 里那行 `command.creation_flags(CREATE_NO_WINDOW)` +//! ⇒ [`the_constructor_actually_passes_create_no_window`] 红。 +//! +//! 第 3 条第一次跑时**没红**:那版断言搜的是整份源码,而 `quiet` 的文档注释里就写着 +//! `.creation_flags(CREATE_NO_WINDOW)`——注释把断言喂饱了。现在两个测试都先剥注释 +//! ([`code_lines`])再判,重跑才红。这条教训写在这里:**守卫本身也要被变异验过**, +//! 否则它守的是注释。 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// 允许直接构造子进程的**角色**:`(相对 crates/core/src 的路径, 为什么这个角色可以)`。 +/// +/// 这张表是**角色**清单,不是「白名单文件」清单:新增一行必须写清「为什么这一处不能走 +/// `quiet`」。`launch/process.rs` 那条就是全部理由——游戏进程要自己那块控制台。 +const ALLOWED_ROLES: &[(&str, &str)] = &[ + ( + "platform/child.rs", + "构造器自身:`child::quiet` 内部就是这一处 `Command::new`", + ), + ( + "launch/process.rs", + "游戏进程:要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T 回收进程树;\ + 游戏要不要带自己的控制台窗口是产品决定,不是顺手抑制(M213 明确不动)", + ), +]; + +#[test] +fn no_spawn_site_bypasses_the_quiet_constructor() { + let root = src_root(); + let mut files = Vec::new(); + collect_rust_files(&root, &root, &mut files); + files.sort(); + assert!( + !files.is_empty(), + "没扫到任何 .rs:{} 路径不对", + root.display() + ); + + let mut sites: Vec<(String, usize, String)> = Vec::new(); + for rel in &files { + let text = read_source(&root, rel); + let aliases = command_aliases(&text); + for (number, line) in code_lines(&text) { + if is_spawn_site(line, &aliases) { + sites.push((rel.clone(), number, line.trim().to_string())); + } + } + } + + let illegal: Vec = sites + .iter() + .filter(|(rel, ..)| !ALLOWED_ROLES.iter().any(|(path, _)| path == rel)) + .map(|(rel, number, line)| format!(" src/{rel}:{number} {line}")) + .collect(); + assert!( + illegal.is_empty(), + "发现绕开 `platform::child::quiet` 的子进程构造({} 处):\n{}\n\ + 在 Windows 上这会为每个子进程新建一块控制台(GUI 父进程下就是桌面上「黑框闪一下」)。\n\ + 改走 `crate::platform::child::quiet(program)`;若这一处确实不该抑制窗口,\ + 把它的**角色**加进本文件的 ALLOWED_ROLES 并写清理由。", + illegal.len(), + illegal.join("\n"), + ); + + // 角色核对:允许的角色必须真的存在、且**恰好一处**,否则这张表会悄悄变成摆设。 + for (path, role) in ALLOWED_ROLES { + let hits: Vec = sites + .iter() + .filter(|(rel, ..)| rel == path) + .map(|(.., number, line)| format!(":{number} {line}")) + .collect(); + assert_eq!( + hits.len(), + 1, + "角色 `src/{path}`({role})应当恰好一处直接构造,实测 {} 处:{hits:?}", + hits.len(), + ); + } +} + +#[test] +fn the_constructor_actually_passes_create_no_window() { + let text = read_source(&src_root(), "platform/child.rs"); + // **只看代码行**:`quiet` 的文档注释里就写着 `.creation_flags(CREATE_NO_WINDOW)`, + // 对着整份源码搜会被那句注释喂饱——删掉真正的设置行也照样绿(实测踩过)。 + let code: Vec<&str> = code_lines(&text) + .into_iter() + .map(|(_, line)| line) + .collect(); + // 只去空白,避免格式调整造成假红(下划线是标识符的一部分,不能去)。 + let no_space: String = code + .concat() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + assert!( + no_space.contains("creation_flags(CREATE_NO_WINDOW)"), + "`platform::child::quiet` 不再设置 creation_flags —— 抑制窗口的那一行被删了?\n\ + 注意 `.stdout(Stdio::null())` 不能替代它:那只改句柄,控制台窗口照样会被创建。", + ); + // 常量值单独比:数字里的下划线是排版自由,去掉再比。 + let definition = code + .iter() + .find(|line| line.trim_start().starts_with("const CREATE_NO_WINDOW")) + .expect("找不到 `CREATE_NO_WINDOW` 的常量定义"); + let literal: String = definition + .split_once('=') + .expect("常量定义里没有 `=`") + .1 + .chars() + .filter(|c| !c.is_whitespace() && *c != '_') + .collect(); + assert_eq!( + literal.trim_end_matches(';'), + "0x08000000", + "`CREATE_NO_WINDOW` 的取值不对(`{definition}`)。必须是 0x0800_0000,\ + 不是 CREATE_NEW_CONSOLE(0x0000_0010,意思正好相反)。", + ); +} + +#[test] +fn quiet_is_a_plain_command_that_still_captures_output() { + // 抑制窗口不能顺手把输出吞了:`.output()` 的管道与退出码必须照旧可用。 + let output = shell(&["echo hi"]).output().expect("spawn 失败"); + assert!(output.status.success(), "退出码:{}", output.status); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi"); +} + +#[test] +fn quiet_keeps_stderr_and_nonzero_exit_code() { + let output = shell(&["echo oops 1>&2", "exit 3"]) + .output() + .expect("spawn 失败"); + assert_eq!(output.status.code(), Some(3), "退出码没透传"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("oops"), + "stderr 没被捕获:{:?}", + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn quiet_keeps_env_and_args() { + // 走 argv 与 env 两条通道,证明 `quiet` 没把 Command 包装成别的东西。 + let script = if cfg!(windows) { + "echo %ULL_QUIET_PROBE%" + } else { + "echo $ULL_QUIET_PROBE" + }; + let output = shell(&[script]) + .env("ULL_QUIET_PROBE", "m213") + .output() + .expect("spawn 失败"); + assert!(output.status.success(), "退出码:{}", output.status); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "m213", + "参数/环境变量在构造器里丢了", + ); +} + +#[test] +fn quiet_does_not_swallow_spawn_errors() { + // 找不到程序时仍然是 Err:构造器不加任何「失败即静默」的逻辑。 + let error = unlucky_core::platform::child::quiet("ull-no-such-program-m213") + .output() + .expect_err("不存在的程序居然 spawn 成功了?"); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); +} + +/// 构造器的返回值必须能直接当 `std::process::Command` 用(下游 `run_with_timeout` 就是这么接的)。 +#[test] +fn quiet_returns_a_std_command() { + fn takes_command(_: Command) {} + takes_command(unlucky_core::platform::child::quiet("ull-type-check-only")); +} + +/// 走构造器起一个 shell,拼上本平台的「跑一段脚本」参数。 +fn shell(scripts: &[&str]) -> Command { + let (program, flag) = if cfg!(windows) { + ("cmd", "/C") + } else { + ("sh", "-c") + }; + let mut command = unlucky_core::platform::child::quiet(program); + command.arg(flag); + // Windows 的 `cmd /C` 只吃一条命令行;POSIX 的 `sh -c` 同理,所以用 `&` / `;` 串起来。 + let joined = if cfg!(windows) { + scripts.join(" & ") + } else { + scripts.join("; ") + }; + command.arg(joined); + command +} + +fn src_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn read_source(root: &Path, rel: &str) -> String { + fs::read_to_string(root.join(rel)).unwrap_or_else(|error| panic!("读不到 src/{rel}:{error}")) +} + +fn collect_rust_files(root: &Path, dir: &Path, out: &mut Vec) { + let entries = + fs::read_dir(dir).unwrap_or_else(|error| panic!("读不到 {}:{error}", dir.display())); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rust_files(root, &path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let rel = path.strip_prefix(root).expect("strip_prefix 失败"); + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } +} + +/// 去掉注释后的可见行(行号保留,报错能指到原文件)。 +/// +/// 只剥「整行注释」与 `/* … */` 块;行尾注释不剥(见文件头的「抓不住什么」)。 +fn code_lines(text: &str) -> Vec<(usize, &str)> { + let mut visible: Vec<(usize, &str)> = Vec::new(); + let mut in_block = false; + for (index, raw) in text.lines().enumerate() { + let number = index + 1; + let mut rest = raw; + loop { + if in_block { + match rest.find("*/") { + Some(end) => { + rest = &rest[end + 2..]; + in_block = false; + } + None => break, + } + } + match rest.find("/*") { + Some(start) => { + visible.push((number, &rest[..start])); + rest = &rest[start + 2..]; + in_block = true; + } + None => { + visible.push((number, rest)); + break; + } + } + } + } + visible + .into_iter() + .filter(|(_, line)| !line.trim_start().starts_with("//")) + .collect() +} + +/// 本文件里由 `std::process::Command` 导出的别名(`use ... Command as X;` / `type X = …Command;`)。 +fn command_aliases(text: &str) -> Vec { + let mut aliases = Vec::new(); + for (_, line) in code_lines(text) { + let trimmed = line.trim_start(); + // `use std::process::Command as Spawn;` / `use std::process::{Command as Spawn};` + if let Some(at) = line.find("Command as ") { + let ident: String = line[at + "Command as ".len()..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !ident.is_empty() { + aliases.push(ident); + } + continue; + } + // `type Cmd = std::process::Command;` —— 别名在 `=` 的**左边**。 + let is_command_type_alias = trimmed.starts_with("type ") && line.ends_with("Command;"); + if is_command_type_alias { + if let Some(ident) = trimmed + .split_whitespace() + .find(|word| *word != "type" && !word.contains('=')) + { + aliases.push(ident.to_string()); + } + } + } + aliases +} + +fn is_spawn_site(line: &str, aliases: &[String]) -> bool { + if line.contains("Command::new(") { + return true; + } + aliases + .iter() + .any(|alias| line.contains(&format!("{alias}::new("))) +}