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

@@ -151,6 +151,8 @@ impl App {
app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
+2
View File
@@ -87,6 +87,8 @@ impl App {
self.has_screenshot()
|| !self.state.toasts.is_empty()
|| !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self
.watch_until
.is_some_and(|deadline| Instant::now() < deadline)
+5
View File
@@ -49,6 +49,9 @@ impl App {
self.font_seen = font_status;
self.view_dirty = true;
}
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
self.hydrate_java_scan();
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -77,6 +80,8 @@ impl App {
self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
}
if self.view_dirty {
+274 -16
View File
@@ -1,33 +1,291 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//!
//! 消息进来的通道有两条,都不经过共享文件:
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
//! (`app::Message::Page` 是既有通道,不用加新变体)。
//! | 消息 | 做什么 |
//! |---|---|
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
//!
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
//! ## 为什么「设为默认」不攒到「保存」里
//!
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
//! 界面还标着旧的默认值,只会以为没生效。
//!
//! ## 版本门槛(「为什么选它」那句话的数据)
//!
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
use super::super::*;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
impl App {
/// Java 分组的一条消息。
pub(super) fn on_settings_java(
&mut self,
message: pages::settings::java::Message,
) -> Task<Message> {
use pages::settings::java::Message;
match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
#[cfg(test)]
pages::settings::java::Message::Ping => {
self.view_dirty = true;
Task::none()
Message::Scan => return self.start_java_scan(),
Message::CancelScan => {
self.settings.java.scan.cancel();
self.hydrate_java_scan();
}
Message::AddDraft(value) => {
self.settings.java.add_draft = value;
self.settings.java.notice = None;
self.view_dirty = true;
}
Message::Add => self.add_java_manual(),
Message::SelectRuntime(binary) => self.select_java_default(&binary),
Message::EditMemory(value) => {
self.settings.java.memory = value;
self.touch_java();
}
Message::EditJvm(value) => {
self.settings.java.jvm = value;
self.touch_java();
}
Message::Save => self.save_java_settings(),
}
Task::none()
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch_java(&mut self) {
self.settings.java.dirty = true;
self.settings.java.notice = None;
self.view_dirty = true;
}
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
///
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
///
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
///
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
pub(in crate::app) fn hydrate_java_settings(&mut self) {
let seeded = self.options.screenshot.is_some();
let mut live = false;
if seeded {
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
if raw.trim() == "live" {
live = true;
} else {
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
self.settings.java = state;
}
return;
}
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java = pages::settings::java::State::default();
return;
};
let snapshot = pages::settings::java::store::load(&config);
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
let changed = self.settings.java.hydrate(snapshot);
let scan_changed = self.hydrate_java_scan();
self.settings.java.context = self.java_context();
if changed || scan_changed {
self.view_dirty = true;
}
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
if !cfg!(test) {
self.auto_scan_java_if_due(seeded && !live);
}
}
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
///
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
let report = self.settings.java.scan.report();
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
let _ = self.start_java_scan();
return true;
}
false
}
/// 把后台扫描的快照灌进状态。返回是否变了。
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
let report = self.settings.java.scan.report();
let changed = self.settings.java.apply_scan(&report);
if changed {
self.view_dirty = true;
}
changed
}
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
fn start_java_scan(&mut self) -> Task<Message> {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return Task::none();
};
let manual = pages::settings::java::store::load_manual(&config)
.into_iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let roots = pages::settings::java::store::managed_roots(&config);
self.settings.java.notice = None;
if self.settings.java.scan.start(roots, manual, true) {
self.hydrate_java_scan();
}
self.view_dirty = true;
Task::none()
}
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
fn add_java_manual(&mut self) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
let draft = self.settings.java.add_draft.clone();
match pages::settings::java::store::add_manual(&config, &draft) {
Ok((binary, row)) => {
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
if !self
.settings
.java
.runtimes
.iter()
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
{
self.settings.java.runtimes.push(row);
self.settings
.java
.runtimes
.sort_by_key(|runtime| runtime.major);
}
self.settings.java.add_draft = String::new();
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
self.settings.java.scanned = true;
self.settings.java.notice = Some(Notice::Added(binary));
}
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
}
self.view_dirty = true;
}
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
fn select_java_default(&mut self, binary: &str) {
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::set_selected(&config, binary) {
Ok(snapshot) => {
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
fn save_java_settings(&mut self) {
let state = &self.settings.java;
if let Err(refusal) = state.validate() {
self.settings.java.notice = Some(Notice::Refused(refusal));
self.view_dirty = true;
return;
}
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java.notice = Some(Notice::NoStorage);
self.view_dirty = true;
return;
};
match pages::settings::java::store::save(&config, &self.settings.java) {
Ok(snapshot) => {
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
self.settings.java.dirty = false;
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::Saved);
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
self.view_dirty = true;
}
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
///
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
/// (页面就不画门槛那一行,也不假装有要求)。
fn java_context(&self) -> Context {
let tiles = self.state.tiles();
let routed = match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => {
tiles.iter().find(|cell| &cell.instance.id == id)
}
_ => None,
};
let Some(cell) = routed.or_else(|| tiles.first()) else {
return Context::default();
};
let instance = cell.instance;
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
Context {
required: Some(Requirement {
instance: instance.name.clone(),
version: instance.mc_version.clone(),
minimum: requirement.minimum,
maximum: requirement.maximum,
}),
layer: Some(match instance.launch.memory_mb {
Some(mb) => Layer::Instance {
instance: instance.name.clone(),
mb,
},
None => Layer::Global,
}),
effective_memory_mb: Some(instance.launch.memory_effective_mb),
}
}
}
/// 写盘失败 → 回执:三档都走 i18n key。
///
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
/// 界面上给一句页面自己的「写不进去」。
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
use pages::settings::java::store::WriteError;
match error {
WriteError::Refused(refusal) => Notice::Refused(refusal),
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
WriteError::Schema(detail) => {
eprintln!("[java] 设置写盘失败:{detail}");
Notice::WriteFailed
}
}
}
@@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
let mut app = headless();
app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
"4096".to_string(),
)),
)));
assert!(
@@ -269,7 +273,7 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
// --------------------------------------------------------------- M174:助手配置的活路径
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"ull-ai-app-{label}-{}-{}",
std::process::id(),
@@ -386,3 +390,7 @@ fn testing_without_configuration_does_nothing() {
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
#[path = "tests/java.rs"]
mod java;
@@ -0,0 +1,163 @@
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
//!
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
use super::bridged_app;
use crate::app::Message;
use crate::pages;
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
/// 一条 Java 分组消息 → 页面消息。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
///
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
/// 一个字节都没落盘、也没有任何提示。
///
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
/// JVM 回到空、`dirty` 变假)。
#[test]
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
let (mut app, path) = bridged_app("java-select");
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"6144".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
"C:/jdk-21/bin/java.exe".to_string(),
)));
assert_eq!(
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
"设为默认必须真的写进去"
);
assert_eq!(
app.settings.java.memory, "6144",
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert_eq!(
app.settings.java.jvm, "-XX:+UseG1GC",
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
);
assert!(
app.settings.java.dirty,
"内存 / JVM 参数还没保存,不许标成已保存"
);
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// 一个会一直卡住的扫描作业(直到被取消)。
fn held_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<pages::settings::java::RuntimeRow> {
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
Vec::new()
}
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
///
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
///
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
#[test]
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
HOLD.store(false, Ordering::SeqCst);
let (mut app, path) = bridged_app("java-cancel");
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
// cancelled=true / finished=false —— 正是复审指认的那个形状。
let slot = app.settings.java.scan.clone();
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
slot.cancel();
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!slot.report().running, "取消之后线程必须退出");
assert!(slot.report().cancelled);
assert!(!slot.report().finished);
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
app.hydrate_java_scan();
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
assert!(app.settings.java.scanning.is_none());
let started = app.auto_scan_java_if_due(false);
assert!(
!started,
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
);
assert!(
!slot.report().running,
"不许起第二个扫描线程:{:?}",
slot.report()
);
app.hydrate_java_scan();
assert!(
app.settings.java.scanning.is_none(),
"状态行不许回到「正在扫描」"
);
assert!(
app.settings.java.cancelled,
"取消标记要保持到下一次手动扫描为止"
);
HOLD.store(true, Ordering::SeqCst);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
///
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
/// `Not saved: IO 失败 @ …`。
///
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。
#[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
let (mut app, path) = bridged_app("java-schema");
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度,
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。
let too_long = "-D".to_string() + &"x".repeat(2000);
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
let _ = app.update(java(pages::settings::java::Message::Save));
assert_eq!(
app.settings.java.notice,
Some(pages::settings::java::Notice::WriteFailed),
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
@@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> {
Self::new(label, ChipVariant::Input, false, on_press)
}
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
///
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**:
/// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。
pub fn badge(label: &'a str) -> Self {
Self {
label,
variant: ChipVariant::Filter,
selected: true,
leading: None,
trailing: None,
on_press: None,
state: None,
}
}
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
+67 -27
View File
@@ -1,44 +1,84 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。
//! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。
//!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件),
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里:
//!
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架;
//! Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
//! | 这里 | 内容 |
//! |---|---|
//! | [`GROUP`] | 左列名 / 图标(正文走 [`view::body`],`body` 槽位保持 `None`) |
//! | [`JAVA_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update / Assistant 同一条**按下标分发**:本组要 `Lang`,`Group.body` 的签名里没有它) |
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/java.rs`) |
//!
//! ## M101 的演示行去哪了
//!
//! M101 留的 `rows: &[("Runtime","Temurin 21.0.4"), …]` 与绊线测试
//! `the_java_group_is_still_demo_rows`(断言 `body.is_none()`)在 M175 一起删掉了:
//! 那三个值是**写死的演示字符串**,用户实测把它们当成了真检测结果
//! (「它默认显示 Temurin 21.0.4 我甚至不能确定它是否是一个硬编码的值」)。
//! 现在的真值来自三段真读盘:`core::game::java::staged`(扫描)、`core::game::java::manual`
//! (手动路径严格探测)、`config/launcher.json`(`java.*` 三键)。
//!
//! `rows` 仍留成空切片:它是 `Group` 的字段(另外五组在用),而本组的 `body` 分发永远命中
//! [`view::body`],空切片不会被画出来——宁可空着,也不留一句会撒谎的演示值。
mod fields;
mod row;
mod seed;
mod state;
pub(crate) mod store;
pub(super) mod view;
#[cfg(test)]
mod tests;
pub use state::{same_path, Context, Layer, Notice, Requirement, State};
/// 测试里的注入作业要写 `Vec<RuntimeRow>`(App 层在生产路径上只用 `State`,不需要这个名字)。
#[cfg(test)]
pub use row::RuntimeRow;
use unlucky_design::icons::IconKind;
use super::Group;
/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。
///
/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。
pub(crate) const JAVA_GROUP: usize = 2;
/// Java 分组。
pub(super) const GROUP: Group = Group {
name: "Java",
icon: IconKind::Folder,
rows: &[
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
// 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。
rows: &[],
body: None,
};
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。
///
/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段,
/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。
#[derive(Debug, Default)]
pub struct State {}
/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。
///
/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
/// [`GroupMessage`](super::GroupMessage))。
/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。
#[derive(Debug, Clone)]
pub enum Message {
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 →
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。
/// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的
/// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。
#[cfg(test)]
Ping,
/// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来)
Scan,
/// 取消正在跑的扫描
CancelScan,
/// 手动路径输入框
AddDraft(String),
/// 提交手动添加(严格校验:真跑 `java -version`)
Add,
/// 把某个运行时设为默认(写 `java.selected_path`)
SelectRuntime(String),
/// 默认内存草稿(MB 文本)
EditMemory(String),
/// JVM 额外参数草稿
EditJvm(String),
/// 保存内存 + JVM 参数(校验 + 落盘 + 回读)
Save,
}
impl Message {
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::Java`)。
#[must_use]
pub fn into_group(self) -> super::GroupMessage {
super::GroupMessage::Java(self)
}
}
@@ -0,0 +1,246 @@
//! 「Java」分组的**编辑区**(M175):手动路径 / 默认内存 / JVM 参数 / 保存与回执。
//!
//! 从 `view.rs` 拆出来的(那个文件加了守卫之后贴到 438 行,越过 400 行硬线):这里全是
//! 「用户能改的东西」,`view.rs` 只剩「扫到了什么」与版面。两半共用 `view::msg` 这一个消息口,
//! 所以「一条控件对应哪条消息」不会各写一遍。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::pages::frame::text_role_owned;
use crate::pages::Message;
use crate::theme::Ui;
use super::state::{Layer, Notice, Refusal, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::view::msg;
use super::Message as JavaMessage;
/// 手动添加一行:输入框 + 「添加」按钮 + 原因。
pub(super) fn add_row<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.add_draft)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaAdd))
.placeholder(catalog.text(Key::SettingsJavaAddPlaceholder))
.supporting(catalog.text(Key::SettingsJavaAddHint));
let add = if state.add_draft.trim().is_empty() || !state.storage {
buttons::outlined(
ui,
Button::disabled(catalog.text(Key::SettingsJavaAddAction)),
)
} else {
buttons::outlined(
ui,
Button::new(
catalog.text(Key::SettingsJavaAddAction),
msg(JavaMessage::Add),
),
)
};
row![
container(text_field(ui, field, |value| msg(JavaMessage::AddDraft(
value
))))
.width(Length::Fill),
add,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 默认内存:数字框 + 范围 + 「生效值来自哪一层」。
pub(super) fn memory_field<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let range = catalog.render(
Key::SettingsJavaMemoryHint,
&[
("min", MIN_MEMORY_MB.to_string().as_str()),
("max", MAX_MEMORY_MB.to_string().as_str()),
],
);
let field = Field::new(&state.memory)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaMemory));
// 校验不过就把输入框本身标红(不只是下面那行字):用户一眼看到「是这个框有问题」。
let field = match state.validate() {
Err(Refusal::MemoryEmpty | Refusal::MemoryNotNumber | Refusal::MemoryRange) => {
field.error()
}
_ => field,
};
let effective = match state.layer() {
Layer::Global => catalog.render(
Key::SettingsJavaLayerGlobal,
&[("mb", state.saved_memory.to_string().as_str())],
),
Layer::Instance { instance, mb } => catalog.render(
Key::SettingsJavaLayerInstance,
&[
("mb", mb.to_string().as_str()),
("instance", instance.as_str()),
],
),
};
column![
text_field(ui, field, |value| msg(JavaMessage::EditMemory(value))),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
effective,
ui.color(Role::OnSurfaceVariant),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
range,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// JVM 额外参数:文本框 + 「会拼到每次启动」的说明。
pub(super) fn jvm_field<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let field = Field::new(&state.jvm)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsJavaJvm))
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
.supporting(catalog.text(Key::SettingsJavaJvmHint));
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) {
field.error()
} else {
field
};
// 已保存值:空的时候说「还没有保存任何额外参数」,而不是让「已保存的值:」后面空着。
let saved = if state.saved_jvm.trim().is_empty() {
catalog.text(Key::SettingsJavaJvmSavedNone).to_string()
} else {
catalog.render(
Key::SettingsJavaJvmSaved,
&[("value", state.saved_jvm.as_str())],
)
};
column![
text_field(ui, field, |value| msg(JavaMessage::EditJvm(value))),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
saved,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
pub(super) fn actions_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let label = catalog.text(Key::SettingsJavaSave);
if state.can_save() {
buttons::filled(ui, Button::new(label, msg(JavaMessage::Save)))
} else {
buttons::filled(ui, Button::disabled(label))
}
}
/// 按钮为什么灰着 / 下一步做什么——**永远说清**。
pub(super) fn hint_line<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
) -> Element<'a, Message> {
let (text, error) = if !state.storage {
(catalog.text(Key::SettingsJavaNoStorage).to_string(), true)
} else if state.dirty {
(catalog.text(Key::SettingsJavaUnsaved).to_string(), false)
} else {
(String::new(), false)
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
}),
)
}
pub(super) fn notice_line<'a>(
ui: &'a Ui,
catalog: Catalog,
notice: &Notice,
) -> Element<'a, Message> {
let (text, error) = match notice {
Notice::Saved => (catalog.text(Key::SettingsJavaSaved).to_string(), false),
Notice::Refused(reason) => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(refusal_key(*reason)))],
),
true,
),
Notice::Unwritable(reason) => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(reason.label()))],
),
true,
),
Notice::WriteFailed => (
catalog.render(
Key::SettingsJavaRefused,
&[("reason", catalog.text(Key::SettingsJavaReasonWriteFailed))],
),
true,
),
Notice::NoStorage => (catalog.text(Key::SettingsJavaNoStorage).to_string(), true),
Notice::DefaultSet(path) => (
catalog.render(Key::SettingsJavaDefaultSet, &[("path", path.as_str())]),
false,
),
Notice::Added(path) => (
catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]),
false,
),
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error { Role::Error } else { Role::OnSurface }),
)
}
/// 拒绝原因 → 文案 key(`Refusal::label` 已是这张表,这里只是给 `match` 一个名字)。
pub(super) fn refusal_key(refusal: Refusal) -> Key {
refusal.label()
}
@@ -0,0 +1,191 @@
//! 「Java」分组的一行运行时(M175):路径 / 版本 / 厂商 / 来源 + 上屏用的截断。
//!
//! 从 `state.rs` 拆出来的(那个文件加了上下文与校验之后到 424 行,越过 400 行硬线):
//! 这里只放「一行长什么样」,状态与校验仍在 `state.rs`。
use unlucky_i18n::Key;
/// 一个扫到的 / 手动加的运行时(页面要的那几格)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeRow {
/// `bin/java[.exe]` 的绝对路径(也是 `java.selected_path` 的取值)。
pub binary: String,
/// `java -version` 的首行(探测失败时为空串)。
pub version: String,
/// 主版本号(探测不出为 `None`)。
pub major: Option<u32>,
/// 发行方(认不出为 `None`)。
pub vendor: Option<String>,
/// 来源 id(`managed` / `env` / `path` / `system` / `manual`)。
pub source: &'static str,
}
impl RuntimeRow {
/// 版本号:**能区分 `17.0.13` 与 `17.0.9`** 的那一位,而不是只给大版本(r1 复审 P2-4)。
///
/// 三层取法,逐层降级(不许因为解析不出来就只显示 `17`):
/// 1. `-version` 首行引号里的版本号(`openjdk version "21.0.11" …` → `21.0.11`);
/// 2. 无引号形态里的第一个版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`);
/// 3. 整行原样(原始版本串比一个大版本号有用);
/// 4. 连首行都没有(探测失败)→ 大版本;再没有 → `?`。
#[must_use]
pub fn version_label(&self) -> String {
if let Some(quoted) = quoted_version(&self.version) {
return quoted;
}
if let Some(bare) = bare_version(&self.version) {
return bare;
}
let line = self.version.trim();
if !line.is_empty() {
return line.to_string();
}
self.major
.map_or_else(|| "?".to_string(), |major| major.to_string())
}
/// 版本一句话:`Java 21.0.11 · Microsoft`。
#[must_use]
pub fn summary(&self) -> String {
let version = self.version_label();
match &self.vendor {
Some(vendor) => format!("Java {version} · {vendor}"),
None => format!("Java {version}"),
}
}
/// JDK 安装目录(`…/bin/java.exe` 的上一级再上一级)。
///
/// 上屏用它而不是整条可执行文件路径:424 宽的正文列放不下一条 `C:\Program Files\…\bin\java.exe`,
/// 而用户认的是 JDK 目录(也是他手动添加时会粘的那一段)。
#[must_use]
pub fn home(&self) -> String {
let path = std::path::Path::new(self.binary.trim());
let parent = path.parent();
match parent.filter(|dir| {
dir.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
}) {
Some(bin) => bin
.parent()
.map_or_else(|| self.binary.clone(), |home| home.display().to_string()),
None => parent.map_or_else(|| self.binary.clone(), |dir| dir.display().to_string()),
}
}
/// 上屏用的目录:过长时保留**尾部**两段,前面用 `…` 收掉。
///
/// 为什么必须截:无空格的 Windows 路径在 iced 的文本排版里不可断行,一条 60 字符的路径会直接
/// 盖住右边的「设为默认」chip(M175 出图时实测到了这个重叠)。保尾部而不是开头,因为磁盘名
/// (`C:`)谁都知道,而 `…\Java\temurin\17.0.13` 才是能区分两条 JDK 的那一段。
#[must_use]
pub fn display_home(&self) -> String {
const LIMIT: usize = 39;
let home = self.home();
if home.chars().count() <= LIMIT {
return home;
}
let tail: String = home
.chars()
.rev()
.take(LIMIT - 1)
.collect::<Vec<char>>()
.into_iter()
.rev()
.collect();
// 从第一个分隔符之后开始(别让 `…` 后面接着半截目录名)。
let tail = tail
.find(['\\', '/'])
.map_or(tail.clone(), |at| tail[at + 1..].to_string());
format!("…{}{}", std::path::MAIN_SEPARATOR, tail)
}
/// 来源文案 key。
#[must_use]
pub fn source_key(&self) -> Key {
match self.source {
"managed" => Key::SettingsJavaSourceManaged,
"env" => Key::SettingsJavaSourceEnv,
"path" => Key::SettingsJavaSourcePath,
"manual" => Key::SettingsJavaSourceManual,
_ => Key::SettingsJavaSourceSystem,
}
}
}
/// 引号里的版本号(`openjdk version "21.0.11" 2025-10-21 LTS` → `21.0.11`)。
fn quoted_version(line: &str) -> Option<String> {
let start = line.find('"')? + 1;
let rest = &line[start..];
let end = rest.find('"')?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_string())
}
/// 无引号形态里的版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`)。
fn bare_version(line: &str) -> Option<String> {
line.split_whitespace().find_map(|token| {
let candidate = token.trim_matches([',', '(', ')', '"']);
let mut parts = candidate.split('.');
let first = parts.next()?;
let looks_like_version = first.chars().all(|c| c.is_ascii_digit())
&& !first.is_empty()
&& parts.next().is_some();
looks_like_version.then(|| candidate.to_string())
})
}
#[cfg(test)]
mod tests {
use super::RuntimeRow;
fn row(version: &str, major: Option<u32>) -> RuntimeRow {
RuntimeRow {
binary: "C:/jdk/bin/java.exe".to_string(),
version: version.to_string(),
major,
vendor: Some("Microsoft".to_string()),
source: "system",
}
}
/// 版本号必须能区分同一大版本的两份 JDK(`17.0.13` vs `17.0.9`),不许只给 `17`。
///
/// 变异证明:把 `version_label` 换回「只用 `major`」,这条立刻红。
#[test]
fn the_version_label_keeps_the_minor_digits() {
assert_eq!(
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).version_label(),
"17.0.13"
);
assert_eq!(
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).version_label(),
"17.0.9"
);
assert_ne!(
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).summary(),
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).summary(),
"两份同大版本 JDK 的摘要必须不同"
);
assert_eq!(
row("openjdk 25.0.3 2026-04-21", Some(25)).version_label(),
"25.0.3",
"无引号形态也要认"
);
assert_eq!(
row("something unexpected 21", Some(21)).version_label(),
"something unexpected 21",
"解析不出来时给原始版本串,不给光秃秃的大版本"
);
assert_eq!(row("", Some(21)).version_label(), "21");
assert_eq!(row("", None).version_label(), "?");
}
#[test]
fn the_summary_carries_version_and_vendor() {
assert_eq!(
row("openjdk version \"21.0.11\" 2025-10-21 LTS", Some(21)).summary(),
"Java 21.0.11 · Microsoft"
);
}
}
@@ -0,0 +1,75 @@
//! 「Java」分组的截图种子(M175,`ULL_JAVA_STATE`):**只给 `--screenshot` 用**。
//!
//! 三档对应任务书要的三张图:未安装 Java 的空态 / 检测到两个 JDK 并选中其一 / 内存与 JVM 参数
//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出
//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。
//!
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State};
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用)。
///
/// 三档对应三张图:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与
/// JVM 参数、还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
#[must_use]
pub fn build(raw: &str) -> Option<State> {
let mut state = State {
storage: true,
scanned: true,
..State::default()
};
match raw.trim() {
"empty" => {
state.memory = "2048".to_string();
state.saved_memory = 2048;
}
"two" => {
state.runtimes = vec![
RuntimeRow {
binary: "C:/Program Files/Eclipse Adoptium/jdk-17.0.13/bin/java.exe"
.to_string(),
version: "openjdk version \"17.0.13\" 2024-10-15".to_string(),
major: Some(17),
vendor: Some("Temurin".to_string()),
source: "system",
},
RuntimeRow {
binary: "C:/Program Files/Microsoft/jdk-21.0.4/bin/java.exe".to_string(),
version: "openjdk version \"21.0.4\" 2024-07-16 LTS".to_string(),
major: Some(21),
vendor: Some("Microsoft".to_string()),
source: "system",
},
];
state.selected = state.runtimes[1].binary.clone();
state.manual = vec!["D:/jdk/zulu-21".to_string()];
state.memory = "4096".to_string();
state.saved_memory = 4096;
state.jvm = "-XX:+UseG1GC".to_string();
state.saved_jvm = "-XX:+UseG1GC".to_string();
state.context = Context {
required: Some(Requirement {
instance: "生存服".to_string(),
version: "1.20.1".to_string(),
minimum: 17,
maximum: None,
}),
layer: Some(Layer::Global),
effective_memory_mb: Some(4096),
};
}
"editing" => {
let mut seeded = build("two")?;
seeded.memory = "128".to_string();
seeded.jvm = "-XX:+UseG1GC -Xss\"512k".to_string();
seeded.dirty = true;
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
return Some(seeded);
}
_ => return None,
}
Some(state)
}
@@ -0,0 +1,374 @@
//! 「Java」分组的运行期状态(M175):扫描结论 + 手动名单 + 两个可编辑项。
//!
//! 状态住在 `pages::settings::Groups.java`([`State`]),由 `App` 持有。三条边界:
//!
//! | 东西 | 住哪 | 为什么 |
//! |---|---|---|
//! | 扫描结论 / 进度 | 本结构(由后台线程的快照 1 拍一拍灌进来) | 用户切走再回来不该重扫 |
//! | 手动加的路径名单 | 本结构 + 一个纯文本文件(`store::manual_file`) | 它是「我加过哪些」的记忆,不是启动策略 |
//! | `java.selected_path` / `default_memory_mb` / `extra_args` | **`config/launcher.json`** | 真值只有一份,读回也读它 |
//!
//! ## 为什么内存草稿要记两层(`memory` 文本 + `saved_memory` 数值)
//!
//! 输入框里的是**文本**(用户可能刚敲了半截「819」),而「现在生效多少」是**落盘的真值**。
//! 两者混成一个字段就会重演「显示的就是生效的」这种假象:改到一半的数字不是生效值。
//! 于是草稿只影响「有未保存改动」与「保存按钮能不能点」,生效值一律来自 `saved_*`(落盘后读回的)。
//!
//! ## 版本门槛从哪来
//!
//! [`Context::required`] 由 **app 层**(`app/update/settings/java.rs`)算好塞进来:它要读实例快照
//! (`requirement_for(mc_version)`),而页面渲染器只拿得到 [`super::States`]。
//! 页面自己不去读盘、也不复制版本表。
pub use super::row::RuntimeRow;
use unlucky_i18n::Key;
/// `java.default_memory_mb` 的下限(MB)。
///
/// **必须**等于 `core::setting::keys` 里那个键登记的下限(512):UI 放行一个 schema 会拒的值,
/// 用户点保存只会得到一条「schema 拒绝」的错。一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const MIN_MEMORY_MB: u32 = 512;
/// `java.default_memory_mb` 的上限(MB)。
pub const MAX_MEMORY_MB: u32 = 65536;
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const DEFAULT_MEMORY_MB: u32 = 2048;
/// 扫描进度(`None` = 没在扫)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
/// 已探测的候选数
pub done: usize,
/// 候选总数
pub total: usize,
}
/// 当前实例对 Java 的版本要求(app 层算好塞进来)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
/// 实例名(「为什么选它」那句话里要指名道姓)
pub instance: String,
/// 实例绑的版本 id(`1.20.1`)
pub version: String,
/// 最低主版本
pub minimum: u32,
/// 最高主版本(老版本才有;`None` = 不封顶)
pub maximum: Option<u32>,
}
impl Requirement {
/// 某个大版本满不满足。
#[must_use]
pub fn satisfied_by(&self, major: u32) -> bool {
major >= self.minimum && self.maximum.is_none_or(|max| major <= max)
}
}
/// 生效内存来自哪一层。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Layer {
/// 全局 `java.default_memory_mb`
Global,
/// 某个实例的 `launch.memoryMb` 覆盖了全局
Instance {
/// 实例名
instance: String,
/// 实例层的值
mb: u32,
},
}
/// 上下文(app 层每次 hydrate 重算)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Context {
/// 当前实例的版本要求(没有实例 ⇒ `None`)
pub required: Option<Requirement>,
/// 当前生效内存来自哪一层
pub layer: Option<Layer>,
/// 当前生效内存(三级回落之后的数值)
pub effective_memory_mb: Option<u32>,
}
/// 保存 / 操作的回执。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notice {
/// 已落盘并读回
Saved,
/// 本地校验拒绝(没写盘)
Refused(Refusal),
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key,不拼字符串)
Unwritable(Unwritable),
/// 写盘失败(schema / 磁盘):core 的 `Display` 是中文,不能直出给英文界面,
/// 所以这里只带一个页面自己的 key;原始句子进 stderr 日志。
WriteFailed,
/// 没有可写文件(内存态运行)
NoStorage,
/// 默认 JDK 已写进 `java.selected_path`
DefaultSet(String),
/// 手动路径已加入名单
Added(String),
}
/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unwritable {
/// 没有任何脏键(我们以为改了、其实没有)
NotDirty,
/// 文件只读(被更新版本的启动器写过 / 被标记不可信)
ReadOnly,
/// 文件不可信(加载时就降级成只读)
Unreadable,
}
impl Unwritable {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::NotDirty => Key::SettingsJavaSkipNotDirty,
Self::ReadOnly => Key::SettingsJavaSkipReadOnly,
Self::Unreadable => Key::SettingsJavaSkipUnreadable,
}
}
}
/// 本地校验的拒绝原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
/// 内存草稿为空
MemoryEmpty,
/// 内存草稿不是整数
MemoryNotNumber,
/// 内存越界
MemoryRange,
/// JVM 参数里的引号没配成对
JvmQuotes,
/// 手动路径为空
AddEmpty,
/// 手动路径在盘上找不到
AddMissing,
/// 有这个东西,但不是 java 可执行文件
AddNotJava,
/// 跑不出 `-version`
AddNoVersion,
/// 路径太长(粘了一整段日志)
AddTooLong,
/// 名单文件写不进去(磁盘 / 权限)
AddWriteFailed,
/// 已经加过了
AddDuplicate,
}
impl Refusal {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::MemoryEmpty => Key::SettingsJavaReasonMemoryEmpty,
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
Self::AddMissing => Key::SettingsJavaReasonAddMissing,
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
Self::AddNoVersion => Key::SettingsJavaReasonAddNoVersion,
Self::AddTooLong => Key::SettingsJavaReasonAddTooLong,
Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed,
Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate,
}
}
}
/// 「Java」分组的运行期状态。
#[derive(Debug)]
pub struct State {
/// 扫到的运行时(顺序 = 大版本升序,探测不到主版本的排最后)
pub runtimes: Vec<RuntimeRow>,
/// 至少完整扫过一次(区分「未检测到」与「还没扫」)
pub scanned: bool,
/// 扫描进度
pub scanning: Option<Progress>,
/// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫)
pub cancelled: bool,
/// 扫描状态槽(快照 + 取消位;线程与界面共享同一份)
pub scan: super::store::ScanSlot,
/// 用户手动加的路径(原样,持久化在 `manual_file`)
pub manual: Vec<String>,
/// `java.selected_path` 的真值(空 = 没指定,启动时自动挑)
pub selected: String,
/// 内存草稿(MB 文本)
pub memory: String,
/// 落盘的内存真值(MB)
pub saved_memory: u32,
/// JVM 参数草稿
pub jvm: String,
/// 落盘的 JVM 参数真值
pub saved_jvm: String,
/// 手动路径输入框
pub add_draft: String,
/// 有未保存的改动
pub dirty: bool,
/// 这一次运行有没有可写的设置文件
pub storage: bool,
/// 上一次操作的回执
pub notice: Option<Notice>,
/// 当前实例的上下文(版本门槛 / 生效内存层级)
pub context: Context,
}
impl Default for State {
fn default() -> Self {
Self {
runtimes: Vec::new(),
scanned: false,
scanning: None,
cancelled: false,
scan: super::store::ScanSlot::default(),
manual: Vec::new(),
selected: String::new(),
memory: String::new(),
saved_memory: DEFAULT_MEMORY_MB,
jvm: String::new(),
saved_jvm: String::new(),
add_draft: String::new(),
dirty: false,
storage: false,
notice: None,
context: Context::default(),
}
}
}
/// 从 `launcher.json` 读回来的三键真值。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
/// `java.selected_path`
pub selected: String,
/// `java.default_memory_mb`
pub memory_mb: u32,
/// `java.extra_args`
pub jvm: String,
}
impl State {
/// 用真值刷新(**脏草稿不覆盖**,否则外部一改文件就冲掉用户正在输入的内容)。
///
/// 返回是否改了「生效值」那一半(调用方据此决定要不要重建视图模型)。
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
self.storage = true;
let Snapshot {
selected,
memory_mb,
jvm,
} = snapshot;
let changed =
self.selected != selected || self.saved_memory != memory_mb || self.saved_jvm != jvm;
self.selected = selected;
self.saved_memory = memory_mb;
if !self.dirty {
self.memory = memory_mb.to_string();
self.jvm = jvm.clone();
}
self.saved_jvm = jvm;
changed
}
/// 灌一次扫描结论(后台线程的快照)。返回是否变了。
pub fn apply_scan(&mut self, report: &super::store::ScanReport) -> bool {
let scanning = report.running.then_some(Progress {
done: report.done,
total: report.total,
});
let changed = self.scanning != scanning
|| self.cancelled != report.cancelled
|| (report.finished && !self.scanned)
|| (report.finished && self.runtimes != report.runtimes);
self.scanning = scanning;
self.cancelled = report.cancelled;
if report.finished {
self.scanned = true;
self.runtimes = report.runtimes.clone();
}
changed
}
/// 某个大版本够不够当前实例用。
#[must_use]
pub fn satisfied(&self, major: Option<u32>) -> Option<bool> {
let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else {
return None;
};
Some(required.satisfied_by(major))
}
/// 保存前的本地校验(过不了就不写盘)。
///
/// # Errors
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。
pub fn validate(&self) -> Result<u32, Refusal> {
let text = self.memory.trim();
if text.is_empty() {
return Err(Refusal::MemoryEmpty);
}
let Ok(mb) = text.parse::<u32>() else {
return Err(Refusal::MemoryNotNumber);
};
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&mb) {
return Err(Refusal::MemoryRange);
}
if unbalanced_quotes(&self.jvm) {
return Err(Refusal::JvmQuotes);
}
Ok(mb)
}
/// 界面上「现在生效多少」的那句话用哪一层(没有上下文时按全局)。
#[must_use]
pub fn layer(&self) -> Layer {
self.context.layer.clone().unwrap_or(Layer::Global)
}
/// 现在能不能点保存。
#[must_use]
pub fn can_save(&self) -> bool {
self.storage && self.dirty
}
/// 当前默认运行时那一行(`selected` 指向的)。
#[must_use]
pub fn default_row(&self) -> Option<&RuntimeRow> {
self.runtimes
.iter()
.find(|row| same_path(&row.binary, &self.selected))
}
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用;三档的实现在 `java/seed.rs`)。
///
/// 三档:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 JVM 参数、
/// 还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
#[must_use]
pub fn screenshot_seed(raw: &str) -> Option<Self> {
super::seed::build(raw)
}
}
/// 两条路径是不是同一个(`/` 与 `\` 视为同一分隔符;Windows 折大小写)。
#[must_use]
pub fn same_path(left: &str, right: &str) -> bool {
let key = |text: &str| {
let text = text.replace('\\', "/");
if cfg!(windows) {
text.to_ascii_lowercase()
} else {
text
}
};
!left.trim().is_empty() && key(left.trim()) == key(right.trim())
}
/// 引号(`"` 与 `'`)数量是不是奇数——奇数就意味着用户漏了一半。
#[must_use]
pub fn unbalanced_quotes(text: &str) -> bool {
text.matches('"').count() % 2 == 1 || text.matches('\'').count() % 2 == 1
}
@@ -0,0 +1,393 @@
//! 「Java」分组的真读写(M175):设置三键 + 手动名单 + 后台扫描。
//!
//! ## 写盘走 `SettingStore`,与 `ai/store.rs` 同一条通路
//!
//! `java.selected_path` / `java.default_memory_mb` / `java.extra_args` 都是
//! `config/launcher.json` 里的 schema 键(`core::setting::keys` 已登记,本 mission 不动 schema)。
//! 于是这里只做「读一份 → 改几个键 → 存 → **再从磁盘读回来**」:`save()` 有 `Skipped` 一档
//! (只读 / 文件被更新版本写过 / 没有脏键),把它当成功就会出现「界面说已保存、重启回旧值」。
//! 失败一律走 [`WriteError`]:**要么是页面自己的拒绝原因(i18n key),要么是 core 给的原始句子**,
//! 不再把 Rust 枚举名(`"MemoryRange"`)当人话往上抛(M175 r1 复审 P2-2)。
//!
//! ## 扫描为什么是「进程级快照 + 后台线程」,不是 `Task::perform`
//!
//! 与 `fonts::install` 同一条理由(那里记了完整推导):本 crate 的执行器是 `thread-pool`,
//! 而扫描要的是**进度(3/7)+ 取消 + 不卡 UI**。快照方案的代价是一把短锁 + 1 拍一次比较,
//! 好处是窗口关掉之后不会往死掉的 channel 里写。界面的刷新节奏见 `app/update/clock.rs`
//! 与 `app/subscription.rs`(扫描期间按帧跑,扫描结束回到 1 Hz)。
//!
//! ### 三条与「取消」有关的硬约束(M175 r1 复审 P2-5)
//!
//! 1. **每个候选都有超时**:卡住的 `java -version` 由 `core::game::java::manual` 的
//! `run_with_timeout` kill 掉,扫描线程不会永远不结束;
//! 2. **取消打断的是「正在跑的那一个」**:取消位一路传进探测函数,不必等它自己结束;
//! 3. **同时只允许一个扫描线程**:[`ScanReport::running`] 的含义就是「线程还活着」,
//! 取消**不**翻这个位(只置 `cancelled`),于是取消到线程真退出之间点「立即扫描」
//! 不会起第二个线程——界面上那一小段显示的是「正在取消…」。
//!
//! ## 手动名单为什么是纯文本文件
//!
//! 它记的是「我手动加过哪些路径」,不是启动策略(真正决定用哪个 Java 的是 `java.selected_path`)。
//! 放进 `launcher.json` 要新登记一个 schema 键并推 minor(会让旧版本整份文件变只读),
//! 代价远大于收益;而 `core::game::java::manual` 的读写是纯文本、一行一个,坏了顶多丢名单。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use unlucky_core::game::java::{discover, manual, staged, JavaRuntime};
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable};
/// 扫描结果快照(后台线程写、界面读)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanReport {
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
pub running: bool,
/// 已探测
pub done: usize,
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
pub total: usize,
/// 至少完整扫完过一次
pub finished: bool,
/// 用户按过取消(线程还活着时是 `true`)
pub cancelled: bool,
/// 扫描结论(`finished` 且没被取消时有效)
pub runtimes: Vec<RuntimeRow>,
}
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
pub type Job =
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
///
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
///
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
#[derive(Debug, Clone, Default)]
pub struct ScanSlot {
inner: std::sync::Arc<ScanInner>,
}
#[derive(Debug, Default)]
struct ScanInner {
report: Mutex<ScanReport>,
cancel: AtomicBool,
}
impl ScanSlot {
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
#[must_use]
pub fn report(&self) -> ScanReport {
self.inner
.report
.lock()
.map(|report| report.clone())
.unwrap_or_default()
}
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
///
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
pub fn cancel(&self) {
self.inner.cancel.store(true, Ordering::SeqCst);
if let Ok(mut report) = self.inner.report.lock() {
if report.running {
report.cancelled = true;
}
}
}
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
self.start_with(roots, manual, system, run_staged)
}
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
pub fn start_with(
&self,
roots: Vec<PathBuf>,
manual: Vec<PathBuf>,
system: bool,
job: Job,
) -> bool {
{
let Ok(mut report) = self.inner.report.lock() else {
return false;
};
if report.running {
return false;
}
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
*report = ScanReport {
running: true,
..ScanReport::default()
};
self.inner.cancel.store(false, Ordering::SeqCst);
}
let slot = self.clone();
std::thread::spawn(move || {
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
let candidates = staged::candidates(&roots, &manual, system);
if let Ok(mut report) = slot.inner.report.lock() {
report.total = candidates.len();
}
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
let mut progress = |done: usize, _total: usize| {
if let Ok(mut report) = slot.inner.report.lock() {
report.done = done;
}
};
let runtimes = job(&candidates, &cancelled, &mut progress);
if let Ok(mut report) = slot.inner.report.lock() {
report.running = false;
report.done = candidates.len();
if !report.cancelled {
report.finished = true;
report.runtimes = runtimes;
}
}
});
true
}
}
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
fn run_staged(
candidates: &[staged::Candidate],
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
let found = staged::scan(
candidates,
|done, total| {
progress(done, total);
true
},
cancelled,
);
rows(&found, candidates)
}
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
found
.iter()
.map(|runtime| RuntimeRow {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: candidates
.iter()
.find(|candidate| candidate.binary == runtime.binary)
.map_or("system", |candidate| candidate.source.id()),
})
.collect()
}
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
///
/// 四条都要成立:①不是截图通道(D30:同一份代码出同一张图,截图不许读本机装了什么);
/// ②没有在扫;③**没被取消过**(一次取消 = 本进程不再自动扫:被取消的扫描 `finished` 仍是
/// `false`,而 `launcher.json` 每改一次(存内存、改主题、改语言、存 AI 配置)心跳就会走一次
/// `hydrate_java_settings`,不看这一条就会把用户的取消无声撤销);④还没扫完过(一次就够——
/// 用户手动点「立即扫描」走的是 `start`,不看这里)。
///
/// 单独抽出来是因为它是「用户实测的头一条抱怨」(设置页从来自动检测本机 Java)的开关:
/// 判据写在这里,`tests/cmdline.rs` 逐档断言,改坏立刻红。
#[must_use]
pub fn auto_scan_due(report: &ScanReport, screenshot: bool) -> bool {
!screenshot && !report.running && !report.cancelled && !report.finished
}
/// 托管安装根(`<launcher home>/data/java-managed` + `java.installRoot` + 标准落点)。
///
/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式
/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。
#[must_use]
pub fn managed_roots(config: &Path) -> Vec<PathBuf> {
let Some(home) = config.parent().and_then(Path::parent) else {
return Vec::new();
};
let layout = unlucky_core::platform::layout::LauncherLayout::new(home);
let override_root = SettingStore::load(config, launcher_schema())
.doc()
.effective_str("java.installRoot")
.map(str::trim)
.filter(|text| !text.is_empty())
.map(PathBuf::from);
unlucky_core::game::java::location::scan_roots(
unlucky_core::platform::data_dir::DataMode::Portable,
&layout,
override_root.as_deref(),
Platform::current(),
&ProcessEnv,
)
}
/// 手动名单文件(与 `launcher.json` 同一个 `config/`)。
#[must_use]
pub fn manual_file(config: &Path) -> PathBuf {
config.parent().map_or_else(
|| PathBuf::from("java-extra-paths.txt"),
|dir| dir.join("java-extra-paths.txt"),
)
}
/// 读三键真值。
#[must_use]
pub fn load(config: &Path) -> Snapshot {
snapshot(SettingStore::load(config, launcher_schema()).doc())
}
/// 写盘失败的三种来源(都是「没写进去」,但对用户要说的不是同一句话)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteError {
/// 页面自己的校验拒绝(i18n key)
Refused(Refusal),
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key)
Unwritable(Unwritable),
/// schema / 磁盘给的原始句子(core 的文案,不属于本页)
Schema(String),
}
/// 保存内存 + JVM 参数,返回**落盘后读回来**的真值。
///
/// # Errors
/// 本地校验不过([`WriteError::Refused`])、文件只读/不可信([`WriteError::Unwritable`])
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
pub fn save(config: &Path, state: &State) -> Result<Snapshot, WriteError> {
let mb = state.validate().map_err(WriteError::Refused)?;
let mut store = SettingStore::load(config, launcher_schema());
store
.set("java.default_memory_mb", i64::from(mb).into())
.and_then(|()| store.set("java.extra_args", state.jvm.trim().to_string().into()))
.map_err(|error| WriteError::Schema(format!("{error}")))?;
finish(store, config)
}
/// 写 `java.selected_path`(选定默认 JDK),返回落盘后读回来的真值。
///
/// # Errors
/// 文件只读/不可信或 schema 拒绝时返回原因。
pub fn set_selected(config: &Path, binary: &str) -> Result<Snapshot, WriteError> {
let mut store = SettingStore::load(config, launcher_schema());
store
.set("java.selected_path", binary.to_string().into())
.map_err(|error| WriteError::Schema(format!("{error}")))?;
finish(store, config)
}
/// 落盘 + 判定 + 读回(`save` / `set_selected` 共用:只认 `Saved` 一档)。
fn finish(mut store: SettingStore, config: &Path) -> Result<Snapshot, WriteError> {
match store.save() {
Ok(SaveOutcome::Saved(_)) => Ok(load(config)),
Ok(SaveOutcome::Skipped(reason)) => Err(WriteError::Unwritable(unwritable(reason))),
Err(error) => Err(WriteError::Schema(format!("{error}"))),
}
}
/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。
fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
use unlucky_core::setting::store::SkipReason;
match reason {
SkipReason::NotDirty => Unwritable::NotDirty,
SkipReason::ReadOnly => Unwritable::ReadOnly,
SkipReason::Unreadable => Unwritable::Unreadable,
}
}
/// 文档 → 快照。
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
Snapshot {
selected: doc
.effective_str("java.selected_path")
.unwrap_or_default()
.trim()
.to_string(),
memory_mb: doc
.effective_i64("java.default_memory_mb")
.and_then(|value| u32::try_from(value).ok())
.unwrap_or(super::state::DEFAULT_MEMORY_MB),
jvm: doc
.effective_str("java.extra_args")
.unwrap_or_default()
.to_string(),
}
}
/// 读手动名单。
#[must_use]
pub fn load_manual(config: &Path) -> Vec<String> {
manual::load_paths(&manual_file(config))
.iter()
.map(|path| path.display().to_string())
.collect()
}
/// 手动加一个路径:严格校验(真跑 `java -version`)→ 进名单 → 落盘。
///
/// 拒绝时**什么都不写**(名单不变、设置不变)——这是「非法路径不污染设置」那条守卫的落点。
///
/// # Errors
/// 校验不过(见 [`Refusal`])或名单写盘失败时返回原因。
pub fn add_manual(config: &Path, input: &str) -> Result<(String, RuntimeRow), Refusal> {
add_manual_with(config, input, &manual::probe_manual)
}
/// 同 [`add_manual`],但探测函数可注入(测试用:不依赖本机装没装 JDK)。
///
/// # Errors
/// 探测被拒或名单写盘失败时返回原因。
pub fn add_manual_with(
config: &Path,
input: &str,
probe: &dyn Fn(&str) -> Result<JavaRuntime, manual::ManualFailure>,
) -> Result<(String, RuntimeRow), Refusal> {
let runtime = probe(input.trim()).map_err(failure_refusal)?;
let binary = runtime.binary.display().to_string();
let mut paths = manual::load_paths(&manual_file(config));
let key = discover::path_key(&runtime.binary);
if paths.iter().any(|known| discover::path_key(known) == key) {
return Err(Refusal::AddDuplicate);
}
paths.push(runtime.binary.clone());
manual::save_paths(&manual_file(config), &paths).map_err(|_| Refusal::AddWriteFailed)?;
Ok((
binary.clone(),
RuntimeRow {
binary,
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: "manual",
},
))
}
/// `core` 的拒绝原因 → 页面原因。
fn failure_refusal(failure: manual::ManualFailure) -> Refusal {
match failure {
manual::ManualFailure::Empty => Refusal::AddEmpty,
manual::ManualFailure::TooLong => Refusal::AddTooLong,
manual::ManualFailure::Missing => Refusal::AddMissing,
manual::ManualFailure::NotJava => Refusal::AddNotJava,
manual::ManualFailure::NoVersion => Refusal::AddNoVersion,
}
}
/// 设置文件路径(数据根解析不出来时没有)。
#[must_use]
pub fn config_of(bridge: &unlucky_presenter::settings_bridge::SettingsBridge) -> Option<PathBuf> {
bridge.path().map(Path::to_path_buf)
}
@@ -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;
@@ -0,0 +1,262 @@
//! `tests.rs` 的后半:真命令行 / 落盘读回 / 版本门槛 / 截图种子。
//!
//! 拆出来的理由:`tests.rs` 加上守卫之后 434 行,越过 400 行硬线;这一半是「真值走完整条生产链」
//! 的一族(`launcher.json` → 组装链 / 重启后读回),与前半的「输入校验」不是一件事。
//! 公共的临时目录与假 JDK 构造在父模块(`super::root` / `super::fake_jdk`)。
use std::path::Path;
use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State};
use super::super::store;
use super::{config, fake_jdk, root};
// --------------------------------------------------------------- 守卫④:内存真进命令行
/// 写进 `java.default_memory_mb` 的值必须出现在**真组装出来的 JVM 参数**里。
///
/// 走的是生产代码那条链:`launcher.json` → `GlobalLaunchDefaults` → `launch_overrides::resolve`
/// → `launch::overrides::apply` → `launch::args_table::assemble`(`ull launch --dry-run` 用的是同一条)。
/// 不是读配置回显:断言的是 `-Xmx<新值>m` 这个 argv 元素。
///
/// 变异证明:把 `store::save` 里写 `java.default_memory_mb` 的那一行删掉(或写死 2048),
/// 这条立刻红(argv 里还是 `-Xmx2048m`)。
#[test]
fn the_saved_memory_reaches_the_real_command_line() {
let dir = root("cmdline");
let file = config(&dir);
let state = State {
storage: true,
memory: "6144".to_string(),
jvm: "-Dfile.encoding=UTF-8".to_string(),
..State::default()
};
assert!(state.validate().is_ok());
let saved = store::save(&file, &state).expect("保存应成功");
assert_eq!(saved.memory_mb, 6144, "落盘后读回的值");
assert_eq!(saved.jvm, "-Dfile.encoding=UTF-8");
let args = real_argv(&file);
assert!(
args.iter().any(|arg| arg == "-Xmx6144m"),
"内存字段必须长成 -Xmx6144m:{args:?}"
);
assert!(
args.iter().any(|arg| arg == "-Dfile.encoding=UTF-8"),
"java.extra_args 必须进命令行:{args:?}"
);
assert!(
!args.iter().any(|arg| arg == "-Xmx2048m"),
"出厂默认值不许还在里面:{args:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 用生产链组装一次命令行(`launch.json` 之外什么都不读)。
fn real_argv(config: &Path) -> Vec<String> {
use std::path::PathBuf;
use unlucky_core::game::launch_overrides::{resolve, GlobalLaunchDefaults};
use unlucky_core::launch::args_table::assemble;
use unlucky_core::launch::overrides;
use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
use unlucky_core::launch::request::{
InstanceInfo, JavaSelection, LaunchDirs, ManifestArgs, MemoryOptions,
};
use unlucky_core::launch::request::{
LaunchRequest, LauncherInfo, Log4jOptions, OptimizationOptions,
};
let snapshot = store::load(config);
let resolved = resolve(
None,
&GlobalLaunchDefaults {
jvm_args: Some(snapshot.jvm.clone()),
memory_mb: Some(snapshot.memory_mb),
..GlobalLaunchDefaults::default()
},
);
let mut request = LaunchRequest {
instance: InstanceInfo {
id: "inst-1".to_string(),
name: "1.20.1".to_string(),
version: unlucky_core::launch::game_version::GameVersion::parse("1.20.1"),
version_type: "release".to_string(),
},
launcher: LauncherInfo {
name: "UnluckyLauncher".to_string(),
version: "0.2.3".to_string(),
},
java: JavaSelection::new("C:/java/bin/java.exe", 21, 64),
memory: MemoryOptions {
min_mb: None,
max_mb: None,
},
manifest: ManifestArgs {
main_class: Some("net.minecraft.client.main.Main".to_string()),
..ManifestArgs::default()
},
dirs: LaunchDirs {
game_dir: PathBuf::from("C:/mc"),
primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"),
..LaunchDirs::default()
},
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
log4j: Log4jOptions {
enabled: false,
..Log4jOptions::default()
},
optimization: OptimizationOptions {
enabled: false,
physical_memory_mb: None,
},
language: "zh-CN".to_string(),
..LaunchRequest::default()
};
overrides::apply(&mut request, &resolved);
assemble(&request, &PlatformCtx::new(OsFamily::Windows, "x86_64"))
.expect("组装应成功")
.args
}
// --------------------------------------------------------------- 落盘 / 读回 / 门槛
/// 三键都能落盘、能在「重启后」读回来(新的 `State` + `hydrate` 就是重启的等价物)。
#[test]
fn the_settings_survive_a_restart() {
let dir = root("restart");
let file = config(&dir);
let state = State {
storage: true,
memory: "8192".to_string(),
jvm: "-XX:+UseG1GC".to_string(),
..State::default()
};
store::save(&file, &state).expect("保存");
let selected = store::set_selected(&file, "C:/jdk-17/bin/java.exe").expect("选默认");
// 「重启」:全新的 State 从盘上灌一次。
let mut reborn = State::default();
assert!(reborn.hydrate(store::load(&file)));
assert_eq!(reborn.memory, "8192");
assert_eq!(reborn.saved_memory, 8192);
assert_eq!(reborn.jvm, "-XX:+UseG1GC");
assert_eq!(reborn.selected, "C:/jdk-17/bin/java.exe");
assert_eq!(selected.selected, "C:/jdk-17/bin/java.exe");
// 手动名单也在(与设置同一个 config/ 目录下的纯文本)。
let jdk = fake_jdk(&dir, "keep-me");
std::fs::write(store::manual_file(&file), format!("{}\n", jdk.display())).expect("写名单");
assert_eq!(store::load_manual(&file).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// 「为什么选它」那一行:版本门槛来自实例,选中的运行时够不够要能算出来。
#[test]
fn the_requirement_explains_why_a_runtime_fits_or_not() {
let mut state = State {
scanned: true,
runtimes: vec![
RuntimeRow {
binary: "C:/jdk-8/bin/java.exe".to_string(),
version: String::new(),
major: Some(8),
vendor: None,
source: "system",
},
RuntimeRow {
binary: "C:/jdk-21/bin/java.exe".to_string(),
version: String::new(),
major: Some(21),
vendor: None,
source: "system",
},
],
selected: "C:/jdk-8/bin/java.exe".to_string(),
..State::default()
};
assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论");
state.context.required = Some(Requirement {
instance: "生存服".to_string(),
version: "1.20.1".to_string(),
minimum: 17,
maximum: None,
});
assert_eq!(state.satisfied(Some(8)), Some(false));
assert_eq!(state.satisfied(Some(21)), Some(true));
assert_eq!(
state.default_row().map(|row| row.binary.clone()),
Some("C:/jdk-8/bin/java.exe".to_string()),
"默认那一行按 selected 找出来"
);
state.context.layer = Some(Layer::Instance {
instance: "生存服".to_string(),
mb: 8192,
});
assert!(matches!(state.layer(), Layer::Instance { mb: 8192, .. }));
}
/// 截图种子:三档都得给得出来(`--screenshot` 的取证开关)。
#[test]
fn the_screenshot_seeds_cover_the_three_states() {
let empty = State::screenshot_seed("empty").expect("empty 档");
assert!(empty.runtimes.is_empty() && empty.scanned);
let two = State::screenshot_seed("two").expect("two 档");
assert_eq!(two.runtimes.len(), 2);
assert!(two.default_row().is_some(), "两个 JDK 里要选中其一");
let editing = State::screenshot_seed("editing").expect("editing 档");
assert!(editing.dirty, "编辑态要有未保存改动");
assert!(matches!(editing.notice, Some(Notice::Refused(_))));
assert!(State::screenshot_seed("nonsense").is_none());
}
/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。
///
/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描);
/// 改成恒 `false`,第一档红(用户实测的头一条抱怨就是「设置页从来自动检测」)。
#[test]
fn the_auto_scan_predicate_runs_once_and_never_in_screenshots() {
let fresh = store::ScanReport::default();
assert!(
store::auto_scan_due(&fresh, false),
"首屏(没扫过、没在扫)应该自动扫一次"
);
assert!(
!store::auto_scan_due(&fresh, true),
"截图通道不许读本机装了什么"
);
let running = store::ScanReport {
running: true,
..store::ScanReport::default()
};
assert!(!store::auto_scan_due(&running, false), "正在扫就不重入");
let finished = store::ScanReport {
finished: true,
..store::ScanReport::default()
};
assert!(
!store::auto_scan_due(&finished, false),
"扫过一次就不再自动扫(手动「立即扫描」仍可再扫)"
);
// 被取消过的扫描:`finished` 仍是 false(结论作废),但**不许**自动重来——否则
// `launcher.json` 每变一次,心跳就会把用户刚做的取消无声撤销(r2 复审 P2-2)。
let cancelled = store::ScanReport {
cancelled: true,
..store::ScanReport::default()
};
assert!(
!store::auto_scan_due(&cancelled, false),
"取消过一次之后,本进程不再自动扫描"
);
// 手动「立即扫描」走的是 `ScanSlot::start`,不看这个判据 ⇒ 取消不是永久锁死。
assert!(
store::auto_scan_due(&store::ScanReport::default(), false),
"全新进程(没扫过、没取消)仍然自动扫一次"
);
}
@@ -0,0 +1,318 @@
//! `tests.rs` 的第三块(M175 r2):手动名单的落盘 / 扫描槽的并发与取消 / 错误文案的 i18n。
//!
//! 守卫①~④(列表来自真扫盘、非法路径被拒、内存越界、内存真进命令行)在 `tests.rs` 与
//! `tests/cmdline.rs`;这一块是 r1 复审补的三条(P2-3 / P2-5 / P2-2)。
use super::super::state::{Refusal, RuntimeRow, State, Unwritable};
use super::super::store;
use super::{config, fake_jdk, java_bin, root};
// --------------------------------------------------------------- 守卫⑤:手动名单真落盘(P2-3)
/// 手动加一条合法路径 → **从盘上新读一份** → 名单里有它;「重启」之后仍在。
///
/// 为什么用注入的探测函数:`probe_manual` 要真跑 `java -version`,用例不能依赖本机装没装 JDK;
/// 但**落盘那一段走的是生产代码**(`manual::save_paths` + `manual::load_paths`),所以这条
/// 是「持久化」的正面断言,而不是「内存里的 vec 有它」。
///
/// 变异证明:删掉 `store::add_manual_with` 里的 `manual::save_paths(...)` 那一行 ⇒ 这条红
/// (`load_manual` 从盘上读回 0 条);把去重那段删掉 ⇒ 末尾那条 `AddDuplicate` 断言红。
#[test]
fn a_manual_path_is_written_to_disk_and_survives_a_restart() {
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
let dir = root("manual-ok");
let file = config(&dir);
let jdk = fake_jdk(&dir, "manual-jdk");
let binary = jdk.join("bin").join(java_bin());
let runtime = JavaRuntime {
binary: binary.clone(),
origin: JavaOrigin::System,
version: Some("openjdk version \"21.0.4\" 2024-07-16 LTS".to_string()),
major: Some(21),
bits: 64,
vendor: Some("Temurin".to_string()),
};
let probe = |_input: &str| Ok(runtime.clone());
// 刚加之前:盘上什么都没有。
assert!(store::load_manual(&file).is_empty());
let (added, row) = store::add_manual_with(&file, &jdk.display().to_string(), &probe)
.expect("合法路径必须加得进去");
assert_eq!(added, binary.display().to_string());
assert_eq!(row.major, Some(21));
// **从盘上新读一份**(不是看内存里的 vec)。
let listed = store::load_manual(&file);
assert_eq!(listed.len(), 1, "名单必须真的写到了盘上:{listed:?}");
assert_eq!(listed[0], binary.display().to_string());
assert!(
store::manual_file(&file).is_file(),
"名单文件必须真的存在:{:?}",
store::manual_file(&file)
);
// 「重启」:一个全新的 State 从盘上灌,名单还在。
let reborn = State {
manual: store::load_manual(&file),
..State::default()
};
assert_eq!(reborn.manual.len(), 1);
// 再加同一份 ⇒ 重复(去重也走盘上的名单)。
assert_eq!(
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
Some(Refusal::AddDuplicate)
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 守卫⑥:扫描只许一个线程(P2-5)
static JOB_LIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static JOB_PEAK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static JOB_RELEASE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 一个「卡住」的扫描作业:直到被取消或 `JOB_RELEASE` 放开才返回。
fn blocking_job(
_candidates: &[unlucky_core::game::java::staged::Candidate],
cancelled: &dyn Fn() -> bool,
_progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
use std::sync::atomic::Ordering;
let live = JOB_LIVE.fetch_add(1, Ordering::SeqCst) + 1;
JOB_PEAK.fetch_max(live, Ordering::SeqCst);
while !JOB_RELEASE.load(Ordering::SeqCst) && !cancelled() {
std::thread::sleep(std::time::Duration::from_millis(10));
}
JOB_LIVE.fetch_sub(1, Ordering::SeqCst);
Vec::new()
}
/// 扫描进行中点「立即扫描」不许起第二个线程;「取消」必须让线程**真的退出**(之后才允许再扫)。
///
/// 变异证明:把 `start_with` 开头那道 `if report.running { return false }` 删掉 ⇒ 第二次
/// `start_with` 返回 `true`、`JOB_PEAK` 变成 2,这条红;把 `cancel()` 里置 `cancelled` 那两行
/// 删掉 ⇒ 作业永远看不到取消、`report().running` 不退,超时断言红。
#[test]
fn only_one_scan_thread_can_run_and_cancel_finishes_it() {
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
JOB_RELEASE.store(false, Ordering::SeqCst);
JOB_PEAK.store(0, Ordering::SeqCst);
let slot = store::ScanSlot::default();
assert!(
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"第一次扫描应该能起来"
);
assert!(
!slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"扫描进行中不许起第二个线程"
);
assert!(slot.report().running, "线程活着时 running 必须为真");
slot.cancel();
assert!(slot.report().cancelled, "取消之后要标记 cancelled");
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!slot.report().running,
"取消必须让扫描线程在预算内真的退出(不是只把界面翻成「已取消」)"
);
assert!(!slot.report().finished, "被取消的扫描不许把结论当真");
assert_eq!(
JOB_PEAK.load(Ordering::SeqCst),
1,
"任何时刻只有一个扫描线程"
);
// 线程真退出之后允许重新扫(这就是「取消 ≠ 永久锁死」)。
JOB_RELEASE.store(true, Ordering::SeqCst);
assert!(
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
"上一个线程退出之后应该能重新扫"
);
let deadline = Instant::now() + Duration::from_secs(5);
while slot.report().running && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(slot.report().finished, "这次应该正常扫完");
JOB_RELEASE.store(false, Ordering::SeqCst);
}
// --------------------------------------------------------------- 守卫⑦:错误文案是 i18n 的(P2-2)
/// 用户可见的失败原因**全部**来自 catalog:三档「写不进去」各有各的一句,手动路径的
/// 「太长 / 写不进名单」也不再顶替「路径是空的 / 路径上没有东西」。
///
/// 抓得住:①三档文案串台(只读说成「没有改动」);②新加的原因复用旧 key;③把中文硬编码回
/// `store.rs`(源码扫描那一段)。
/// 抓不住:译文翻得好不好(那是金表与人工的事)。
///
/// 变异证明:`Unwritable::ReadOnly` 的 key 改成 `SettingsJavaSkipNotDirty` ⇒ 这条红;
/// 把 `store.rs` 里加回一句硬编码中文 ⇒ 源码扫描那段红。
#[test]
fn the_unwritable_reasons_have_distinct_text() {
use unlucky_i18n::{Key, Lang};
let en = Lang::En.catalog();
let zh = Lang::ZhCn.catalog();
let reasons = [
Unwritable::NotDirty,
Unwritable::ReadOnly,
Unwritable::Unreadable,
];
for reason in reasons {
assert_ne!(
en.text(reason.label()),
zh.text(reason.label()),
"{} 未翻译",
reason.label().name()
);
}
for (index, left) in reasons.iter().enumerate() {
for right in reasons.iter().skip(index + 1) {
assert_ne!(
en.text(left.label()),
en.text(right.label()),
"「写不进去」的两档用了同一句文案:{} / {}",
left.label().name(),
right.label().name()
);
}
}
// 手动路径的两档新原因不许顶替旧原因(P2-2 的第三条与第四条)。
assert_eq!(
Refusal::AddTooLong.label(),
Key::SettingsJavaReasonAddTooLong
);
assert_eq!(
Refusal::AddWriteFailed.label(),
Key::SettingsJavaReasonAddWriteFailed
);
assert_ne!(Refusal::AddTooLong.label(), Refusal::AddEmpty.label());
assert_ne!(Refusal::AddWriteFailed.label(), Refusal::AddMissing.label());
// 源码扫描:`store.rs` 里不许再有「把 Rust 枚举名或中文字符串当人话」的写法。
let source = include_str!("../store.rs");
assert!(
!source.contains("format!(\"{refusal:?}\")"),
"不许把 Rust 枚举名(Refusal 的 Debug 串)当用户文案"
);
for hardcoded in ["没有改动", "配置文件只读", "配置文件不可信"] {
// 只认**字符串字面量**(带引号):文档注释里出现这几个词是正常的说明。
let literal = format!("\"{hardcoded}\"");
assert!(
!source.contains(&literal),
"store.rs 里还有硬编码中文字符串:{literal}"
);
}
}
/// `core` 的每种拒绝原因都要落到**自己的**页面原因上(P2-2 的第三、四条)。
///
/// 抓得住:`ManualFailure::TooLong` 被写成 `Refusal::AddEmpty`(界面会对一条 600 字符的路径说
/// 「路径是空的」)、名单写盘失败被写成 `Refusal::AddMissing`(磁盘写不进去却说「路径上没有东西」)。
/// 抓不住:文案翻得好不好(那是金表与人工的事)。
///
/// 变异证明:`failure_refusal` 里 `TooLong => AddTooLong` 改成 `AddEmpty` ⇒ 本条红;
/// 写盘失败那行 `map_err(|_| AddWriteFailed)` 改成 `AddMissing` ⇒ 本条红。
#[test]
fn every_manual_failure_maps_to_its_own_reason() {
use unlucky_core::game::java::manual::ManualFailure;
let dir = root("manual-map");
let file = config(&dir);
for (failure, expected) in [
(ManualFailure::Empty, Refusal::AddEmpty),
(ManualFailure::TooLong, Refusal::AddTooLong),
(ManualFailure::Missing, Refusal::AddMissing),
(ManualFailure::NotJava, Refusal::AddNotJava),
(ManualFailure::NoVersion, Refusal::AddNoVersion),
] {
let probe = move |_input: &str| Err(failure);
assert_eq!(
store::add_manual_with(&file, "whatever", &probe).err(),
Some(expected),
"{failure:?} 应该映射到 {expected:?}"
);
}
// 一个都没写进去。
assert!(store::load_manual(&file).is_empty());
std::fs::remove_dir_all(&dir).ok();
}
/// 名单文件写不进去(父路径是个文件)时,报的是「写不进名单」而不是「路径上没有东西」。
#[test]
fn a_failed_list_write_reports_the_write_not_the_path() {
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
let dir = root("manual-write-fail");
// `<dir>/config` 做成**文件**:`manual_file` 的父目录建不出来 ⇒ save_paths 失败。
// (`root()` 会先建好 `config/`,所以这里先删掉再放一个同名文件。)
let blocker = dir.join("config");
std::fs::remove_dir_all(&blocker).expect("删掉空 config 目录");
std::fs::write(&blocker, b"not a directory").expect("写占位文件");
let file = blocker.join("launcher.json");
let jdk = fake_jdk(&dir, "manual-jdk");
let binary = jdk.join("bin").join(java_bin());
let probe = |_input: &str| {
Ok(JavaRuntime {
binary: binary.clone(),
origin: JavaOrigin::System,
version: Some("openjdk version \"17.0.13\" 2024-10-15".to_string()),
major: Some(17),
bits: 64,
vendor: Some("Temurin".to_string()),
})
};
assert_eq!(
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
Some(Refusal::AddWriteFailed),
"磁盘写不进去要说「写不进名单」,不能说「路径不存在」"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- r2 P2-1:空态不许指向不存在的页
/// 空态给的那条「还有别的办法」必须是**真的存在**的入口。
///
/// 现场(r2 复审 P2-1):原文写「或者去 Java 页装一个」,而 GUI 里根本没有 Java 页
/// (`presenter::router::Route` 只有 Instances / Download / Content / Logs / Settings /
/// Accounts / Detail / Agent,`spawn_install` 在 `crates/app-iced/src` 里零消费者)。
/// 于是「一个 JDK 都没装」的用户——正是这个 mission 要救的那位——被指到一个不存在的入口。
///
/// 抓得住:文案被改回「Java 页 / the Java page」,或把命令行入口那半句删掉。
/// 抓不住:命令行的措辞好不好读(那是人工的事)。
///
/// 变异证明:把 `SettingsJavaEmptyBody` 改回「或者去 Java 页装一个」⇒ 本条红。
#[test]
fn the_empty_state_points_at_a_real_entry() {
use unlucky_i18n::Lang;
for lang in [Lang::En, Lang::ZhCn] {
let text = lang
.catalog()
.text(unlucky_i18n::Key::SettingsJavaEmptyBody);
assert!(
text.contains("ull java install"),
"{}:空态要给一条真能走的路(命令行)——{text}",
lang.tag()
);
for dead in ["Java page", "Java 页"] {
assert!(
!text.contains(dead),
"{}:空态指向了不存在的入口「{dead}」——{text}",
lang.tag()
);
}
}
}
@@ -0,0 +1,284 @@
//! 「Java」分组的一张卡片(M175)。
//!
//! ## 版面(自上而下)
//!
//! | 行 | 内容 | 数值 / 证据 |
//! |---|---|---|
//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 |
//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip |
//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] |
//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 |
//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) |
//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` |
//! | 动作 | 保存 | 灰掉时下面是原因 |
//!
//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发
//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::selection::{chip, Chip};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::Message;
use crate::theme::Ui;
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
use super::state::{Progress, RuntimeRow, State};
use super::Message as JavaMessage;
use crate::pages::settings::{GroupMessage, States};
/// 一条本组消息 → 页面消息。
pub(super) fn msg(message: JavaMessage) -> Message {
Message::Settings(GroupMessage::Java(message))
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.java, lang)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(runtime_list(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(add_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(memory_field(ui, catalog, state));
stacked = stacked.push(jvm_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
stacked = stacked.push(notice_line(ui, catalog, notice));
}
card(ui, stacked)
}
/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。
///
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let (text, scanning) = match (state.scanning, state.scanned) {
(Some(_), _) if state.cancelled => {
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
}
// 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。
(Some(Progress { total: 0, .. }), _) => (
catalog.text(Key::SettingsJavaScanningStart).to_string(),
true,
),
(Some(Progress { done, total }), _) => (
catalog.render(
Key::SettingsJavaScanning,
&[
("done", done.to_string().as_str()),
("total", total.to_string().as_str()),
],
),
true,
),
(None, true) if state.runtimes.is_empty() => {
(catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false)
}
(None, true) => (
catalog.render(
Key::SettingsJavaScanDone,
&[("count", state.runtimes.len().to_string().as_str())],
),
false,
),
(None, false) => (
catalog.text(Key::SettingsJavaNeverScanned).to_string(),
false,
),
};
let scan_label = catalog.text(Key::SettingsJavaScan);
let scan = if scanning || !state.storage {
buttons::filled(ui, Button::disabled(scan_label))
} else {
buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan)))
};
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
let cancel = buttons::tonal(
ui,
if scanning && !state.cancelled {
Button::new(
catalog.text(Key::SettingsJavaCancel),
msg(JavaMessage::CancelScan),
)
} else {
Button::disabled(catalog.text(Key::SettingsJavaCancel))
},
);
// 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。
let cancelled = (state.cancelled && state.scanning.is_none()).then(|| {
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsJavaCancelled).to_string(),
ui.color(Role::OnSurfaceVariant),
)
});
let mut block = column![row![
container(text_role_owned(
ui,
TypeRole::BodyMedium,
true,
text,
ui.color(if state.scanning.is_some() {
Role::Primary
} else {
Role::OnSurface
}),
))
.width(Length::Fill),
scan,
cancel,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center),]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if let Some(line) = cancelled {
block = block.push(line);
}
block.into()
}
/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。
fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
if state.runtimes.is_empty() {
let body = if state.scanned {
catalog.text(Key::SettingsJavaEmptyBody)
} else {
catalog.text(Key::SettingsJavaNeverScannedBody)
};
return text_role_owned(
ui,
TypeRole::BodySmall,
false,
body.to_string(),
ui.color(Role::OnSurfaceVariant),
);
}
for runtime in &state.runtimes {
list = list.push(runtime_row(ui, catalog, state, runtime));
}
list.into()
}
fn runtime_row<'a>(
ui: &'a Ui,
catalog: Catalog,
state: &'a State,
runtime: &'a RuntimeRow,
) -> Element<'a, Message> {
let is_default = super::state::same_path(&runtime.binary, &state.selected);
let satisfied = state.satisfied(runtime.major);
let (verdict, verdict_color) = match satisfied {
Some(true) => (
catalog.text(Key::SettingsJavaFits).to_string(),
ui.color(Role::Primary),
),
Some(false) => {
let required = state
.context
.required
.as_ref()
.map(|required| required.minimum.to_string())
.unwrap_or_default();
(
catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]),
ui.color(Role::Error),
)
}
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
};
let action = if is_default {
chip(
ui,
// 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉
// 「禁用态 chip 上还挂着一条真消息」的残留。
Chip::badge(catalog.text(Key::SettingsJavaDefault)),
)
} else {
chip(
ui,
Chip::filter(
catalog.text(Key::SettingsJavaSetDefault),
false,
msg(JavaMessage::SelectRuntime(runtime.binary.clone())),
),
)
};
let mut block = column![row![
container(
column![
text_role_owned(
ui,
TypeRole::BodyMedium,
is_default,
runtime.display_home(),
ui.color(Role::OnSurface),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
format!(
"{} · {}",
runtime.summary(),
catalog.text(runtime.source_key())
),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill),
)
.width(Length::Fill),
action,
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !verdict.is_empty() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
verdict,
verdict_color,
));
}
block.into()
}
+18 -3
View File
@@ -271,6 +271,12 @@ const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。
const AI_GROUP: usize = ai_about::AI_GROUP;
/// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。
///
/// M175 起这一组也按下标分发:它要 `Lang`(中英文案)并且要读后台扫描的快照
/// (扫描线程不碰 `App`,结论由心跳灌进 `Groups.java`,页面只从 [`States`] 里取)。
const JAVA_GROUP: usize = java::JAVA_GROUP;
/// 整页。
///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -307,9 +313,15 @@ pub fn view<'a>(
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。
/// **分发点(两条路,M106 起就并存)**:
///
/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回
/// 演示行),`None` 直接画演示行([`frame::demo_card`]);
/// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang`
/// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body`
/// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。
///
/// 除此之外这里不认识任何一组的字段:分组名只作为 `GROUPS` 元素的 id 出现。
fn form<'a>(
ui: &'a Ui,
only: Option<usize>,
@@ -339,6 +351,9 @@ fn form<'a>(
} else if index == AI_GROUP {
// M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。
ai::view::body(ui, states, language.effective)
} else if index == JAVA_GROUP {
// M175:Java 组要 i18n 文案 + 扫描快照,同样按下标分发(`GROUP.body` 保持 `None`)。
java::view::body(ui, states, language.effective)
} else {
group
.body
@@ -1,33 +1,30 @@
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有,
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
//! 「Java」分组自己的断言(M100 起归 java 线;M175 重写)。
//!
//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线,
//! 见 `tests/mod.rs` 的 `every_line_can_be_wired_alone` 说明):
//! 1. `settings/java.rs`(`body = Some(java::body)` + 渲染器);
//! 2. 本文件 `the_java_group_is_still_demo_rows`(把 `is_none()` 改成 `is_some()`)。
//! M101 的三条里有两条在 M175 换掉了:
//!
//! download / general 同型。
//! | 旧断言 | M175 之后 |
//! |---|---|
//! | `the_java_group_shows_runtime_memory_and_jvm_arguments`(逐字钉住 `Temurin 21.0.4` / `4 GiB`) | 换成「演示行必须是空的」——那三行假数据正是用户实测投诉的源头 |
//! | `the_java_group_is_still_demo_rows`(断言 `body.is_none()`) | 保留(`body` 仍是 `None`:本组走按下标分发,见 `settings/java.rs` 的文件头) |
//! | `the_java_group_keeps_the_folder_icon_from_the_visual_review` | 不动 |
//!
//! 判据(M100 交付里正面回答的那条):给 java 加一条**新断言** = **只改本文件**。
//! 真数据源的断言在 `pages/settings/java/tests.rs`(扫描 / 校验 / 落盘 / 真命令行),
//! 这里只守「左列那一格」——两处的分工与 M100 的 section 划分一致。
use super::super::java;
use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
/// 这一组**不再有演示行**:三个假值(运行时 / 内存 / JVM 参数)已经换成真控件。
///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。
/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。
/// 抓得住:谁把演示行加回来(那会让 harness 路径又画出一份「看起来像真检测」的假数据);
/// 也抓得住「接真控件却把 `rows` 留着」这种半吊子状态。
/// 抓不住:真控件里画了什么(那是 `pages/settings/java/tests.rs` 的事)。
#[test]
fn the_java_group_shows_runtime_memory_and_jvm_arguments() {
let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec();
assert_eq!(
rows,
vec![
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
]
fn the_java_group_has_no_demo_rows_left() {
assert!(
java::GROUP.rows.is_empty(),
"Java 组不该再有演示行(真值来自扫描 / 手动路径 / launcher.json):{:?}",
java::GROUP.rows
);
}
@@ -41,17 +38,14 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
assert_eq!(java::GROUP.icon, IconKind::Folder);
}
/// **绊线**:这一组还没接真控件,画的是演示行。
/// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。
///
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
/// 抓不住:界面上真控件长什么样。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。
/// 抓得住:谁把 `body: Some(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。
/// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。
#[test]
fn the_java_group_is_still_demo_rows() {
fn the_java_group_is_dispatched_by_index_not_by_body() {
assert!(
java::GROUP.body.is_none(),
"Java 还没接真控件:接了就改本断言"
"Java 组走按下标分发(要 Lang),body 必须保持 None"
);
}
@@ -88,18 +88,30 @@ fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
// --------------------------------------------------------------- 注册表(四组一起看)
/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。
/// 守的是**注册表的结构不变量**:每组都有名字、图标,且「有真控件分发」或「有演示行」至少占一样。
///
/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组,
/// 而「新增一组忘了填 rows」正是这条要抓的。
/// 下放不了:它遍历的是 `GROUPS`,单组文件拿不到其它组,而「新增一组忘了填 rows、又没有分发」
/// 正是这条要抓的。
///
/// M175 起 Java 组的 `rows` 是**空**的:它那三行演示值(`Temurin 21.0.4` / `4 GiB` /
/// `Automatic`)正是用户实测投诉的假数据源,真控件接上之后演示行就删干净了。所以判据从
/// 「必须有 rows」放宽成「必须有 rows **或** 有别的分发路径」——按下标分发的那几组
/// (general / update / assistant / java)在 `settings/mod.rs` 的 `form` 里各有 `else if`,
/// 本断言只对「既没有 rows 又不在这张名单里」的组报错。
#[test]
fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len());
// 按下标分发(`settings/mod.rs` 的 `form`):这些组的正文不在 `Group.rows` 里。
const INDEX_DISPATCHED: [&str; 4] = ["General", "Updates", "Assistant", "Java"];
for group in &GROUPS {
assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
assert!(
!group.rows.is_empty() || INDEX_DISPATCHED.contains(&group.name),
"{} 既没有设置行,也不在按下标分发的名单里",
group.name
);
for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty());
}