Merge branch 'feat/m175-javapage-wire-java-settings-to-real'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-08 20:09:04 +08:00
commit 329c529bea
26 files changed
+4028 -83

No files matched your search

@@ -151,6 +151,8 @@ impl App {
app.rebuild_ui(); app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
app.hydrate_ai_settings(); app.hydrate_ai_settings();
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
app.hydrate_java_settings();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log(); app.lang.log();
+2
View File
@@ -87,6 +87,8 @@ impl App {
self.has_screenshot() self.has_screenshot()
|| !self.state.toasts.is_empty() || !self.state.toasts.is_empty()
|| !self.state.running().is_empty() || !self.state.running().is_empty()
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|| self.settings.java.scanning.is_some()
|| self || self
.watch_until .watch_until
.is_some_and(|deadline| Instant::now() < deadline) .is_some_and(|deadline| Instant::now() < deadline)
+5
View File
@@ -49,6 +49,9 @@ impl App {
self.font_seen = font_status; self.font_seen = font_status;
self.view_dirty = true; self.view_dirty = true;
} }
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
self.hydrate_java_scan();
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s, // `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。 // 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS)); let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
@@ -77,6 +80,8 @@ impl App {
self.reload_appearance(); self.reload_appearance();
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。 // M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
self.hydrate_ai_settings(); self.hydrate_ai_settings();
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
self.hydrate_java_settings();
} }
if self.view_dirty { if self.view_dirty {
+272 -14
View File
@@ -1,33 +1,291 @@
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。 //! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个 //! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。 //! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
//! //!
//! 消息进来的通道有两条,都不经过共享文件: //! | 消息 | 做什么 |
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`; //! |---|---|
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))` //! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
//! (`app::Message::Page` 是既有通道,不用加新变体)。 //! | `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 super::super::*;
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
impl App { impl App {
/// Java 分组的一条消息。 /// Java 分组的一条消息。
pub(super) fn on_settings_java( pub(super) fn on_settings_java(
&mut self, &mut self,
message: pages::settings::java::Message, message: pages::settings::java::Message,
) -> Task<Message> { ) -> Task<Message> {
use pages::settings::java::Message;
match message { match message {
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。 Message::Scan => return self.start_java_scan(),
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。 Message::CancelScan => {
#[cfg(test)] self.settings.java.scan.cancel();
pages::settings::java::Message::Ping => { 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; self.view_dirty = true;
Task::none() 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(); let mut app = headless();
app.view_dirty = false; app.view_dirty = false;
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
let _ = app.update(Message::Page(pages::Message::Settings( 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!( assert!(
@@ -269,7 +273,7 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
// --------------------------------------------------------------- M174:助手配置的活路径 // --------------------------------------------------------------- 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!( let dir = std::env::temp_dir().join(format!(
"ull-ai-app-{label}-{}-{}", "ull-ai-app-{label}-{}-{}",
std::process::id(), std::process::id(),
@@ -386,3 +390,7 @@ fn testing_without_configuration_does_nothing() {
); );
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); 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) 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)。 /// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
/// ///
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的 /// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
+67 -27
View File
@@ -1,44 +1,84 @@
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。 //! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件), //! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里:
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
//! //!
//! 图标说明(视觉复审 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 unlucky_design::icons::IconKind;
use super::Group; use super::Group;
/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。
///
/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。
pub(crate) const JAVA_GROUP: usize = 2;
/// Java 分组。 /// Java 分组。
pub(super) const GROUP: Group = Group { pub(super) const GROUP: Group = Group {
name: "Java", name: "Java",
icon: IconKind::Folder, icon: IconKind::Folder,
rows: &[ // 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。
("Runtime", "Temurin 21.0.4"), rows: &[],
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
],
body: None, body: None,
}; };
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。 /// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。
///
/// 加字段只改本文件:`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))。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 → /// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来)
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。 Scan,
/// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的 /// 取消正在跑的扫描
/// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。 CancelScan,
#[cfg(test)] /// 手动路径输入框
Ping, 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`。 /// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。
const AI_GROUP: usize = ai_about::AI_GROUP; 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 接进来的外观运行期状态 /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -307,9 +313,15 @@ pub fn view<'a>(
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane), /// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。 /// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
/// ///
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去 /// **分发点(两条路,M106 起就并存)**:
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行 ///
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。 /// 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>( fn form<'a>(
ui: &'a Ui, ui: &'a Ui,
only: Option<usize>, only: Option<usize>,
@@ -339,6 +351,9 @@ fn form<'a>(
} else if index == AI_GROUP { } else if index == AI_GROUP {
// M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。 // M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。
ai::view::body(ui, states, language.effective) 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 { } else {
group group
.body .body
@@ -1,33 +1,30 @@
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有, //! 「Java」分组自己的断言(M100 起归 java 线;M175 重写)。
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
//! //!
//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线, //! M101 的三条里有两条在 M175 换掉了:
//! 见 `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()`)。
//! //!
//! 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 super::super::java;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。 /// 这一组**不再有演示行**:三个假值(运行时 / 内存 / JVM 参数)已经换成真控件。
/// ///
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错); /// 抓得住:谁把演示行加回来(那会让 harness 路径又画出一份「看起来像真检测」的假数据);
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。 /// 也抓得住「接真控件却把 `rows` 留着」这种半吊子状态。
/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。 /// 抓不住:真控件里画了什么(那是 `pages/settings/java/tests.rs` 的事)。
#[test] #[test]
fn the_java_group_shows_runtime_memory_and_jvm_arguments() { fn the_java_group_has_no_demo_rows_left() {
let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec(); assert!(
assert_eq!( java::GROUP.rows.is_empty(),
rows, "Java 组不该再有演示行(真值来自扫描 / 手动路径 / launcher.json):{:?}",
vec![ java::GROUP.rows
("Runtime", "Temurin 21.0.4"),
("Default memory", "4 GiB"),
("JVM arguments", "Automatic"),
]
); );
} }
@@ -41,17 +38,14 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
assert_eq!(java::GROUP.icon, IconKind::Folder); assert_eq!(java::GROUP.icon, IconKind::Folder);
} }
/// **绊线**:这一组还没接真控件,画的是演示行。 /// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。
/// ///
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。 /// 抓得住:谁把 `body: Some(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。
/// 抓不住:界面上真控件长什么样。 /// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。
///
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。
#[test] #[test]
fn the_java_group_is_still_demo_rows() { fn the_java_group_is_dispatched_by_index_not_by_body() {
assert!( assert!(
java::GROUP.body.is_none(), 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`(四组拼出来的数组),单组文件拿不到其它三组, /// 下放不了:它遍历的是 `GROUPS`,单组文件拿不到其它组,而「新增一组忘了填 rows、又没有分发」
/// 而「新增一组忘了填 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] #[test]
fn every_group_has_a_name_icon_and_rows() { fn every_group_has_a_name_icon_and_rows() {
assert!(GROUPS.len() >= 3); assert!(GROUPS.len() >= 3);
assert!(SELECTED < GROUPS.len()); assert!(SELECTED < GROUPS.len());
// 按下标分发(`settings/mod.rs` 的 `form`):这些组的正文不在 `Group.rows` 里。
const INDEX_DISPATCHED: [&str; 4] = ["General", "Updates", "Assistant", "Java"];
for group in &GROUPS { for group in &GROUPS {
assert!(!group.name.is_empty()); assert!(!group.name.is_empty());
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name); 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 { for (label, value) in group.rows {
assert!(!label.is_empty() && !value.is_empty()); assert!(!label.is_empty() && !value.is_empty());
} }
+324
View File
@@ -0,0 +1,324 @@
//! 用户手动指定的 JDK 路径(M175):校验、厂商识别、名单持久化。
//!
//! 场景:MSI 装的 JDK 落在 `%ProgramFiles%` 之外的盘符上、或者用户自己解压的一份 JDK ——
//! [`super::discover`] 的标准位置扫描看不见它们,用户只能在设置页里**手动指一个路径**。
//!
//! # 三条口径
//!
//! 1. **收目录也收可执行文件**:两样都是用户会粘的东西,统一折成 `bin/java[.exe]`;
//! 2. **校验就是真跑一次 `java -version`**:目录存在、里面有 `bin/java` 都不算数——
//! 一份放错的 `java`(空文件、别的平台的可执行文件)必须在这里被拒,而不是等启动时崩;
//! 3. **这份名单是 UI 的持久化,不是启动策略**:真正「用哪个 Java 启动」仍由
//! `java.selected_path`(core 的 schema 键)决定,名单只是「我手动加过哪些」的记忆。
//!
//! 名单落一个纯文本文件(一行一个路径):它是 UI 侧的登记表,不参与 schema 校验,
//! 也不该借道 `launcher.json`(那条路要新键 ⇒ 动 schema 版本与迁移,代价远大于收益)。
use std::path::{Path, PathBuf};
use super::extract::java_binary_name;
use super::probe::{self, JavaOrigin, JavaRuntime};
/// 用户可粘路径的长度上限(防手滑粘进来一整段日志)。
pub const MAX_PATH_CHARS: usize = 512;
/// 手动路径被拒的原因(稳定 id 给 UI 选文案;不做本地化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManualFailure {
/// 空串 / 全空白
Empty,
/// 超过 [`MAX_PATH_CHARS`]
TooLong,
/// 盘上没这个文件或目录
Missing,
/// 有这个东西,但里面没有 `bin/java[.exe]`(或给的不是一个 java 可执行文件)
NotJava,
/// 有 `java`,但 `java -version` 跑不起来 / 读不出版本号
NoVersion,
}
impl ManualFailure {
/// 稳定 id(UI 文案 key 的后缀,见 `pages/settings/java`)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Empty => "empty",
Self::TooLong => "too-long",
Self::Missing => "missing",
Self::NotJava => "not-java",
Self::NoVersion => "no-version",
}
}
}
/// 用户给的路径 → `java` 可执行文件(不探测,只看文件系统)。
///
/// # Errors
/// 空 / 太长 / 不存在 / 不是 java 可执行文件时返回原因。
pub fn resolve_binary(input: &str) -> Result<PathBuf, ManualFailure> {
let text = input.trim();
if text.is_empty() {
return Err(ManualFailure::Empty);
}
if text.chars().count() > MAX_PATH_CHARS {
return Err(ManualFailure::TooLong);
}
let path = Path::new(text);
if path.is_file() {
return looks_like_java(path)
.then(|| path.to_path_buf())
.ok_or(ManualFailure::NotJava);
}
if path.is_dir() {
let binary = path.join("bin").join(java_binary_name());
return binary
.is_file()
.then_some(binary)
.ok_or(ManualFailure::NotJava);
}
Err(ManualFailure::Missing)
}
/// 文件名是不是 `java` / `javaw`(Windows 带 `.exe`;POSIX 不区分大小写属于宽容)。
fn looks_like_java(path: &Path) -> bool {
path.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw"))
}
/// 跑一次 `java -version`,同时把**原始输出**交回来(厂商要从整段输出里认,不能只看首行)。
///
/// 为什么需要原始输出:`-version` 的第一行只有 `openjdk version "21.0.11"`,发行方印在**第二行**
/// (`OpenJDK Runtime Environment Microsoft-11230047` / `… Temurin-21.0.4+7`)。只拿首行去认厂商,
/// Microsoft / Temurin 的 JDK 会一律显示成 `OpenJDK`——M175 出图时实测到了这个错。
#[must_use]
pub fn probe_with_output(binary: &Path, origin: JavaOrigin) -> (JavaRuntime, String) {
probe_with_timeout(binary, origin, super::proc::PROBE_TIMEOUT, &|| false)
}
/// 同 [`probe_with_output`],但带**超时与取消**(扫描线程用这一条)。
#[must_use]
pub fn probe_with_timeout(
binary: &Path,
origin: JavaOrigin,
timeout: std::time::Duration,
cancelled: &dyn Fn() -> bool,
) -> (JavaRuntime, String) {
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
let mut command = std::process::Command::new(binary);
command.arg("-version");
let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else {
return (runtime, String::new());
};
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stdout));
if let Some((line, major)) = probe::parse_version_output(&text) {
runtime.major = Some(major);
runtime.version = Some(line);
runtime.bits = probe::bits_of(&text);
}
runtime.vendor = vendor_of(&text);
(runtime, text)
}
/// 严格探测:必须真跑得起来 `-version`。
///
/// 与 [`probe::probe`] 的差别只有一个:后者「探测不出也返回一个 runtime」,这里探测不出就是**拒绝**。
/// 手动添加这条路上,一个答不出话的 `java` 就是无效输入,不能进名单。
///
/// # Errors
/// 校验不过([`resolve_binary`])或 `-version` 读不出版本号时返回原因。
pub fn probe_manual(input: &str) -> Result<JavaRuntime, ManualFailure> {
let binary = resolve_binary(input)?;
let (runtime, _output) = probe_with_output(&binary, JavaOrigin::System);
if runtime.major.is_none() {
return Err(ManualFailure::NoVersion);
}
Ok(runtime)
}
/// `java -version` 输出里的发行方(认不出返回 `None`,不猜)。
///
/// 为什么从输出里认而不是从安装目录名认:用户手动指的路径跟目录命名毫无关系,
/// 而 `java -version` 的首行/次行本来就把发行方印出来了
/// (`OpenJDK Runtime Environment Temurin-21.0.4+7`、`Java(TM) SE Runtime Environment`)。
#[must_use]
pub fn vendor_of(text: &str) -> Option<String> {
let lowered = text.to_ascii_lowercase();
// 顺序即判据:「Temurin 构建的 OpenJDK」也能匹配到 openjdk,所以具体品牌必须排在前面。
const MARKERS: &[(&str, &str)] = &[
("temurin", "Temurin"),
("adoptium", "Temurin"),
("microsoft", "Microsoft"),
("zulu", "Zulu"),
("azul", "Zulu"),
("corretto", "Corretto"),
("semeru", "Semeru"),
("graalvm", "GraalVM"),
("sapmachine", "SapMachine"),
("oracle", "Oracle"),
// 老 Oracle / HotSpot 的 `-version` 不提 "Oracle"(Java 8 就是 `Java(TM) SE Runtime
// Environment` + `Java HotSpot(TM) 64-Bit Server VM`),这两条是那一代的唯一指纹。
("se runtime", "Oracle"),
("hotspot", "Oracle"),
("openjdk", "OpenJDK"),
];
MARKERS
.iter()
.find(|(needle, _)| lowered.contains(needle))
.map(|(_, display)| (*display).to_string())
}
/// 读名单(文件不存在 / 读不动 ⇒ 空名单,不报错)。
///
/// 去掉空行、去重(按 [`probe::discover::path_key`](super::discover::path_key) 的同一套键),
/// 顺序保持文件里的顺序(用户添加的先后)。
#[must_use]
pub fn load_paths(file: &Path) -> Vec<PathBuf> {
let Ok(text) = std::fs::read_to_string(file) else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let path = PathBuf::from(line);
if !paths
.iter()
.any(|known| super::discover::path_key(known) == super::discover::path_key(&path))
{
paths.push(path);
}
}
paths
}
/// 写名单(一行一个,末尾换行;父目录不存在就建)。
///
/// # Errors
/// 目录建不出来或写盘失败时返回 IO 错误。
pub fn save_paths(file: &Path, paths: &[PathBuf]) -> std::io::Result<()> {
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent)?;
}
let mut text = String::new();
for path in paths {
text.push_str(&path.display().to_string());
text.push('\n');
}
std::fs::write(file, text)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-manual-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("临时目录");
dir
}
#[test]
fn recognises_vendors_from_real_version_lines() {
assert_eq!(
vendor_of("OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)"),
Some("Temurin".to_string())
);
assert_eq!(
vendor_of("OpenJDK 64-Bit Server VM Microsoft-8321 (build 17.0.13+11)"),
Some("Microsoft".to_string())
);
assert_eq!(
vendor_of("openjdk version \"17.0.9\" 2023-10-17\nOpenJDK Runtime Environment Zulu17.46+19-CA"),
Some("Zulu".to_string())
);
assert_eq!(
vendor_of(
"java version \"1.8.0_402\"\nJava(TM) SE Runtime Environment (build 1.8.0_402-b06)"
),
Some("Oracle".to_string())
);
assert_eq!(vendor_of("nothing useful here"), None);
}
#[test]
fn accepts_a_directory_and_a_binary_but_rejects_anything_else() {
let dir = temp("resolve");
let jdk = dir.join("jdk-21");
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
let binary = jdk.join("bin").join(java_binary_name());
std::fs::write(&binary, b"").expect("写 java");
assert_eq!(
resolve_binary(&jdk.display().to_string()),
Ok(binary.clone())
);
assert_eq!(resolve_binary(&binary.display().to_string()), Ok(binary));
assert_eq!(resolve_binary(" "), Err(ManualFailure::Empty));
assert_eq!(
resolve_binary(&dir.join("nope").display().to_string()),
Err(ManualFailure::Missing)
);
let empty = dir.join("empty");
std::fs::create_dir_all(&empty).expect("empty");
assert_eq!(
resolve_binary(&empty.display().to_string()),
Err(ManualFailure::NotJava)
);
// 目录里有一个**不叫 java 的**可执行文件:也不算。
let text = dir.join("notes.txt");
std::fs::write(&text, b"hi").expect("写文本");
assert_eq!(
resolve_binary(&text.display().to_string()),
Err(ManualFailure::NotJava)
);
let long = "a".repeat(MAX_PATH_CHARS + 1);
assert_eq!(resolve_binary(&long), Err(ManualFailure::TooLong));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_binary_that_cannot_answer_version_is_refused() {
let dir = temp("noversion");
let jdk = dir.join("jdk");
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
// 空文件:解析不出版本号 ⇒ 拒绝(这条正是「文件存在 ≠ 能用」的判据)。
std::fs::write(jdk.join("bin").join(java_binary_name()), b"").expect("写 java");
assert_eq!(
probe_manual(&jdk.display().to_string()).err(),
Some(ManualFailure::NoVersion)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_list_round_trips_and_dedupes() {
let dir = temp("list");
let file = dir.join("config").join("java-extra-paths.txt");
let paths = vec![
PathBuf::from("C:/jdk/17"),
PathBuf::from("C:/jdk/21"),
PathBuf::from("C:\\jdk\\17"),
];
save_paths(&file, &paths).expect("写名单");
// 第三条是第一条的另一种写法:读回来只留一条(与扫描去重同一套键)。
assert_eq!(load_paths(&file).len(), 2);
assert_eq!(load_paths(&file)[0], PathBuf::from("C:/jdk/17"));
let missing = dir.join("nope.txt");
assert!(load_paths(&missing).is_empty(), "缺文件是空名单,不是错误");
std::fs::remove_dir_all(&dir).ok();
}
}
+3
View File
@@ -45,14 +45,17 @@ pub mod error;
pub mod extract; pub mod extract;
pub mod install; pub mod install;
pub mod location; pub mod location;
pub mod manual;
pub mod mapping; pub mod mapping;
pub mod microsoft; pub mod microsoft;
pub mod net; pub mod net;
pub mod oracle; pub mod oracle;
pub mod probe; pub mod probe;
pub mod proc;
pub mod release; pub mod release;
pub mod select; pub mod select;
pub mod source; pub mod source;
pub mod staged;
pub mod target; pub mod target;
pub mod termux; pub mod termux;
pub mod vendor; pub mod vendor;
+169
View File
@@ -0,0 +1,169 @@
//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。
//!
//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不
//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。
use std::time::Duration;
/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。
const POLL_INTERVAL: Duration = Duration::from_millis(20);
/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。
///
/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。
///
/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死,
/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。
#[must_use]
pub fn run_with_timeout(
mut command: std::process::Command,
timeout: Duration,
cancelled: &dyn Fn() -> bool,
) -> Option<std::process::Output> {
use std::process::Stdio;
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().ok()?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if cancelled() || started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
child.wait_with_output().ok()
}
#[cfg(test)]
mod tests {
use super::run_with_timeout;
#[test]
fn a_hung_probe_is_killed_after_the_timeout() {
// 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。
let started = std::time::Instant::now();
let output = run_with_timeout(
sleeping_command(30),
std::time::Duration::from_millis(400),
&|| false,
);
assert!(output.is_none(), "超时必须判成「没有结论」");
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}"
);
}
#[test]
fn a_cancel_interrupts_the_probe_that_is_already_running() {
// 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let tripper = flag.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
tripper.store(true, std::sync::atomic::Ordering::SeqCst);
});
let started = std::time::Instant::now();
let output = run_with_timeout(
sleeping_command(30),
std::time::Duration::from_secs(30),
&|| flag.load(std::sync::atomic::Ordering::SeqCst),
);
assert!(output.is_none(), "被取消的探测没有结论");
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"取消必须打断正在跑的那个探测,而不是等它自己结束"
);
}
/// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。
///
/// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()`
/// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。
///
/// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把
/// 标记文件写出来 ⇒ 本条红。
#[test]
fn a_timed_out_child_is_really_killed() {
let dir = std::env::temp_dir().join(format!(
"ull-proc-kill-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("临时目录");
let marker = dir.join("survived.txt");
let _ = std::fs::remove_file(&marker);
let output = run_with_timeout(
marker_command(&marker, 4),
std::time::Duration::from_millis(300),
&|| false,
);
assert!(output.is_none(), "超时没有结论");
// 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。
std::thread::sleep(std::time::Duration::from_secs(6));
assert!(
!marker.exists(),
"子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。
fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command {
let target = marker.display().to_string();
if cfg!(windows) {
// PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的
// 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用
// PowerShell 的单引号,外层只剩 Rust 加的那一层。
let mut command = std::process::Command::new("powershell");
command.args([
"-NoProfile",
"-Command",
&format!(
"Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive"
),
]);
command
} else {
let mut command = std::process::Command::new("sh");
command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]);
command
}
}
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
fn sleeping_command(seconds: u32) -> std::process::Command {
if cfg!(windows) {
let mut command = std::process::Command::new("cmd");
command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]);
command
} else {
let mut command = std::process::Command::new("sleep");
command.arg(seconds.to_string());
command
}
}
}
+347
View File
@@ -0,0 +1,347 @@
//! 带进度与取消的 Java 扫描(M175):**先收集候选、再逐个探测**。
//!
//! 为什么不能直接调 [`super::discover::candidates_in_roots`]:那条路把「收集」与「探测」揉在一起,
//! 一个 JDK 跑一次 `java -version`(几十到几百毫秒),一台装了七八个 JDK 的机器上整段要几秒。
//! 设置页要的是**能显示 3/7、能中途取消、且不卡住 UI**——于是这里把两段拆开:
//!
//! ```text
//! candidates(roots, manual, system) 纯文件系统,快、无子进程
//! → scan(&candidates, on_progress, cancelled) 逐个探测;任一回调说停就停
//! ```
//!
//! 判据与 [`super::discover`] 一致(同一套去重键、同一套标准位置),差别只有「可观测」这一层——
//! 所以 `ull java list` 与设置页看到的是同一批 JDK。
use std::path::PathBuf;
use super::discover::{self, path_key};
use super::extract::{java_binary_name, read_stamp};
use super::probe::{self, JavaOrigin, JavaRuntime};
use super::vendor::JdkVendor;
/// 候选来源(比 [`JavaOrigin`] 多一个「用户手动加的」,UI 也按它分组)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
/// 托管目录(`ull java install` 装的)
Managed,
/// `JAVA_HOME`
JavaHome,
/// `PATH` 上的 `java`
Path,
/// 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表 / `/usr/lib/jvm`)
System,
/// 用户在设置页手动指的路径
Manual,
}
impl Source {
/// 稳定 id(DTO / UI 文案选档用)。
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Managed => "managed",
Self::JavaHome => "env",
Self::Path => "path",
Self::System => "system",
Self::Manual => "manual",
}
}
/// 展示优先级(小者先;与 [`JavaOrigin::rank`] 同序,手动路径排在系统安装之前——
/// 用户亲手指的是明确的意图)。
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Managed => 1,
Self::JavaHome => 2,
Self::Path => 3,
Self::Manual => 4,
Self::System => 5,
}
}
/// 探测时用的 [`JavaOrigin`](`JavaOrigin` 没有「手动」这一档,用 `System` 承接下来即可:
/// 展示走本枚举的 [`Source::id`],不看 `JavaRuntime.origin`)。
#[must_use]
pub const fn origin(self) -> JavaOrigin {
match self {
Self::Managed => JavaOrigin::Managed,
Self::JavaHome => JavaOrigin::JavaHome,
Self::Path => JavaOrigin::Path,
Self::System | Self::Manual => JavaOrigin::System,
}
}
}
/// 一个待探测的候选。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
/// `bin/java[.exe]` 的绝对路径
pub binary: PathBuf,
/// 来源
pub source: Source,
}
/// 收集候选(**不探测**,所以很快;目录不存在一律跳过)。
///
/// `system = false` 用于测试与「只看托管区」的场景:它跳过 `JAVA_HOME`/`PATH`/标准位置,
/// 于是同一份输入在任何机器上得到同一份候选表(不依赖本机装了什么)。
#[must_use]
pub fn candidates(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
for root in managed_roots {
out.extend(managed_in(root));
}
// 手动路径:目录 → `bin/java`;文件原样(用户在输入框里粘过两种形态,都认)。
for path in manual {
let binary = if path.is_dir() {
path.join("bin").join(java_binary_name())
} else {
path.clone()
};
if binary.is_file() {
out.push(Candidate {
binary,
source: Source::Manual,
});
}
}
if system {
out.extend(env_candidates());
out.extend(
discover::scan_java_binaries()
.into_iter()
.map(|binary| Candidate {
binary,
source: Source::System,
}),
);
}
dedupe(out)
}
/// 托管安装根里的候选(两种布局都认,**必须有戳记**——没戳记的目录可能是用户塞的别的东西)。
fn managed_in(root: &std::path::Path) -> Vec<Candidate> {
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(root) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !path.is_dir() || name.starts_with('.') {
continue;
}
if let Some(binary) = stamped_binary(&path) {
found.push(Candidate {
binary,
source: Source::Managed,
});
continue;
}
// 两层布局 `<vendor>/<version>`:厂商名取自父目录名。
if JdkVendor::parse(&name).is_some() {
let Ok(children) = std::fs::read_dir(&path) else {
continue;
};
for child in children.flatten() {
if let Some(binary) = stamped_binary(&child.path()) {
found.push(Candidate {
binary,
source: Source::Managed,
});
}
}
}
}
found
}
/// 带戳记、且 `bin/java[.exe]` 在盘上的托管目录。
fn stamped_binary(dir: &std::path::Path) -> Option<PathBuf> {
if !dir.is_dir() || read_stamp(dir).is_none() {
return None;
}
let binary = dir.join("bin").join(java_binary_name());
binary.is_file().then_some(binary)
}
/// `JAVA_HOME` 与 `PATH`(与 [`discover::candidates_in_roots`] 同一口径)。
fn env_candidates() -> Vec<Candidate> {
let mut found = Vec::new();
if let Some(home) = std::env::var_os("JAVA_HOME") {
let binary = PathBuf::from(home).join("bin").join(java_binary_name());
if binary.is_file() {
found.push(Candidate {
binary,
source: Source::JavaHome,
});
}
}
if let Some(binary) = probe::resolve_on_path(java_binary_name()) {
found.push(Candidate {
binary,
source: Source::Path,
});
}
found
}
/// 按路径去重,保留**先出现**的(调用方按优先级排好序)。
fn dedupe(candidates: Vec<Candidate>) -> Vec<Candidate> {
let mut seen: Vec<String> = Vec::with_capacity(candidates.len());
let mut kept = Vec::with_capacity(candidates.len());
for candidate in candidates {
let key = path_key(&candidate.binary);
if seen.contains(&key) {
continue;
}
seen.push(key);
kept.push(candidate);
}
kept
}
/// 逐个探测。`on_progress(done, total)` 返回 `false` ⇒ 立刻停(取消),已探测的照样返回。
///
/// `cancelled()` 在一次探测**进行中**也会被查询:卡住的候选会被 [`super::proc::PROBE_TIMEOUT`]
/// 或取消打断,而不是把扫描线程钉死在一个 `-version` 上(见 [`super::proc::run_with_timeout`])。
///
/// 返回的列表按**大版本升序、同版本按路径**排序,探测不出主版本的排在最后
/// (与 [`super::probe::discover_managed`] 的排序口径一致,用户看到的第一行不该是「版本未知」)。
#[must_use]
pub fn scan<F>(
candidates: &[Candidate],
mut on_progress: F,
cancelled: &dyn Fn() -> bool,
) -> Vec<JavaRuntime>
where
F: FnMut(usize, usize) -> bool,
{
let total = candidates.len();
let mut found: Vec<JavaRuntime> = Vec::with_capacity(total);
for (index, candidate) in candidates.iter().enumerate() {
if cancelled() || !on_progress(index, total) {
break;
}
// 发行方要从**整段** `-version` 输出里认(首行只有 `openjdk version "…"`,品牌在第二行)。
// 超时与取消都交给这一层:卡住的子进程会被 kill,取消不必等它自己结束。
let (runtime, _output) = super::manual::probe_with_timeout(
&candidate.binary,
candidate.source.origin(),
super::proc::PROBE_TIMEOUT,
cancelled,
);
found.push(runtime);
}
found.sort_by(|left, right| {
left.major
.unwrap_or(u32::MAX)
.cmp(&right.major.unwrap_or(u32::MAX))
.then_with(|| left.binary.cmp(&right.binary))
});
found
}
/// 候选 + 扫描一步到位(不与 UI 打交道的调用方用;设置页走两段式)。
#[must_use]
pub fn scan_roots(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec<JavaRuntime> {
let list = candidates(managed_roots, manual, system);
scan(&list, |_, _| true, &|| false)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-java-staged-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("临时目录");
dir
}
/// 造一个「看起来像 JDK」的目录(`bin/java` 是空文件 ⇒ 探测出「版本未知」,
/// 但它确实被扫到了——这正是「列表来自真实扫描」要的证据)。
fn fake_jdk(root: &std::path::Path, name: &str) -> PathBuf {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("bin")).expect("bin");
std::fs::write(dir.join("bin").join(java_binary_name()), b"").expect("写 java");
dir
}
#[test]
fn the_list_comes_from_the_disk_not_from_a_constant() {
let root = temp("list");
let one = fake_jdk(&root, "jdk-a");
let two = fake_jdk(&root, "jdk-b");
let found = scan_roots(&[], &[one.clone(), two.clone()], false);
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
assert!(found
.iter()
.any(|runtime| runtime.binary == one.join("bin").join(java_binary_name())));
// 删掉一个:列表必须少一项(证明它读的是盘,不是写死的字符串)。
std::fs::remove_dir_all(&one).expect("删 jdk-a");
let after = scan_roots(&[], &[one, two], false);
assert_eq!(after.len(), 1, "删掉一个 JDK 之后列表必须少一项:{after:?}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_managed_directory_without_a_stamp_is_not_claimed() {
let root = temp("stamp");
// 长得像托管目录,但没有戳记 ⇒ 不是我们装的,不去猜。
fake_jdk(&root, "jdk-21-temurin");
let found = scan_roots(std::slice::from_ref(&root), &[], false);
assert!(found.is_empty(), "无戳记目录不该被认领:{found:?}");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn progress_reports_every_candidate_and_cancel_stops_early() {
let root = temp("progress");
let paths: Vec<PathBuf> = (0..3)
.map(|n| fake_jdk(&root, &format!("jdk-{n}")))
.collect();
let list = candidates(&[], &paths, false);
assert_eq!(list.len(), 3);
let mut seen = Vec::new();
let all = scan(
&list,
|done, total| {
seen.push((done, total));
true
},
&|| false,
);
assert_eq!(seen, vec![(0, 3), (1, 3), (2, 3)], "每个候选都要报一次进度");
assert_eq!(all.len(), 3);
let stopped = scan(&list, |done, _| done < 1, &|| false);
assert_eq!(stopped.len(), 1, "取消之后只留已探测的那一项");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn candidates_dedupe_the_same_jdk_written_two_ways() {
let root = temp("dedupe");
let jdk = fake_jdk(&root, "jdk-x");
let binary = jdk.join("bin").join(java_binary_name());
// 同一份 JDK 用「目录」和「可执行文件」两种写法各登记一次。
let list = candidates(&[], &[jdk, binary], false);
assert_eq!(list.len(), 1, "同一路径只列一次:{list:?}");
std::fs::remove_dir_all(&root).ok();
}
}
+59
View File
@@ -29,6 +29,65 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsAppearanceCornerSquare", "Square", "直角"), ("SettingsAppearanceCornerSquare", "Square", "直角"),
("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"),
("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"),
// M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。
// 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、
// `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在
// `SettingsAppearanceCornerSuspended` 与 `SettingsGroupAssistant` 之间(顺序 = `Key::ALL`)。
("SettingsJavaScan", "Scan now", "立即扫描"),
("SettingsJavaScanningStart", "Scanning…", "正在扫描…"),
("SettingsJavaScanning", "Scanning… {done}/{total}", "正在扫描… {done}/{total}"),
("SettingsJavaCancelling", "Cancelling…", "正在取消…"),
("SettingsJavaCancel", "Cancel", "取消"),
("SettingsJavaCancelled", "The scan was cancelled; the list below is from the previous run.", "扫描已取消;下面的列表还是上一次的结果。"),
("SettingsJavaScanDone", "{count} JDK found", "找到 {count} 个 JDK"),
("SettingsJavaNeverScanned", "Not scanned yet", "还没扫描过"),
("SettingsJavaNeverScannedBody", "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"),
("SettingsJavaEmptyTitle", "No Java runtime found", "未检测到 Java 运行时"),
("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"),
("SettingsJavaDefault", "Default", "默认"),
("SettingsJavaSetDefault", "Use as default", "设为默认"),
("SettingsJavaFits", "Meets the current instance requirement", "满足当前实例的要求"),
("SettingsJavaTooOld", "Below the requirement: this instance needs Java {minimum}+", "低于要求:当前实例需要 Java {minimum} 及以上"),
("SettingsJavaDefaultSet", "Default JDK is now {path}", "默认 JDK 已设为 {path}"),
("SettingsJavaSourceManaged", "installed by this launcher", "本启动器安装"),
("SettingsJavaSourceEnv", "JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"),
("SettingsJavaSourcePath", "on PATH", "PATH 上"),
("SettingsJavaSourceSystem", "system install", "系统安装"),
("SettingsJavaSourceManual", "added by you", "手动添加"),
("SettingsJavaAdd", "Add a JDK path", "手动添加 JDK 路径"),
("SettingsJavaAddPlaceholder", "C:\\Program Files\\Java\\jdk-21", "例:C:\\Program Files\\Java\\jdk-21"),
("SettingsJavaAddHint", "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"),
("SettingsJavaAddAction", "Add", "添加"),
("SettingsJavaAdded", "Added {path}", "已添加 {path}"),
("SettingsJavaMemory", "Default memory (MB)", "默认内存(MB)"),
("SettingsJavaMemoryHint", "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"),
("SettingsJavaLayerGlobal", "In effect: {mb} MB, from the global default.", "生效:{mb} MB,来自全局默认值。"),
("SettingsJavaLayerInstance", "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"),
("SettingsJavaJvm", "Extra JVM arguments", "额外 JVM 参数"),
("SettingsJavaJvmPlaceholder", "-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"),
("SettingsJavaJvmHint", "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"),
("SettingsJavaJvmSaved", "Saved value: {value}", "已保存的值:{value}"),
("SettingsJavaJvmSavedNone", "No extra JVM arguments are saved yet.", "还没有保存任何额外参数。"),
("SettingsJavaSave", "Save", "保存"),
("SettingsJavaSaved", "Saved and read back from disk. The next launch uses the new value.", "已保存,并从磁盘读回确认。下次启动就用新值。"),
("SettingsJavaUnsaved", "There are unsaved changes.", "有未保存的改动。"),
("SettingsJavaRefused", "Not saved: {reason}", "未保存:{reason}"),
("SettingsJavaSkipNotDirty", "there was nothing to change, so no file was written", "没有任何改动,因此没有写文件"),
("SettingsJavaSkipReadOnly", "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", "配置文件只读:它被更新版本的启动器写过,或被标记为不可信"),
("SettingsJavaSkipUnreadable", "the settings file is untrusted, so it is treated as read-only", "配置文件不可信,因此按只读处理"),
("SettingsJavaNoStorage", "No settings file is available in this run, so nothing can be scanned or saved.", "这一次运行没有可用的设置文件,扫描与保存都做不了。"),
("SettingsJavaReasonMemoryEmpty", "the memory field is empty", "内存没填"),
("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"),
("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"),
("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"),
("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"),
("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"),
("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"),
("SettingsJavaReasonAddNoVersion", "java -version did not answer there, so it is not a usable JDK", "在那里跑不出 java -version,不是一份能用的 JDK"),
("SettingsJavaReasonAddTooLong", "the path is too long to be a JDK location", "路径太长,不像是一个 JDK 位置"),
("SettingsJavaReasonAddWriteFailed", "the JDK is fine, but the list file could not be written (disk or permission)", "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"),
("SettingsJavaReasonAddDuplicate", "that JDK is already in the list", "这份 JDK 已经在列表里了"),
("SettingsJavaReasonWriteFailed", "the settings file could not be written (disk full, no permission, or the value was rejected)", "设置写不进去(磁盘满、没权限,或这个值被拒绝)"),
// M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。 // M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。
// 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、 // 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、
// `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与 // `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与
+147 -4
View File
@@ -1,11 +1,154 @@
//! 设置页「Java」分组文案:**预留**(页面侧的真控件还没接)。 //! 设置页「Java」分组文案(M175):扫描 / 运行时列表 / 默认 JDK / 内存 / JVM 参数。
//! //!
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 //! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在
//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 //! `settings_appearance_entries` 之后、`settings_download_entries` 之前,所以金表那批行要插在
//! `SettingsAppearanceCornerSuspended` 之后、`SettingsGroupAssistant`(M174 的助手域)之前
//! ——**顺序即 `Key::ALL`**。
//!
//! 三条口径:
//!
//! 1. 技术名词不翻:`JDK` / `JVM` / `-version` / `bin/java` / `java.selected_path` 原样;
//! 2. **不许出现写死的版本号或数字**:本组以前那三行(`Temurin 21.0.4` / `4 GiB`)正是被用户
//! 当成假数据的那三条,现在所有数值都由页面从真读盘的结果里格式化进来;
//! 3. 拒绝原因一句一条(空 / 不存在 / 不是 java / 跑不出 -version / 重复),合成一句
//! 「路径无效」等于让用户自己猜。
macro_rules! settings_java_entries { macro_rules! settings_java_entries {
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
$next! { $($rest,)* { $($acc)* } } $next! { $($rest,)* {
$($acc)*
// ── 扫描(状态 / 进度 / 取消)
SettingsJavaScan => ("Scan now", "立即扫描"),
SettingsJavaScanningStart => ("Scanning…", "正在扫描…"),
SettingsJavaScanning => ("Scanning… {done}/{total}", "正在扫描… {done}/{total}"),
SettingsJavaCancelling => ("Cancelling…", "正在取消…"),
SettingsJavaCancel => ("Cancel", "取消"),
SettingsJavaCancelled => (
"The scan was cancelled; the list below is from the previous run.",
"扫描已取消;下面的列表还是上一次的结果。"
),
SettingsJavaScanDone => ("{count} JDK found", "找到 {count} 个 JDK"),
SettingsJavaNeverScanned => ("Not scanned yet", "还没扫描过"),
SettingsJavaNeverScannedBody => (
"Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.",
"点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"
),
SettingsJavaEmptyTitle => ("No Java runtime found", "未检测到 Java 运行时"),
SettingsJavaEmptyBody => (
"Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.",
"没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"
),
// ── 运行时列表
SettingsJavaDefault => ("Default", "默认"),
SettingsJavaSetDefault => ("Use as default", "设为默认"),
SettingsJavaFits => ("Meets the current instance requirement", "满足当前实例的要求"),
SettingsJavaTooOld => (
"Below the requirement: this instance needs Java {minimum}+",
"低于要求:当前实例需要 Java {minimum} 及以上"
),
SettingsJavaDefaultSet => ("Default JDK is now {path}", "默认 JDK 已设为 {path}"),
SettingsJavaSourceManaged => ("installed by this launcher", "本启动器安装"),
SettingsJavaSourceEnv => ("JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"),
SettingsJavaSourcePath => ("on PATH", "PATH 上"),
SettingsJavaSourceSystem => ("system install", "系统安装"),
SettingsJavaSourceManual => ("added by you", "手动添加"),
// ── 手动添加
SettingsJavaAdd => ("Add a JDK path", "手动添加 JDK 路径"),
SettingsJavaAddPlaceholder => (
"C:\\Program Files\\Java\\jdk-21",
"例:C:\\Program Files\\Java\\jdk-21"
),
SettingsJavaAddHint => (
"A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.",
"JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"
),
SettingsJavaAddAction => ("Add", "添加"),
SettingsJavaAdded => ("Added {path}", "已添加 {path}"),
// ── 内存
SettingsJavaMemory => ("Default memory (MB)", "默认内存(MB)"),
SettingsJavaMemoryHint => (
"Whole megabytes, {min}–{max}. Instances without their own override follow this value.",
"整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"
),
SettingsJavaLayerGlobal => (
"In effect: {mb} MB, from the global default.",
"生效:{mb} MB,来自全局默认值。"
),
SettingsJavaLayerInstance => (
"In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.",
"生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"
),
// ── JVM 参数
SettingsJavaJvm => ("Extra JVM arguments", "额外 JVM 参数"),
SettingsJavaJvmPlaceholder => ("-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"),
SettingsJavaJvmHint => (
"Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.",
"按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"
),
SettingsJavaJvmSaved => ("Saved value: {value}", "已保存的值:{value}"),
SettingsJavaJvmSavedNone => (
"No extra JVM arguments are saved yet.",
"还没有保存任何额外参数。"
),
// ── 保存与回执
SettingsJavaSave => ("Save", "保存"),
SettingsJavaSaved => (
"Saved and read back from disk. The next launch uses the new value.",
"已保存,并从磁盘读回确认。下次启动就用新值。"
),
SettingsJavaUnsaved => ("There are unsaved changes.", "有未保存的改动。"),
SettingsJavaRefused => ("Not saved: {reason}", "未保存:{reason}"),
SettingsJavaSkipNotDirty => (
"there was nothing to change, so no file was written",
"没有任何改动,因此没有写文件"
),
SettingsJavaSkipReadOnly => (
"the settings file is read-only: a newer launcher wrote it, or it is marked untrusted",
"配置文件只读:它被更新版本的启动器写过,或被标记为不可信"
),
SettingsJavaSkipUnreadable => (
"the settings file is untrusted, so it is treated as read-only",
"配置文件不可信,因此按只读处理"
),
SettingsJavaNoStorage => (
"No settings file is available in this run, so nothing can be scanned or saved.",
"这一次运行没有可用的设置文件,扫描与保存都做不了。"
),
// ── 拒绝原因(一条一句)
SettingsJavaReasonMemoryEmpty => ("the memory field is empty", "内存没填"),
SettingsJavaReasonMemoryNotNumber => ("the memory must be a whole number of MB", "内存必须是整数 MB"),
SettingsJavaReasonMemoryRange => (
"the memory is outside the allowed range",
"内存超出允许范围"
),
SettingsJavaReasonJvmQuotes => (
"the JVM arguments have an unclosed quote",
"JVM 参数里有没配对的引号"
),
SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"),
SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"),
SettingsJavaReasonAddNotJava => (
"there is no java executable at that path",
"那个路径里没有 java 可执行文件"
),
SettingsJavaReasonAddNoVersion => (
"java -version did not answer there, so it is not a usable JDK",
"在那里跑不出 java -version,不是一份能用的 JDK"
),
SettingsJavaReasonAddTooLong => (
"the path is too long to be a JDK location",
"路径太长,不像是一个 JDK 位置"
),
SettingsJavaReasonAddWriteFailed => (
"the JDK is fine, but the list file could not be written (disk or permission)",
"这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"
),
SettingsJavaReasonAddDuplicate => ("that JDK is already in the list", "这份 JDK 已经在列表里了"),
SettingsJavaReasonWriteFailed => (
"the settings file could not be written (disk full, no permission, or the value was rejected)",
"设置写不进去(磁盘满、没权限,或这个值被拒绝)"
),
} }
}; };
} }