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

@@ -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)
+3 -3
View File
@@ -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<String> {
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<String> {
/// 系统的 locale 名(`HKCU\Control Panel\International` 的 `LocaleName`)。
#[must_use]
pub fn windows_locale() -> Option<String> {
let output = Command::new("reg")
let output = quiet("reg")
.args([
"query",
"HKCU\\Control Panel\\International",
+2 -2
View File
@@ -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<PathBuf> {
}
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()
+1 -1
View File
@@ -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());
+2 -2
View File
@@ -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();
+4 -4
View File
@@ -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
}
+2 -2
View File
@@ -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);
+6 -9
View File
@@ -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<u32> {
#[cfg(all(unix, not(target_os = "linux")))]
fn pgid_of(pid: u32) -> Option<u32> {
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<u32> {
#[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<bool> {
match Command::new("kill")
match quiet("kill")
.arg(flag)
.args(args)
.stdout(std::process::Stdio::null())
+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;
}