fix(core): 所有子进程统一走 platform::child::quiet + 角色守卫(M213)

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)
  都实测变红并已还原。
This commit is contained in:
AskaEth committed 2026-11-09 15:07:56 +08:00
1 parent bb8be87c8f
commit 40a296471d
17 files changed
+417 -39

No files matched your search

+55
View File
@@ -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`:子进程不获得控制台窗口。
///
/// 见 <https://learn.microsoft.com/windows/win32/procthread/process-creation-flags>。
/// 别和 `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<S: AsRef<OsStr>>(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
}
+1 -2
View File
@@ -216,8 +216,7 @@ pub fn format_version(version: (u32, u32)) -> String {
#[cfg(target_os = "linux")]
fn run(program: &str, args: &[&str]) -> Option<String> {
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;
}
+1 -2
View File
@@ -91,8 +91,7 @@ pub fn parse_sysctl_bool(text: &str) -> bool {
#[cfg(target_os = "macos")]
fn run(program: &str, args: &[&str]) -> Option<String> {
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;
}
+4
View File
@@ -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;
+1 -2
View File
@@ -173,8 +173,7 @@ fn parse_dword(raw: &str) -> Option<u32> {
/// 跑一个外部命令,取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
fn run(program: &str, args: &[&str]) -> Option<String> {
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;
}
+1 -2
View File
@@ -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<String> {
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;
}
+2 -4
View File
@@ -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<String> {
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 会把
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
+1 -2
View File
@@ -206,8 +206,7 @@ fn parse_version_pair(value: &str) -> Option<(u32, u32)> {
#[cfg(target_os = "windows")]
fn run(program: &str, args: &[&str]) -> Option<String> {
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;
}