Files
Unlucky-Launcher/crates/core/src/game/java/manual.rs
T
AskaEth 40a296471d 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)
  都实测变红并已还原。
2026-11-09 15:07:56 +08:00

325 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 用户手动指定的 JDK 路径(M175):校验、厂商识别、名单持久化。
//!
//! 场景:MSI 装的 JDK 落在 `%ProgramFiles%` 之外的盘符上、或者用户自己解压的一份 JDK ——
//! [`super::discover`] 的标准位置扫描看不见它们,用户只能在设置页里**手动指一个路径**。
//!
//! # 三条口径
//!
//! 1. **收目录也收可执行文件**:两样都是用户会粘的东西,统一折成 `bin/java[.exe]`;
//! 2. **校验就是真跑一次 `java -version`**:目录存在、里面有 `bin/java` 都不算数——
//! 一份放错的 `java`(空文件、别的平台的可执行文件)必须在这里被拒,而不是等启动时崩;
//! 3. **这份名单是 UI 的持久化,不是启动策略**:真正「用哪个 Java 启动」仍由
//! `java.selected_path`(core 的 schema 键)决定,名单只是「我手动加过哪些」的记忆。
//!
//! 名单落一个纯文本文件(一行一个路径):它是 UI 侧的登记表,不参与 schema 校验,
//! 也不该借道 `launcher.json`(那条路要新键 ⇒ 动 schema 版本与迁移,代价远大于收益)。
use std::path::{Path, PathBuf};
use super::extract::java_binary_name;
use super::probe::{self, JavaOrigin, JavaRuntime};
/// 用户可粘路径的长度上限(防手滑粘进来一整段日志)。
pub const MAX_PATH_CHARS: usize = 512;
/// 手动路径被拒的原因(稳定 id 给 UI 选文案;不做本地化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManualFailure {
/// 空串 / 全空白
Empty,
/// 超过 [`MAX_PATH_CHARS`]
TooLong,
/// 盘上没这个文件或目录
Missing,
/// 有这个东西,但里面没有 `bin/java[.exe]`(或给的不是一个 java 可执行文件)
NotJava,
/// 有 `java`,但 `java -version` 跑不起来 / 读不出版本号
NoVersion,
}
impl ManualFailure {
/// 稳定 id(UI 文案 key 的后缀,见 `pages/settings/java`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Empty => "empty",
Self::TooLong => "too-long",
Self::Missing => "missing",
Self::NotJava => "not-java",
Self::NoVersion => "no-version",
}
}
}
/// 用户给的路径 → `java` 可执行文件(不探测,只看文件系统)。
///
/// # Errors
/// 空 / 太长 / 不存在 / 不是 java 可执行文件时返回原因。
pub fn resolve_binary(input: &str) -> Result<PathBuf, ManualFailure> {
let text = input.trim();
if text.is_empty() {
return Err(ManualFailure::Empty);
}
if text.chars().count() > MAX_PATH_CHARS {
return Err(ManualFailure::TooLong);
}
let path = Path::new(text);
if path.is_file() {
return looks_like_java(path)
.then(|| path.to_path_buf())
.ok_or(ManualFailure::NotJava);
}
if path.is_dir() {
let binary = path.join("bin").join(java_binary_name());
return binary
.is_file()
.then_some(binary)
.ok_or(ManualFailure::NotJava);
}
Err(ManualFailure::Missing)
}
/// 文件名是不是 `java` / `javaw`(Windows 带 `.exe`;POSIX 不区分大小写属于宽容)。
fn looks_like_java(path: &Path) -> bool {
path.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw"))
}
/// 跑一次 `java -version`,同时把**原始输出**交回来(厂商要从整段输出里认,不能只看首行)。
///
/// 为什么需要原始输出:`-version` 的第一行只有 `openjdk version "21.0.11"`,发行方印在**第二行**
/// (`OpenJDK Runtime Environment Microsoft-11230047` / `… Temurin-21.0.4+7`)。只拿首行去认厂商,
/// Microsoft / Temurin 的 JDK 会一律显示成 `OpenJDK`——M175 出图时实测到了这个错。
#[must_use]
pub fn probe_with_output(binary: &Path, origin: JavaOrigin) -> (JavaRuntime, String) {
probe_with_timeout(binary, origin, super::proc::PROBE_TIMEOUT, &|| false)
}
/// 同 [`probe_with_output`],但带**超时与取消**(扫描线程用这一条)。
#[must_use]
pub fn probe_with_timeout(
binary: &Path,
origin: JavaOrigin,
timeout: std::time::Duration,
cancelled: &dyn Fn() -> bool,
) -> (JavaRuntime, String) {
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
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());
};
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stdout));
if let Some((line, major)) = probe::parse_version_output(&text) {
runtime.major = Some(major);
runtime.version = Some(line);
runtime.bits = probe::bits_of(&text);
}
runtime.vendor = vendor_of(&text);
(runtime, text)
}
/// 严格探测:必须真跑得起来 `-version`。
///
/// 与 [`probe::probe`] 的差别只有一个:后者「探测不出也返回一个 runtime」,这里探测不出就是**拒绝**。
/// 手动添加这条路上,一个答不出话的 `java` 就是无效输入,不能进名单。
///
/// # Errors
/// 校验不过([`resolve_binary`])或 `-version` 读不出版本号时返回原因。
pub fn probe_manual(input: &str) -> Result<JavaRuntime, ManualFailure> {
let binary = resolve_binary(input)?;
let (runtime, _output) = probe_with_output(&binary, JavaOrigin::System);
if runtime.major.is_none() {
return Err(ManualFailure::NoVersion);
}
Ok(runtime)
}
/// `java -version` 输出里的发行方(认不出返回 `None`,不猜)。
///
/// 为什么从输出里认而不是从安装目录名认:用户手动指的路径跟目录命名毫无关系,
/// 而 `java -version` 的首行/次行本来就把发行方印出来了
/// (`OpenJDK Runtime Environment Temurin-21.0.4+7`、`Java(TM) SE Runtime Environment`)。
#[must_use]
pub fn vendor_of(text: &str) -> Option<String> {
let lowered = text.to_ascii_lowercase();
// 顺序即判据:「Temurin 构建的 OpenJDK」也能匹配到 openjdk,所以具体品牌必须排在前面。
const MARKERS: &[(&str, &str)] = &[
("temurin", "Temurin"),
("adoptium", "Temurin"),
("microsoft", "Microsoft"),
("zulu", "Zulu"),
("azul", "Zulu"),
("corretto", "Corretto"),
("semeru", "Semeru"),
("graalvm", "GraalVM"),
("sapmachine", "SapMachine"),
("oracle", "Oracle"),
// 老 Oracle / HotSpot 的 `-version` 不提 "Oracle"(Java 8 就是 `Java(TM) SE Runtime
// Environment` + `Java HotSpot(TM) 64-Bit Server VM`),这两条是那一代的唯一指纹。
("se runtime", "Oracle"),
("hotspot", "Oracle"),
("openjdk", "OpenJDK"),
];
MARKERS
.iter()
.find(|(needle, _)| lowered.contains(needle))
.map(|(_, display)| (*display).to_string())
}
/// 读名单(文件不存在 / 读不动 ⇒ 空名单,不报错)。
///
/// 去掉空行、去重(按 [`probe::discover::path_key`](super::discover::path_key) 的同一套键),
/// 顺序保持文件里的顺序(用户添加的先后)。
#[must_use]
pub fn load_paths(file: &Path) -> Vec<PathBuf> {
let Ok(text) = std::fs::read_to_string(file) else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let path = PathBuf::from(line);
if !paths
.iter()
.any(|known| super::discover::path_key(known) == super::discover::path_key(&path))
{
paths.push(path);
}
}
paths
}
/// 写名单(一行一个,末尾换行;父目录不存在就建)。
///
/// # Errors
/// 目录建不出来或写盘失败时返回 IO 错误。
pub fn save_paths(file: &Path, paths: &[PathBuf]) -> std::io::Result<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut text = String::new();
for path in paths {
text.push_str(&path.display().to_string());
text.push('\n');
}
std::fs::write(file, text)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-manual-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("临时目录");
dir
}
#[test]
fn recognises_vendors_from_real_version_lines() {
assert_eq!(
vendor_of("OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)"),
Some("Temurin".to_string())
);
assert_eq!(
vendor_of("OpenJDK 64-Bit Server VM Microsoft-8321 (build 17.0.13+11)"),
Some("Microsoft".to_string())
);
assert_eq!(
vendor_of("openjdk version \"17.0.9\" 2023-10-17\nOpenJDK Runtime Environment Zulu17.46+19-CA"),
Some("Zulu".to_string())
);
assert_eq!(
vendor_of(
"java version \"1.8.0_402\"\nJava(TM) SE Runtime Environment (build 1.8.0_402-b06)"
),
Some("Oracle".to_string())
);
assert_eq!(vendor_of("nothing useful here"), None);
}
#[test]
fn accepts_a_directory_and_a_binary_but_rejects_anything_else() {
let dir = temp("resolve");
let jdk = dir.join("jdk-21");
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
let binary = jdk.join("bin").join(java_binary_name());
std::fs::write(&binary, b"").expect("写 java");
assert_eq!(
resolve_binary(&jdk.display().to_string()),
Ok(binary.clone())
);
assert_eq!(resolve_binary(&binary.display().to_string()), Ok(binary));
assert_eq!(resolve_binary(" "), Err(ManualFailure::Empty));
assert_eq!(
resolve_binary(&dir.join("nope").display().to_string()),
Err(ManualFailure::Missing)
);
let empty = dir.join("empty");
std::fs::create_dir_all(&empty).expect("empty");
assert_eq!(
resolve_binary(&empty.display().to_string()),
Err(ManualFailure::NotJava)
);
// 目录里有一个**不叫 java 的**可执行文件:也不算。
let text = dir.join("notes.txt");
std::fs::write(&text, b"hi").expect("写文本");
assert_eq!(
resolve_binary(&text.display().to_string()),
Err(ManualFailure::NotJava)
);
let long = "a".repeat(MAX_PATH_CHARS + 1);
assert_eq!(resolve_binary(&long), Err(ManualFailure::TooLong));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_binary_that_cannot_answer_version_is_refused() {
let dir = temp("noversion");
let jdk = dir.join("jdk");
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
// 空文件:解析不出版本号 ⇒ 拒绝(这条正是「文件存在 ≠ 能用」的判据)。
std::fs::write(jdk.join("bin").join(java_binary_name()), b"").expect("写 java");
assert_eq!(
probe_manual(&jdk.display().to_string()).err(),
Some(ManualFailure::NoVersion)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_list_round_trips_and_dedupes() {
let dir = temp("list");
let file = dir.join("config").join("java-extra-paths.txt");
let paths = vec![
PathBuf::from("C:/jdk/17"),
PathBuf::from("C:/jdk/21"),
PathBuf::from("C:\\jdk\\17"),
];
save_paths(&file, &paths).expect("写名单");
// 第三条是第一条的另一种写法:读回来只留一条(与扫描去重同一套键)。
assert_eq!(load_paths(&file).len(), 2);
assert_eq!(load_paths(&file)[0], PathBuf::from("C:/jdk/17"));
let missing = dir.join("nope.txt");
assert!(load_paths(&missing).is_empty(), "缺文件是空名单,不是错误");
std::fs::remove_dir_all(&dir).ok();
}
}