Files
Unlucky-Launcher/crates/core/src/game/java/discover.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

271 lines
9.6 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.
//! 标准安装位置扫描 + 全部发现源的合并去重(`java list` 的「系统发现」半边)。
//!
//! 只认 `JAVA_HOME`/`PATH` 会漏掉最常见的一类用户:**用 MSI 装的 Temurin / Microsoft OpenJDK
//! 不设环境变量**(安装器只登记开始菜单)。HMCL 的 `JavaManager.searchAllJavaInRepository`
//! 与 PCL 的 `JavaUtils.SearchFolders` 都扫这些位置,本模块照同一思路:
//!
//! | 平台 | 扫描位置 |
//! | --- | --- |
//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
//!
//! 三个纪律:
//!
//! 1. **目录不存在是常态**(本机实测 `%APPDATA%\.minecraft\runtime` 与 `JavaSoft\JDK` 都没有),
//! 一律静默跳过,绝不报错;
//! 2. **同一路径多来源命中只列一次**,保留优先级高者(`JAVA_HOME` 指向 Program Files 里的
//! JDK 时,用户看到的是 `JAVA_HOME` 而不是 `system`);
//! 3. **扫描只在需要时才做**:`java list` 全扫;启动路径上先看托管+环境,不满足才扫盘
//! (别为了一次启动白跑 N 次 `java -version`)。
use std::path::{Path, PathBuf};
use std::process::Command;
use super::extract::java_binary_name;
use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
#[must_use]
pub fn candidates(managed_root: &Path) -> Vec<JavaRuntime> {
candidates_in_roots(std::slice::from_ref(&managed_root.to_path_buf()))
}
/// 同上,但托管区可以是**多个安装根**(Portable 数据根 + Standard/Custom 落点,D23)。
#[must_use]
pub fn candidates_in_roots(managed_roots: &[PathBuf]) -> Vec<JavaRuntime> {
let mut all = discover_managed_roots(managed_roots);
all.extend(system_candidates());
all.extend(standard_candidates());
dedupe(all)
}
/// 标准安装位置扫出来的运行时(逐个探测)。
#[must_use]
pub fn standard_candidates() -> Vec<JavaRuntime> {
scan_java_binaries()
.into_iter()
.map(|binary| probe::probe(&binary, JavaOrigin::System))
.collect()
}
/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
#[must_use]
pub fn scan_java_binaries() -> Vec<PathBuf> {
let mut found: Vec<PathBuf> = Vec::new();
for root in standard_roots() {
let binary = root.join("bin").join(java_binary_name());
if binary.is_file() {
found.push(binary);
}
}
for home in registry_homes() {
let binary = home.join("bin").join(java_binary_name());
if binary.is_file() {
found.push(binary);
}
}
found.sort();
found.dedup();
found
}
/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
#[must_use]
pub fn standard_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if cfg!(windows) {
for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
let Some(base) = std::env::var_os(variable) else {
continue;
};
let base = PathBuf::from(base);
for vendor in WINDOWS_VENDOR_DIRS {
roots.extend(child_dirs(&base.join(vendor)));
}
}
if let Some(appdata) = std::env::var_os("APPDATA") {
roots.extend(child_dirs(
&PathBuf::from(appdata).join(".minecraft").join("runtime"),
));
}
} else if cfg!(target_os = "macos") {
for entry in child_dirs(Path::new("/Library/Java/JavaVirtualMachines")) {
// `*.jdk/Contents/Home`(`java_home` 语义)。
let home = entry.join("Contents").join("Home");
roots.push(if home.is_dir() { home } else { entry });
}
} else {
for base in ["/usr/lib/jvm", "/usr/java"] {
roots.extend(child_dirs(Path::new(base)));
}
}
roots.sort();
roots.dedup();
roots
}
/// Windows 各发行版的安装父目录(每种下面按子目录名当版本)。
const WINDOWS_VENDOR_DIRS: &[&str] = &[
"Java",
"Eclipse Adoptium",
"Microsoft",
"Zulu",
"Amazon Corretto",
"BellSoft",
"Semeru",
];
/// 列一个目录的直接子目录(不存在/无权限一律空)。
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| entry.path())
.collect()
}
/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
///
/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
/// (` JavaHome REG_SZ C:\Program Files\Java\jdk-17`)。
#[must_use]
pub fn registry_homes() -> Vec<PathBuf> {
if !cfg!(windows) {
return Vec::new();
}
let mut homes = Vec::new();
for view in ["64", "32"] {
let Ok(output) = Command::new("reg")
.args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
.arg(view)
.output()
else {
continue;
};
if !output.status.success() {
continue;
}
homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
}
homes.sort();
homes.dedup();
homes
}
/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
#[must_use]
pub fn parse_java_home(output: &str) -> Vec<PathBuf> {
output
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("JavaHome")?;
let (_, value) = rest.split_once("REG_SZ")?;
let home = value.trim();
(!home.is_empty()).then(|| PathBuf::from(home))
})
.collect()
}
/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
#[must_use]
pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
let mut seen: Vec<String> = Vec::with_capacity(runtimes.len());
let mut kept = Vec::with_capacity(runtimes.len());
for runtime in runtimes {
let key = path_key(&runtime.binary);
if seen.contains(&key) {
continue;
}
seen.push(key);
kept.push(runtime);
}
kept
}
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
#[must_use]
pub fn path_key(path: &Path) -> String {
let text = path.display().to_string();
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
if cfg!(windows) {
text.replace('/', "\\").to_ascii_lowercase()
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::{dedupe, parse_java_home, path_key, standard_roots};
use crate::game::java::probe::{JavaOrigin, JavaRuntime};
fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: std::path::PathBuf::from(path),
origin,
version: None,
major: Some(21),
bits: 64,
vendor: None,
}
}
#[test]
fn dedupe_keeps_the_higher_priority_source() {
let list = vec![
runtime(
"C:/Program Files/Java/jdk-21/bin/java.exe",
JavaOrigin::JavaHome,
),
runtime(
"C:\\Program Files\\Java\\jdk-21\\bin\\java.exe",
JavaOrigin::System,
),
runtime(
"C:/Program Files/Zulu/zulu-17/bin/java.exe",
JavaOrigin::System,
),
];
let kept = dedupe(list);
assert_eq!(kept.len(), 2);
assert_eq!(kept[0].origin, JavaOrigin::JavaHome, "重复路径保留高优先级");
}
#[test]
fn path_key_normalises_separators() {
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
assert_eq!(left, right);
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
}
#[test]
fn parses_real_reg_query_output() {
// 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n CurrentVersion REG_SZ 1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n JavaHome REG_SZ C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
let homes = parse_java_home(output);
assert_eq!(
homes,
vec![std::path::PathBuf::from(
r"C:\Program Files\Java\jdk1.8.0_402"
)]
);
}
#[test]
fn missing_registry_key_is_not_an_error() {
assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
}
#[test]
fn standard_roots_never_panic_on_missing_directories() {
// 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
let roots = standard_roots();
assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
}
}