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();
}
}