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

@@ -0,0 +1,242 @@
//! 「Java」分组的真数据源断言(M175):扫描 / 手动路径 / 内存 / JVM 参数 / 真命令行。
//!
//! 每一条都**能变红**(报告的「变异证明」逐条给了改哪一行会红)。四条守卫:
//!
//! | 守卫 | 断言 | 改回什么会红 |
//! |---|---|---|
//! | 列表来自真扫盘 | [`the_runtime_list_comes_from_the_disk`] | 把行换成写死的字符串 / 不读候选表 |
//! | 非法路径不污染设置 | [`an_invalid_manual_path_is_refused_and_writes_nothing`] | 删掉 `manual::probe_manual` 的校验 |
//! | 内存越界被拒 | [`the_memory_bounds_are_enforced_before_writing`] | 删掉范围判断 / 允许非数字 |
//! | 内存真进命令行 | [`the_saved_memory_reaches_the_real_command_line`] | 把 `-Xmx` 的来源改回常量 |
//!
//! 前三条不依赖本机装没装 JDK(假的 `bin/java` 空文件就够:它扫得到、但答不出版本),
//! 第四条(`tests/cmdline.rs`)走 core 的**真组装链**(`launch::args_table::assemble`)——
//! 不是读配置回显。落盘 / 读回 / 版本门槛 / 截图种子也在那个文件里(同一族「真值」断言)。
#[path = "tests/cmdline.rs"]
mod cmdline;
use std::path::{Path, PathBuf};
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::store;
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
pub(super) fn root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-page-{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.join("config")).expect("config");
dir
}
pub(super) fn config(dir: &Path) -> PathBuf {
dir.join("config").join("launcher.json")
}
/// 造一个「看起来像 JDK」的目录:`bin/java` 是空文件(扫得到、但 `-version` 答不出)。
pub(super) fn fake_jdk(dir: &Path, name: &str) -> PathBuf {
let jdk = dir.join(name);
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
std::fs::write(jdk.join("bin").join(java_bin()), b"").expect("写 java");
jdk
}
pub(super) fn java_bin() -> &'static str {
if cfg!(windows) {
"java.exe"
} else {
"java"
}
}
// --------------------------------------------------------------- 守卫①:列表来自真扫盘
/// 扫描列表必须来自**盘上真有的目录**:造两个 → 两项;删一个 → 一项。
///
/// 变异证明:把 `store::start` 里的候选表换成一个写死的两行常量,删目录之后仍是两项 ⇒ 红。
#[test]
fn the_runtime_list_comes_from_the_disk() {
let dir = root("scan");
let one = fake_jdk(&dir, "jdk-a");
let two = fake_jdk(&dir, "jdk-b");
let found =
unlucky_core::game::java::staged::scan_roots(&[], &[one.clone(), two.clone()], false);
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
std::fs::remove_dir_all(&one).expect("删 jdk-a");
let after = unlucky_core::game::java::staged::scan_roots(&[], &[one, two], false);
assert_eq!(after.len(), 1, "删掉一个之后必须少一项:{after:?}");
std::fs::remove_dir_all(&dir).ok();
}
/// 扫描结论灌进页面状态之后,**行数与路径**都来自那次扫描(不是常量)。
#[test]
fn applying_a_scan_report_replaces_every_row() {
let mut state = State::default();
let report = store::ScanReport {
running: false,
done: 2,
total: 2,
finished: true,
cancelled: false,
runtimes: vec![
RuntimeRow {
binary: "C:/jdk-a/bin/java.exe".to_string(),
version: String::new(),
major: None,
vendor: None,
source: "manual",
},
RuntimeRow {
binary: "C:/jdk-b/bin/java.exe".to_string(),
version: String::new(),
major: Some(21),
vendor: Some("Temurin".to_string()),
source: "system",
},
],
};
assert!(state.apply_scan(&report), "第一次灌结论必须报告「变了」");
assert_eq!(state.runtimes.len(), 2);
assert!(state.scanned, "扫完之后「未检测到」与「还没扫」必须能分开");
assert_eq!(state.runtimes[1].summary(), "Java 21 · Temurin");
}
// --------------------------------------------------------------- 守卫②:非法路径不污染设置
/// 非法路径被拒、**设置与名单一个字节都不写**。
///
/// 变异证明:删掉 `store::add_manual` 里的 `manual::probe_manual`(改成只看 `is_dir()`),
/// 「不存在的路径」就会被加进名单 ⇒ 红。
#[test]
fn an_invalid_manual_path_is_refused_and_writes_nothing() {
let dir = root("manual");
let file = config(&dir);
assert_eq!(
store::add_manual(&file, "").err(),
Some(Refusal::AddEmpty),
"空输入要被拒"
);
assert_eq!(
store::add_manual(&file, "Z:/definitely/not/here").err(),
Some(Refusal::AddMissing),
"不存在的路径要被拒"
);
let empty = dir.join("empty-jdk");
std::fs::create_dir_all(&empty).expect("空目录");
assert_eq!(
store::add_manual(&file, &empty.display().to_string()).err(),
Some(Refusal::AddNotJava),
"目录里没有 bin/java 要被拒"
);
let broken = fake_jdk(&dir, "broken-jdk");
assert_eq!(
store::add_manual(&file, &broken.display().to_string()).err(),
Some(Refusal::AddNoVersion),
"跑不出 java -version 要被拒"
);
assert!(
store::load_manual(&file).is_empty(),
"被拒的路径不许进名单:{:?}",
store::load_manual(&file)
);
let snapshot = store::load(&file);
assert!(
snapshot.selected.is_empty(),
"被拒的路径不许污染 java.selected_path:{snapshot:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 守卫③:内存越界被拒
/// 内存必须落在 schema 登记的区间里,非数字也要拒,而且**不写盘**。
///
/// 变异证明:删掉 `State::validate` 里的范围判断(只留 `parse`),128 与 999999 会落盘 ⇒ 红;
/// 把 `parse::<u32>` 换成 `unwrap_or(2048)`,`"abc"` 不再报错 ⇒ 红。
#[test]
fn the_memory_bounds_are_enforced_before_writing() {
let mut state = State {
storage: true,
..State::default()
};
state.memory = (MIN_MEMORY_MB - 1).to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryRange),
"低于下限"
);
state.memory = (MAX_MEMORY_MB + 1).to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryRange),
"高于上限"
);
state.memory = "abc".to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::MemoryNotNumber),
"非数字"
);
state.memory = " ".to_string();
assert_eq!(state.validate().err(), Some(Refusal::MemoryEmpty), "空");
state.memory = "4096".to_string();
assert!(state.validate().is_ok());
state.jvm = "-XX:+UseG1GC".to_string();
assert!(state.validate().is_ok());
state.jvm = "-Xss\"512k".to_string();
assert_eq!(
state.validate().err(),
Some(Refusal::JvmQuotes),
"引号没配对"
);
// 真的不写盘:一次越界保存之后,文件里还是出厂默认值。
let dir = root("memory");
let file = config(&dir);
let state = State {
storage: true,
memory: "128".to_string(),
..State::default()
};
assert!(store::save(&file, &state).is_err(), "越界必须写不进去");
assert_eq!(
store::load(&file).memory_mb,
super::state::DEFAULT_MEMORY_MB,
"被拒的保存不许落盘"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 两个数字常量必须与 schema 登记的一致(否则 UI 放行的值会被 schema 拒)。
///
/// 变异证明:把 `MIN_MEMORY_MB` 改成 256(core 的 `launch_overrides` 那一档),这条立刻红。
#[test]
fn the_memory_bounds_mirror_the_schema() {
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::ValueKind;
let schema = launcher_schema();
let kind = schema
.spec("java.default_memory_mb")
.map(|spec| &spec.kind)
.expect("schema 里必须有 java.default_memory_mb");
let ValueKind::Int { min, max } = kind else {
panic!("java.default_memory_mb 应该是 Int:{kind:?}");
};
assert_eq!(*min, i64::from(MIN_MEMORY_MB));
assert_eq!(*max, i64::from(MAX_MEMORY_MB));
}
#[path = "tests/runtime.rs"]
mod runtime;