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:
1 parent
30b61e2684
commit
df331f6a60
26 files changed
+4030
-85
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user