feat(m175): Java 设置页接真运行时(扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数)

用户实测四条抱怨逐条对应:
- 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 +
  注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消
- 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘
- 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回
- 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version),
  设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否)

core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与
取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。
i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。

复审 r1(p2-5items)全修:
- P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动
- P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key),
  不再把 Rust 枚举名当人话
- P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在)
- P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串
- P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间
  不许起第二个(界面显示「正在取消…」)

复审 r2(p2-2items)全修:
- P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口
- P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫,
  配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响
- 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、
  core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、
  默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息)
This commit is contained in:
AskaEth committed 2026-11-08 19:52:04 +08:00
1 parent 30b61e2684
commit df331f6a60
26 files changed
+4030 -85

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
//! 用户手动指定的 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 = std::process::Command::new(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();
}
}
+3
View File
@@ -45,14 +45,17 @@ pub mod error;
pub mod extract;
pub mod install;
pub mod location;
pub mod manual;
pub mod mapping;
pub mod microsoft;
pub mod net;
pub mod oracle;
pub mod probe;
pub mod proc;
pub mod release;
pub mod select;
pub mod source;
pub mod staged;
pub mod target;
pub mod termux;
pub mod vendor;
+169
View File
@@ -0,0 +1,169 @@
//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。
//!
//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不
//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。
use std::time::Duration;
/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。
const POLL_INTERVAL: Duration = Duration::from_millis(20);
/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。
///
/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。
///
/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死,
/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。
#[must_use]
pub fn run_with_timeout(
mut command: std::process::Command,
timeout: Duration,
cancelled: &dyn Fn() -> bool,
) -> Option<std::process::Output> {
use std::process::Stdio;
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().ok()?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if cancelled() || started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
child.wait_with_output().ok()
}
#[cfg(test)]
mod tests {
use super::run_with_timeout;
#[test]
fn a_hung_probe_is_killed_after_the_timeout() {
// 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。
let started = std::time::Instant::now();
let output = run_with_timeout(
sleeping_command(30),
std::time::Duration::from_millis(400),
&|| false,
);
assert!(output.is_none(), "超时必须判成「没有结论」");
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}"
);
}
#[test]
fn a_cancel_interrupts_the_probe_that_is_already_running() {
// 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let tripper = flag.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
tripper.store(true, std::sync::atomic::Ordering::SeqCst);
});
let started = std::time::Instant::now();
let output = run_with_timeout(
sleeping_command(30),
std::time::Duration::from_secs(30),
&|| flag.load(std::sync::atomic::Ordering::SeqCst),
);
assert!(output.is_none(), "被取消的探测没有结论");
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"取消必须打断正在跑的那个探测,而不是等它自己结束"
);
}
/// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。
///
/// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()`
/// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。
///
/// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把
/// 标记文件写出来 ⇒ 本条红。
#[test]
fn a_timed_out_child_is_really_killed() {
let dir = std::env::temp_dir().join(format!(
"ull-proc-kill-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("临时目录");
let marker = dir.join("survived.txt");
let _ = std::fs::remove_file(&marker);
let output = run_with_timeout(
marker_command(&marker, 4),
std::time::Duration::from_millis(300),
&|| false,
);
assert!(output.is_none(), "超时没有结论");
// 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。
std::thread::sleep(std::time::Duration::from_secs(6));
assert!(
!marker.exists(),
"子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。
fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command {
let target = marker.display().to_string();
if cfg!(windows) {
// PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的
// 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用
// PowerShell 的单引号,外层只剩 Rust 加的那一层。
let mut command = std::process::Command::new("powershell");
command.args([
"-NoProfile",
"-Command",
&format!(
"Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive"
),
]);
command
} else {
let mut command = std::process::Command::new("sh");
command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]);
command
}
}
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
fn sleeping_command(seconds: u32) -> std::process::Command {
if cfg!(windows) {
let mut command = std::process::Command::new("cmd");
command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]);
command
} else {
let mut command = std::process::Command::new("sleep");
command.arg(seconds.to_string());
command
}
}
}
+347
View File
@@ -0,0 +1,347 @@
//! 带进度与取消的 Java 扫描(M175):**先收集候选、再逐个探测**。
//!
//! 为什么不能直接调 [`super::discover::candidates_in_roots`]:那条路把「收集」与「探测」揉在一起,
//! 一个 JDK 跑一次 `java -version`(几十到几百毫秒),一台装了七八个 JDK 的机器上整段要几秒。
//! 设置页要的是**能显示 3/7、能中途取消、且不卡住 UI**——于是这里把两段拆开:
//!
//! ```text
//! candidates(roots, manual, system) 纯文件系统,快、无子进程
//! → scan(&candidates, on_progress, cancelled) 逐个探测;任一回调说停就停
//! ```
//!
//! 判据与 [`super::discover`] 一致(同一套去重键、同一套标准位置),差别只有「可观测」这一层——
//! 所以 `ull java list` 与设置页看到的是同一批 JDK。
use std::path::PathBuf;
use super::discover::{self, path_key};
use super::extract::{java_binary_name, read_stamp};
use super::probe::{self, JavaOrigin, JavaRuntime};
use super::vendor::JdkVendor;
/// 候选来源(比 [`JavaOrigin`] 多一个「用户手动加的」,UI 也按它分组)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
/// 托管目录(`ull java install` 装的)
Managed,
/// `JAVA_HOME`
JavaHome,
/// `PATH` 上的 `java`
Path,
/// 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表 / `/usr/lib/jvm`)
System,
/// 用户在设置页手动指的路径
Manual,
}
impl Source {
/// 稳定 id(DTO / UI 文案选档用)。
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Managed => "managed",
Self::JavaHome => "env",
Self::Path => "path",
Self::System => "system",
Self::Manual => "manual",
}
}
/// 展示优先级(小者先;与 [`JavaOrigin::rank`] 同序,手动路径排在系统安装之前——
/// 用户亲手指的是明确的意图)。
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Managed => 1,
Self::JavaHome => 2,
Self::Path => 3,
Self::Manual => 4,
Self::System => 5,
}
}
/// 探测时用的 [`JavaOrigin`](`JavaOrigin` 没有「手动」这一档,用 `System` 承接下来即可:
/// 展示走本枚举的 [`Source::id`],不看 `JavaRuntime.origin`)。
#[must_use]
pub const fn origin(self) -> JavaOrigin {
match self {
Self::Managed => JavaOrigin::Managed,
Self::JavaHome => JavaOrigin::JavaHome,
Self::Path => JavaOrigin::Path,
Self::System | Self::Manual => JavaOrigin::System,
}
}
}
/// 一个待探测的候选。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
/// `bin/java[.exe]` 的绝对路径
pub binary: PathBuf,
/// 来源
pub source: Source,
}
/// 收集候选(**不探测**,所以很快;目录不存在一律跳过)。
///
/// `system = false` 用于测试与「只看托管区」的场景:它跳过 `JAVA_HOME`/`PATH`/标准位置,
/// 于是同一份输入在任何机器上得到同一份候选表(不依赖本机装了什么)。
#[must_use]
pub fn candidates(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
for root in managed_roots {
out.extend(managed_in(root));
}
// 手动路径:目录 → `bin/java`;文件原样(用户在输入框里粘过两种形态,都认)。
for path in manual {
let binary = if path.is_dir() {
path.join("bin").join(java_binary_name())
} else {
path.clone()
};
if binary.is_file() {
out.push(Candidate {
binary,
source: Source::Manual,
});
}
}
if system {
out.extend(env_candidates());
out.extend(
discover::scan_java_binaries()
.into_iter()
.map(|binary| Candidate {
binary,
source: Source::System,
}),
);
}
dedupe(out)
}
/// 托管安装根里的候选(两种布局都认,**必须有戳记**——没戳记的目录可能是用户塞的别的东西)。
fn managed_in(root: &std::path::Path) -> Vec<Candidate> {
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(root) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !path.is_dir() || name.starts_with('.') {
continue;
}
if let Some(binary) = stamped_binary(&path) {
found.push(Candidate {
binary,
source: Source::Managed,
});
continue;
}
// 两层布局 `<vendor>/<version>`:厂商名取自父目录名。
if JdkVendor::parse(&name).is_some() {
let Ok(children) = std::fs::read_dir(&path) else {
continue;
};
for child in children.flatten() {
if let Some(binary) = stamped_binary(&child.path()) {
found.push(Candidate {
binary,
source: Source::Managed,
});
}
}
}
}
found
}
/// 带戳记、且 `bin/java[.exe]` 在盘上的托管目录。
fn stamped_binary(dir: &std::path::Path) -> Option<PathBuf> {
if !dir.is_dir() || read_stamp(dir).is_none() {
return None;
}
let binary = dir.join("bin").join(java_binary_name());
binary.is_file().then_some(binary)
}
/// `JAVA_HOME` 与 `PATH`(与 [`discover::candidates_in_roots`] 同一口径)。
fn env_candidates() -> Vec<Candidate> {
let mut found = Vec::new();
if let Some(home) = std::env::var_os("JAVA_HOME") {
let binary = PathBuf::from(home).join("bin").join(java_binary_name());
if binary.is_file() {
found.push(Candidate {
binary,
source: Source::JavaHome,
});
}
}
if let Some(binary) = probe::resolve_on_path(java_binary_name()) {
found.push(Candidate {
binary,
source: Source::Path,
});
}
found
}
/// 按路径去重,保留**先出现**的(调用方按优先级排好序)。
fn dedupe(candidates: Vec<Candidate>) -> Vec<Candidate> {
let mut seen: Vec<String> = Vec::with_capacity(candidates.len());
let mut kept = Vec::with_capacity(candidates.len());
for candidate in candidates {
let key = path_key(&candidate.binary);
if seen.contains(&key) {
continue;
}
seen.push(key);
kept.push(candidate);
}
kept
}
/// 逐个探测。`on_progress(done, total)` 返回 `false` ⇒ 立刻停(取消),已探测的照样返回。
///
/// `cancelled()` 在一次探测**进行中**也会被查询:卡住的候选会被 [`super::proc::PROBE_TIMEOUT`]
/// 或取消打断,而不是把扫描线程钉死在一个 `-version` 上(见 [`super::proc::run_with_timeout`])。
///
/// 返回的列表按**大版本升序、同版本按路径**排序,探测不出主版本的排在最后
/// (与 [`super::probe::discover_managed`] 的排序口径一致,用户看到的第一行不该是「版本未知」)。
#[must_use]
pub fn scan<F>(
candidates: &[Candidate],
mut on_progress: F,
cancelled: &dyn Fn() -> bool,
) -> Vec<JavaRuntime>
where
F: FnMut(usize, usize) -> bool,
{
let total = candidates.len();
let mut found: Vec<JavaRuntime> = Vec::with_capacity(total);
for (index, candidate) in candidates.iter().enumerate() {
if cancelled() || !on_progress(index, total) {
break;
}
// 发行方要从**整段** `-version` 输出里认(首行只有 `openjdk version "…"`,品牌在第二行)。
// 超时与取消都交给这一层:卡住的子进程会被 kill,取消不必等它自己结束。
let (runtime, _output) = super::manual::probe_with_timeout(
&candidate.binary,
candidate.source.origin(),
super::proc::PROBE_TIMEOUT,
cancelled,
);
found.push(runtime);
}
found.sort_by(|left, right| {
left.major
.unwrap_or(u32::MAX)
.cmp(&right.major.unwrap_or(u32::MAX))
.then_with(|| left.binary.cmp(&right.binary))
});
found
}
/// 候选 + 扫描一步到位(不与 UI 打交道的调用方用;设置页走两段式)。
#[must_use]
pub fn scan_roots(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec<JavaRuntime> {
let list = candidates(managed_roots, manual, system);
scan(&list, |_, _| true, &|| false)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-staged-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("临时目录");
dir
}
/// 造一个「看起来像 JDK」的目录(`bin/java` 是空文件 ⇒ 探测出「版本未知」,
/// 但它确实被扫到了——这正是「列表来自真实扫描」要的证据)。
fn fake_jdk(root: &std::path::Path, name: &str) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("bin")).expect("bin");
std::fs::write(dir.join("bin").join(java_binary_name()), b"").expect("写 java");
dir
}
#[test]
fn the_list_comes_from_the_disk_not_from_a_constant() {
let root = temp("list");
let one = fake_jdk(&root, "jdk-a");
let two = fake_jdk(&root, "jdk-b");
let found = scan_roots(&[], &[one.clone(), two.clone()], false);
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
assert!(found
.iter()
.any(|runtime| runtime.binary == one.join("bin").join(java_binary_name())));
// 删掉一个:列表必须少一项(证明它读的是盘,不是写死的字符串)。
std::fs::remove_dir_all(&one).expect("删 jdk-a");
let after = scan_roots(&[], &[one, two], false);
assert_eq!(after.len(), 1, "删掉一个 JDK 之后列表必须少一项:{after:?}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_managed_directory_without_a_stamp_is_not_claimed() {
let root = temp("stamp");
// 长得像托管目录,但没有戳记 ⇒ 不是我们装的,不去猜。
fake_jdk(&root, "jdk-21-temurin");
let found = scan_roots(std::slice::from_ref(&root), &[], false);
assert!(found.is_empty(), "无戳记目录不该被认领:{found:?}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn progress_reports_every_candidate_and_cancel_stops_early() {
let root = temp("progress");
let paths: Vec<PathBuf> = (0..3)
.map(|n| fake_jdk(&root, &format!("jdk-{n}")))
.collect();
let list = candidates(&[], &paths, false);
assert_eq!(list.len(), 3);
let mut seen = Vec::new();
let all = scan(
&list,
|done, total| {
seen.push((done, total));
true
},
&|| false,
);
assert_eq!(seen, vec![(0, 3), (1, 3), (2, 3)], "每个候选都要报一次进度");
assert_eq!(all.len(), 3);
let stopped = scan(&list, |done, _| done < 1, &|| false);
assert_eq!(stopped.len(), 1, "取消之后只留已探测的那一项");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn candidates_dedupe_the_same_jdk_written_two_ways() {
let root = temp("dedupe");
let jdk = fake_jdk(&root, "jdk-x");
let binary = jdk.join("bin").join(java_binary_name());
// 同一份 JDK 用「目录」和「可执行文件」两种写法各登记一次。
let list = candidates(&[], &[jdk, binary], false);
assert_eq!(list.len(), 1, "同一路径只列一次:{list:?}");
std::fs::remove_dir_all(&root).ok();
}
}