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
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:
commit
329c529bea
26 files changed
+4030
-85
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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
self.view_dirty = true;
|
|
||||||
Task::none()
|
|
||||||
}
|
}
|
||||||
|
Message::AddDraft(value) => {
|
||||||
|
self.settings.java.add_draft = value;
|
||||||
|
self.settings.java.notice = None;
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
Message::Add => self.add_java_manual(),
|
||||||
|
Message::SelectRuntime(binary) => self.select_java_default(&binary),
|
||||||
|
Message::EditMemory(value) => {
|
||||||
|
self.settings.java.memory = value;
|
||||||
|
self.touch_java();
|
||||||
|
}
|
||||||
|
Message::EditJvm(value) => {
|
||||||
|
self.settings.java.jvm = value;
|
||||||
|
self.touch_java();
|
||||||
|
}
|
||||||
|
Message::Save => self.save_java_settings(),
|
||||||
|
}
|
||||||
|
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||||
|
fn touch_java(&mut self) {
|
||||||
|
self.settings.java.dirty = true;
|
||||||
|
self.settings.java.notice = None;
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
|
||||||
|
///
|
||||||
|
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||||
|
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
|
||||||
|
///
|
||||||
|
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
|
||||||
|
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
|
||||||
|
///
|
||||||
|
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
|
||||||
|
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
|
||||||
|
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
|
||||||
|
pub(in crate::app) fn hydrate_java_settings(&mut self) {
|
||||||
|
let seeded = self.options.screenshot.is_some();
|
||||||
|
let mut live = false;
|
||||||
|
if seeded {
|
||||||
|
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
|
||||||
|
if raw.trim() == "live" {
|
||||||
|
live = true;
|
||||||
|
} else {
|
||||||
|
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
|
||||||
|
self.settings.java = state;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||||
|
self.settings.java = pages::settings::java::State::default();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let snapshot = pages::settings::java::store::load(&config);
|
||||||
|
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||||
|
let changed = self.settings.java.hydrate(snapshot);
|
||||||
|
let scan_changed = self.hydrate_java_scan();
|
||||||
|
self.settings.java.context = self.java_context();
|
||||||
|
if changed || scan_changed {
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
|
||||||
|
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
|
||||||
|
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
|
||||||
|
if !cfg!(test) {
|
||||||
|
self.auto_scan_java_if_due(seeded && !live);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
|
||||||
|
///
|
||||||
|
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
|
||||||
|
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
|
||||||
|
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
|
||||||
|
let report = self.settings.java.scan.report();
|
||||||
|
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
|
||||||
|
let _ = self.start_java_scan();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把后台扫描的快照灌进状态。返回是否变了。
|
||||||
|
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
|
||||||
|
let report = self.settings.java.scan.report();
|
||||||
|
let changed = self.settings.java.apply_scan(&report);
|
||||||
|
if changed {
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
|
||||||
|
fn start_java_scan(&mut self) -> Task<Message> {
|
||||||
|
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||||
|
self.settings.java.notice = Some(Notice::NoStorage);
|
||||||
|
self.view_dirty = true;
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let manual = pages::settings::java::store::load_manual(&config)
|
||||||
|
.into_iter()
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let roots = pages::settings::java::store::managed_roots(&config);
|
||||||
|
self.settings.java.notice = None;
|
||||||
|
if self.settings.java.scan.start(roots, manual, true) {
|
||||||
|
self.hydrate_java_scan();
|
||||||
|
}
|
||||||
|
self.view_dirty = true;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
|
||||||
|
fn add_java_manual(&mut self) {
|
||||||
|
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||||
|
self.settings.java.notice = Some(Notice::NoStorage);
|
||||||
|
self.view_dirty = true;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let draft = self.settings.java.add_draft.clone();
|
||||||
|
match pages::settings::java::store::add_manual(&config, &draft) {
|
||||||
|
Ok((binary, row)) => {
|
||||||
|
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||||
|
if !self
|
||||||
|
.settings
|
||||||
|
.java
|
||||||
|
.runtimes
|
||||||
|
.iter()
|
||||||
|
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
|
||||||
|
{
|
||||||
|
self.settings.java.runtimes.push(row);
|
||||||
|
self.settings
|
||||||
|
.java
|
||||||
|
.runtimes
|
||||||
|
.sort_by_key(|runtime| runtime.major);
|
||||||
|
}
|
||||||
|
self.settings.java.add_draft = String::new();
|
||||||
|
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
|
||||||
|
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
|
||||||
|
self.settings.java.scanned = true;
|
||||||
|
self.settings.java.notice = Some(Notice::Added(binary));
|
||||||
|
}
|
||||||
|
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
|
||||||
|
}
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
|
||||||
|
fn select_java_default(&mut self, binary: &str) {
|
||||||
|
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||||
|
self.settings.java.notice = Some(Notice::NoStorage);
|
||||||
|
self.view_dirty = true;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match pages::settings::java::store::set_selected(&config, binary) {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
|
||||||
|
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
|
||||||
|
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
|
||||||
|
self.settings.java.hydrate(snapshot);
|
||||||
|
self.settings.java.context = self.java_context();
|
||||||
|
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
|
||||||
|
}
|
||||||
|
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||||
|
}
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
|
||||||
|
fn save_java_settings(&mut self) {
|
||||||
|
let state = &self.settings.java;
|
||||||
|
if let Err(refusal) = state.validate() {
|
||||||
|
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||||
|
self.view_dirty = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||||
|
self.settings.java.notice = Some(Notice::NoStorage);
|
||||||
|
self.view_dirty = true;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match pages::settings::java::store::save(&config, &self.settings.java) {
|
||||||
|
Ok(snapshot) => {
|
||||||
|
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||||
|
self.settings.java.dirty = false;
|
||||||
|
self.settings.java.hydrate(snapshot);
|
||||||
|
self.settings.java.context = self.java_context();
|
||||||
|
self.settings.java.notice = Some(Notice::Saved);
|
||||||
|
}
|
||||||
|
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||||
|
}
|
||||||
|
self.view_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
|
||||||
|
///
|
||||||
|
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
|
||||||
|
/// (页面就不画门槛那一行,也不假装有要求)。
|
||||||
|
fn java_context(&self) -> Context {
|
||||||
|
let tiles = self.state.tiles();
|
||||||
|
let routed = match self.state.route() {
|
||||||
|
unlucky_presenter::router::Route::Detail(id) => {
|
||||||
|
tiles.iter().find(|cell| &cell.instance.id == id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let Some(cell) = routed.or_else(|| tiles.first()) else {
|
||||||
|
return Context::default();
|
||||||
|
};
|
||||||
|
let instance = cell.instance;
|
||||||
|
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
|
||||||
|
Context {
|
||||||
|
required: Some(Requirement {
|
||||||
|
instance: instance.name.clone(),
|
||||||
|
version: instance.mc_version.clone(),
|
||||||
|
minimum: requirement.minimum,
|
||||||
|
maximum: requirement.maximum,
|
||||||
|
}),
|
||||||
|
layer: Some(match instance.launch.memory_mb {
|
||||||
|
Some(mb) => Layer::Instance {
|
||||||
|
instance: instance.name.clone(),
|
||||||
|
mb,
|
||||||
|
},
|
||||||
|
None => Layer::Global,
|
||||||
|
}),
|
||||||
|
effective_memory_mb: Some(instance.launch.memory_effective_mb),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写盘失败 → 回执:三档都走 i18n key。
|
||||||
|
///
|
||||||
|
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
|
||||||
|
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
|
||||||
|
/// 界面上给一句页面自己的「写不进去」。
|
||||||
|
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
|
||||||
|
use pages::settings::java::store::WriteError;
|
||||||
|
match error {
|
||||||
|
WriteError::Refused(refusal) => Notice::Refused(refusal),
|
||||||
|
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
|
||||||
|
WriteError::Schema(detail) => {
|
||||||
|
eprintln!("[java] 设置写盘失败:{detail}");
|
||||||
|
Notice::WriteFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
|||||||
let mut app = headless();
|
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%」——只降**不删**。早先的
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
//! 用户手动指定的 JDK 路径(M175):校验、厂商识别、名单持久化。
|
||||||
|
//!
|
||||||
|
//! 场景:MSI 装的 JDK 落在 `%ProgramFiles%` 之外的盘符上、或者用户自己解压的一份 JDK ——
|
||||||
|
//! [`super::discover`] 的标准位置扫描看不见它们,用户只能在设置页里**手动指一个路径**。
|
||||||
|
//!
|
||||||
|
//! # 三条口径
|
||||||
|
//!
|
||||||
|
//! 1. **收目录也收可执行文件**:两样都是用户会粘的东西,统一折成 `bin/java[.exe]`;
|
||||||
|
//! 2. **校验就是真跑一次 `java -version`**:目录存在、里面有 `bin/java` 都不算数——
|
||||||
|
//! 一份放错的 `java`(空文件、别的平台的可执行文件)必须在这里被拒,而不是等启动时崩;
|
||||||
|
//! 3. **这份名单是 UI 的持久化,不是启动策略**:真正「用哪个 Java 启动」仍由
|
||||||
|
//! `java.selected_path`(core 的 schema 键)决定,名单只是「我手动加过哪些」的记忆。
|
||||||
|
//!
|
||||||
|
//! 名单落一个纯文本文件(一行一个路径):它是 UI 侧的登记表,不参与 schema 校验,
|
||||||
|
//! 也不该借道 `launcher.json`(那条路要新键 ⇒ 动 schema 版本与迁移,代价远大于收益)。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::extract::java_binary_name;
|
||||||
|
use super::probe::{self, JavaOrigin, JavaRuntime};
|
||||||
|
|
||||||
|
/// 用户可粘路径的长度上限(防手滑粘进来一整段日志)。
|
||||||
|
pub const MAX_PATH_CHARS: usize = 512;
|
||||||
|
|
||||||
|
/// 手动路径被拒的原因(稳定 id 给 UI 选文案;不做本地化)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ManualFailure {
|
||||||
|
/// 空串 / 全空白
|
||||||
|
Empty,
|
||||||
|
/// 超过 [`MAX_PATH_CHARS`]
|
||||||
|
TooLong,
|
||||||
|
/// 盘上没这个文件或目录
|
||||||
|
Missing,
|
||||||
|
/// 有这个东西,但里面没有 `bin/java[.exe]`(或给的不是一个 java 可执行文件)
|
||||||
|
NotJava,
|
||||||
|
/// 有 `java`,但 `java -version` 跑不起来 / 读不出版本号
|
||||||
|
NoVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManualFailure {
|
||||||
|
/// 稳定 id(UI 文案 key 的后缀,见 `pages/settings/java`)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Empty => "empty",
|
||||||
|
Self::TooLong => "too-long",
|
||||||
|
Self::Missing => "missing",
|
||||||
|
Self::NotJava => "not-java",
|
||||||
|
Self::NoVersion => "no-version",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户给的路径 → `java` 可执行文件(不探测,只看文件系统)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 空 / 太长 / 不存在 / 不是 java 可执行文件时返回原因。
|
||||||
|
pub fn resolve_binary(input: &str) -> Result<PathBuf, ManualFailure> {
|
||||||
|
let text = input.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(ManualFailure::Empty);
|
||||||
|
}
|
||||||
|
if text.chars().count() > MAX_PATH_CHARS {
|
||||||
|
return Err(ManualFailure::TooLong);
|
||||||
|
}
|
||||||
|
let path = Path::new(text);
|
||||||
|
if path.is_file() {
|
||||||
|
return looks_like_java(path)
|
||||||
|
.then(|| path.to_path_buf())
|
||||||
|
.ok_or(ManualFailure::NotJava);
|
||||||
|
}
|
||||||
|
if path.is_dir() {
|
||||||
|
let binary = path.join("bin").join(java_binary_name());
|
||||||
|
return binary
|
||||||
|
.is_file()
|
||||||
|
.then_some(binary)
|
||||||
|
.ok_or(ManualFailure::NotJava);
|
||||||
|
}
|
||||||
|
Err(ManualFailure::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件名是不是 `java` / `javaw`(Windows 带 `.exe`;POSIX 不区分大小写属于宽容)。
|
||||||
|
fn looks_like_java(path: &Path) -> bool {
|
||||||
|
path.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
.is_some_and(|stem| stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑一次 `java -version`,同时把**原始输出**交回来(厂商要从整段输出里认,不能只看首行)。
|
||||||
|
///
|
||||||
|
/// 为什么需要原始输出:`-version` 的第一行只有 `openjdk version "21.0.11"`,发行方印在**第二行**
|
||||||
|
/// (`OpenJDK Runtime Environment Microsoft-11230047` / `… Temurin-21.0.4+7`)。只拿首行去认厂商,
|
||||||
|
/// Microsoft / Temurin 的 JDK 会一律显示成 `OpenJDK`——M175 出图时实测到了这个错。
|
||||||
|
#[must_use]
|
||||||
|
pub fn probe_with_output(binary: &Path, origin: JavaOrigin) -> (JavaRuntime, String) {
|
||||||
|
probe_with_timeout(binary, origin, super::proc::PROBE_TIMEOUT, &|| false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同 [`probe_with_output`],但带**超时与取消**(扫描线程用这一条)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn probe_with_timeout(
|
||||||
|
binary: &Path,
|
||||||
|
origin: JavaOrigin,
|
||||||
|
timeout: std::time::Duration,
|
||||||
|
cancelled: &dyn Fn() -> bool,
|
||||||
|
) -> (JavaRuntime, String) {
|
||||||
|
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
|
||||||
|
let mut command = std::process::Command::new(binary);
|
||||||
|
command.arg("-version");
|
||||||
|
let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else {
|
||||||
|
return (runtime, String::new());
|
||||||
|
};
|
||||||
|
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||||
|
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||||
|
if let Some((line, major)) = probe::parse_version_output(&text) {
|
||||||
|
runtime.major = Some(major);
|
||||||
|
runtime.version = Some(line);
|
||||||
|
runtime.bits = probe::bits_of(&text);
|
||||||
|
}
|
||||||
|
runtime.vendor = vendor_of(&text);
|
||||||
|
(runtime, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 严格探测:必须真跑得起来 `-version`。
|
||||||
|
///
|
||||||
|
/// 与 [`probe::probe`] 的差别只有一个:后者「探测不出也返回一个 runtime」,这里探测不出就是**拒绝**。
|
||||||
|
/// 手动添加这条路上,一个答不出话的 `java` 就是无效输入,不能进名单。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 校验不过([`resolve_binary`])或 `-version` 读不出版本号时返回原因。
|
||||||
|
pub fn probe_manual(input: &str) -> Result<JavaRuntime, ManualFailure> {
|
||||||
|
let binary = resolve_binary(input)?;
|
||||||
|
let (runtime, _output) = probe_with_output(&binary, JavaOrigin::System);
|
||||||
|
if runtime.major.is_none() {
|
||||||
|
return Err(ManualFailure::NoVersion);
|
||||||
|
}
|
||||||
|
Ok(runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `java -version` 输出里的发行方(认不出返回 `None`,不猜)。
|
||||||
|
///
|
||||||
|
/// 为什么从输出里认而不是从安装目录名认:用户手动指的路径跟目录命名毫无关系,
|
||||||
|
/// 而 `java -version` 的首行/次行本来就把发行方印出来了
|
||||||
|
/// (`OpenJDK Runtime Environment Temurin-21.0.4+7`、`Java(TM) SE Runtime Environment`)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn vendor_of(text: &str) -> Option<String> {
|
||||||
|
let lowered = text.to_ascii_lowercase();
|
||||||
|
// 顺序即判据:「Temurin 构建的 OpenJDK」也能匹配到 openjdk,所以具体品牌必须排在前面。
|
||||||
|
const MARKERS: &[(&str, &str)] = &[
|
||||||
|
("temurin", "Temurin"),
|
||||||
|
("adoptium", "Temurin"),
|
||||||
|
("microsoft", "Microsoft"),
|
||||||
|
("zulu", "Zulu"),
|
||||||
|
("azul", "Zulu"),
|
||||||
|
("corretto", "Corretto"),
|
||||||
|
("semeru", "Semeru"),
|
||||||
|
("graalvm", "GraalVM"),
|
||||||
|
("sapmachine", "SapMachine"),
|
||||||
|
("oracle", "Oracle"),
|
||||||
|
// 老 Oracle / HotSpot 的 `-version` 不提 "Oracle"(Java 8 就是 `Java(TM) SE Runtime
|
||||||
|
// Environment` + `Java HotSpot(TM) 64-Bit Server VM`),这两条是那一代的唯一指纹。
|
||||||
|
("se runtime", "Oracle"),
|
||||||
|
("hotspot", "Oracle"),
|
||||||
|
("openjdk", "OpenJDK"),
|
||||||
|
];
|
||||||
|
MARKERS
|
||||||
|
.iter()
|
||||||
|
.find(|(needle, _)| lowered.contains(needle))
|
||||||
|
.map(|(_, display)| (*display).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读名单(文件不存在 / 读不动 ⇒ 空名单,不报错)。
|
||||||
|
///
|
||||||
|
/// 去掉空行、去重(按 [`probe::discover::path_key`](super::discover::path_key) 的同一套键),
|
||||||
|
/// 顺序保持文件里的顺序(用户添加的先后)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn load_paths(file: &Path) -> Vec<PathBuf> {
|
||||||
|
let Ok(text) = std::fs::read_to_string(file) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut paths: Vec<PathBuf> = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(line);
|
||||||
|
if !paths
|
||||||
|
.iter()
|
||||||
|
.any(|known| super::discover::path_key(known) == super::discover::path_key(&path))
|
||||||
|
{
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写名单(一行一个,末尾换行;父目录不存在就建)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// 目录建不出来或写盘失败时返回 IO 错误。
|
||||||
|
pub fn save_paths(file: &Path, paths: &[PathBuf]) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = file.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut text = String::new();
|
||||||
|
for path in paths {
|
||||||
|
text.push_str(&path.display().to_string());
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
std::fs::write(file, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp(tag: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"ull-java-manual-{tag}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.subsec_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("临时目录");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognises_vendors_from_real_version_lines() {
|
||||||
|
assert_eq!(
|
||||||
|
vendor_of("OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)"),
|
||||||
|
Some("Temurin".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vendor_of("OpenJDK 64-Bit Server VM Microsoft-8321 (build 17.0.13+11)"),
|
||||||
|
Some("Microsoft".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vendor_of("openjdk version \"17.0.9\" 2023-10-17\nOpenJDK Runtime Environment Zulu17.46+19-CA"),
|
||||||
|
Some("Zulu".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vendor_of(
|
||||||
|
"java version \"1.8.0_402\"\nJava(TM) SE Runtime Environment (build 1.8.0_402-b06)"
|
||||||
|
),
|
||||||
|
Some("Oracle".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(vendor_of("nothing useful here"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_directory_and_a_binary_but_rejects_anything_else() {
|
||||||
|
let dir = temp("resolve");
|
||||||
|
let jdk = dir.join("jdk-21");
|
||||||
|
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
|
||||||
|
let binary = jdk.join("bin").join(java_binary_name());
|
||||||
|
std::fs::write(&binary, b"").expect("写 java");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_binary(&jdk.display().to_string()),
|
||||||
|
Ok(binary.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_binary(&binary.display().to_string()), Ok(binary));
|
||||||
|
assert_eq!(resolve_binary(" "), Err(ManualFailure::Empty));
|
||||||
|
assert_eq!(
|
||||||
|
resolve_binary(&dir.join("nope").display().to_string()),
|
||||||
|
Err(ManualFailure::Missing)
|
||||||
|
);
|
||||||
|
let empty = dir.join("empty");
|
||||||
|
std::fs::create_dir_all(&empty).expect("empty");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_binary(&empty.display().to_string()),
|
||||||
|
Err(ManualFailure::NotJava)
|
||||||
|
);
|
||||||
|
// 目录里有一个**不叫 java 的**可执行文件:也不算。
|
||||||
|
let text = dir.join("notes.txt");
|
||||||
|
std::fs::write(&text, b"hi").expect("写文本");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_binary(&text.display().to_string()),
|
||||||
|
Err(ManualFailure::NotJava)
|
||||||
|
);
|
||||||
|
let long = "a".repeat(MAX_PATH_CHARS + 1);
|
||||||
|
assert_eq!(resolve_binary(&long), Err(ManualFailure::TooLong));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_binary_that_cannot_answer_version_is_refused() {
|
||||||
|
let dir = temp("noversion");
|
||||||
|
let jdk = dir.join("jdk");
|
||||||
|
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
|
||||||
|
// 空文件:解析不出版本号 ⇒ 拒绝(这条正是「文件存在 ≠ 能用」的判据)。
|
||||||
|
std::fs::write(jdk.join("bin").join(java_binary_name()), b"").expect("写 java");
|
||||||
|
assert_eq!(
|
||||||
|
probe_manual(&jdk.display().to_string()).err(),
|
||||||
|
Some(ManualFailure::NoVersion)
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_list_round_trips_and_dedupes() {
|
||||||
|
let dir = temp("list");
|
||||||
|
let file = dir.join("config").join("java-extra-paths.txt");
|
||||||
|
let paths = vec![
|
||||||
|
PathBuf::from("C:/jdk/17"),
|
||||||
|
PathBuf::from("C:/jdk/21"),
|
||||||
|
PathBuf::from("C:\\jdk\\17"),
|
||||||
|
];
|
||||||
|
save_paths(&file, &paths).expect("写名单");
|
||||||
|
// 第三条是第一条的另一种写法:读回来只留一条(与扫描去重同一套键)。
|
||||||
|
assert_eq!(load_paths(&file).len(), 2);
|
||||||
|
assert_eq!(load_paths(&file)[0], PathBuf::from("C:/jdk/17"));
|
||||||
|
|
||||||
|
let missing = dir.join("nope.txt");
|
||||||
|
assert!(load_paths(&missing).is_empty(), "缺文件是空名单,不是错误");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,14 +45,17 @@ pub mod error;
|
|||||||
pub mod extract;
|
pub mod 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;
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。
|
||||||
|
//!
|
||||||
|
//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不
|
||||||
|
//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。
|
||||||
|
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||||
|
|
||||||
|
/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。
|
||||||
|
///
|
||||||
|
/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。
|
||||||
|
///
|
||||||
|
/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死,
|
||||||
|
/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。
|
||||||
|
#[must_use]
|
||||||
|
pub fn run_with_timeout(
|
||||||
|
mut command: std::process::Command,
|
||||||
|
timeout: Duration,
|
||||||
|
cancelled: &dyn Fn() -> bool,
|
||||||
|
) -> Option<std::process::Output> {
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = command.spawn().ok()?;
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => break,
|
||||||
|
Ok(None) => {
|
||||||
|
if cancelled() || started.elapsed() > timeout {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait_with_output().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::run_with_timeout;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hung_probe_is_killed_after_the_timeout() {
|
||||||
|
// 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let output = run_with_timeout(
|
||||||
|
sleeping_command(30),
|
||||||
|
std::time::Duration::from_millis(400),
|
||||||
|
&|| false,
|
||||||
|
);
|
||||||
|
assert!(output.is_none(), "超时必须判成「没有结论」");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
assert!(
|
||||||
|
elapsed < std::time::Duration::from_secs(5),
|
||||||
|
"超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancel_interrupts_the_probe_that_is_already_running() {
|
||||||
|
// 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。
|
||||||
|
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let tripper = flag.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
tripper.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let output = run_with_timeout(
|
||||||
|
sleeping_command(30),
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
&|| flag.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
);
|
||||||
|
assert!(output.is_none(), "被取消的探测没有结论");
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < std::time::Duration::from_secs(10),
|
||||||
|
"取消必须打断正在跑的那个探测,而不是等它自己结束"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。
|
||||||
|
///
|
||||||
|
/// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()`
|
||||||
|
/// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。
|
||||||
|
///
|
||||||
|
/// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把
|
||||||
|
/// 标记文件写出来 ⇒ 本条红。
|
||||||
|
#[test]
|
||||||
|
fn a_timed_out_child_is_really_killed() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"ull-proc-kill-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.subsec_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).expect("临时目录");
|
||||||
|
let marker = dir.join("survived.txt");
|
||||||
|
let _ = std::fs::remove_file(&marker);
|
||||||
|
|
||||||
|
let output = run_with_timeout(
|
||||||
|
marker_command(&marker, 4),
|
||||||
|
std::time::Duration::from_millis(300),
|
||||||
|
&|| false,
|
||||||
|
);
|
||||||
|
assert!(output.is_none(), "超时没有结论");
|
||||||
|
// 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(6));
|
||||||
|
assert!(
|
||||||
|
!marker.exists(),
|
||||||
|
"子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。
|
||||||
|
fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command {
|
||||||
|
let target = marker.display().to_string();
|
||||||
|
if cfg!(windows) {
|
||||||
|
// PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的
|
||||||
|
// 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用
|
||||||
|
// PowerShell 的单引号,外层只剩 Rust 加的那一层。
|
||||||
|
let mut command = std::process::Command::new("powershell");
|
||||||
|
command.args([
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
&format!(
|
||||||
|
"Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive"
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
command
|
||||||
|
} else {
|
||||||
|
let mut command = std::process::Command::new("sh");
|
||||||
|
command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
|
||||||
|
fn sleeping_command(seconds: u32) -> std::process::Command {
|
||||||
|
if cfg!(windows) {
|
||||||
|
let mut command = std::process::Command::new("cmd");
|
||||||
|
command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]);
|
||||||
|
command
|
||||||
|
} else {
|
||||||
|
let mut command = std::process::Command::new("sleep");
|
||||||
|
command.arg(seconds.to_string());
|
||||||
|
command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
//! 带进度与取消的 Java 扫描(M175):**先收集候选、再逐个探测**。
|
||||||
|
//!
|
||||||
|
//! 为什么不能直接调 [`super::discover::candidates_in_roots`]:那条路把「收集」与「探测」揉在一起,
|
||||||
|
//! 一个 JDK 跑一次 `java -version`(几十到几百毫秒),一台装了七八个 JDK 的机器上整段要几秒。
|
||||||
|
//! 设置页要的是**能显示 3/7、能中途取消、且不卡住 UI**——于是这里把两段拆开:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! candidates(roots, manual, system) 纯文件系统,快、无子进程
|
||||||
|
//! → scan(&candidates, on_progress, cancelled) 逐个探测;任一回调说停就停
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 判据与 [`super::discover`] 一致(同一套去重键、同一套标准位置),差别只有「可观测」这一层——
|
||||||
|
//! 所以 `ull java list` 与设置页看到的是同一批 JDK。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::discover::{self, path_key};
|
||||||
|
use super::extract::{java_binary_name, read_stamp};
|
||||||
|
use super::probe::{self, JavaOrigin, JavaRuntime};
|
||||||
|
use super::vendor::JdkVendor;
|
||||||
|
|
||||||
|
/// 候选来源(比 [`JavaOrigin`] 多一个「用户手动加的」,UI 也按它分组)。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Source {
|
||||||
|
/// 托管目录(`ull java install` 装的)
|
||||||
|
Managed,
|
||||||
|
/// `JAVA_HOME`
|
||||||
|
JavaHome,
|
||||||
|
/// `PATH` 上的 `java`
|
||||||
|
Path,
|
||||||
|
/// 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表 / `/usr/lib/jvm`)
|
||||||
|
System,
|
||||||
|
/// 用户在设置页手动指的路径
|
||||||
|
Manual,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
/// 稳定 id(DTO / UI 文案选档用)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Managed => "managed",
|
||||||
|
Self::JavaHome => "env",
|
||||||
|
Self::Path => "path",
|
||||||
|
Self::System => "system",
|
||||||
|
Self::Manual => "manual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示优先级(小者先;与 [`JavaOrigin::rank`] 同序,手动路径排在系统安装之前——
|
||||||
|
/// 用户亲手指的是明确的意图)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn rank(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Managed => 1,
|
||||||
|
Self::JavaHome => 2,
|
||||||
|
Self::Path => 3,
|
||||||
|
Self::Manual => 4,
|
||||||
|
Self::System => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测时用的 [`JavaOrigin`](`JavaOrigin` 没有「手动」这一档,用 `System` 承接下来即可:
|
||||||
|
/// 展示走本枚举的 [`Source::id`],不看 `JavaRuntime.origin`)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn origin(self) -> JavaOrigin {
|
||||||
|
match self {
|
||||||
|
Self::Managed => JavaOrigin::Managed,
|
||||||
|
Self::JavaHome => JavaOrigin::JavaHome,
|
||||||
|
Self::Path => JavaOrigin::Path,
|
||||||
|
Self::System | Self::Manual => JavaOrigin::System,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个待探测的候选。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Candidate {
|
||||||
|
/// `bin/java[.exe]` 的绝对路径
|
||||||
|
pub binary: PathBuf,
|
||||||
|
/// 来源
|
||||||
|
pub source: Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收集候选(**不探测**,所以很快;目录不存在一律跳过)。
|
||||||
|
///
|
||||||
|
/// `system = false` 用于测试与「只看托管区」的场景:它跳过 `JAVA_HOME`/`PATH`/标准位置,
|
||||||
|
/// 于是同一份输入在任何机器上得到同一份候选表(不依赖本机装了什么)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn candidates(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec<Candidate> {
|
||||||
|
let mut out: Vec<Candidate> = Vec::new();
|
||||||
|
for root in managed_roots {
|
||||||
|
out.extend(managed_in(root));
|
||||||
|
}
|
||||||
|
// 手动路径:目录 → `bin/java`;文件原样(用户在输入框里粘过两种形态,都认)。
|
||||||
|
for path in manual {
|
||||||
|
let binary = if path.is_dir() {
|
||||||
|
path.join("bin").join(java_binary_name())
|
||||||
|
} else {
|
||||||
|
path.clone()
|
||||||
|
};
|
||||||
|
if binary.is_file() {
|
||||||
|
out.push(Candidate {
|
||||||
|
binary,
|
||||||
|
source: Source::Manual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if system {
|
||||||
|
out.extend(env_candidates());
|
||||||
|
out.extend(
|
||||||
|
discover::scan_java_binaries()
|
||||||
|
.into_iter()
|
||||||
|
.map(|binary| Candidate {
|
||||||
|
binary,
|
||||||
|
source: Source::System,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dedupe(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 托管安装根里的候选(两种布局都认,**必须有戳记**——没戳记的目录可能是用户塞的别的东西)。
|
||||||
|
fn managed_in(root: &std::path::Path) -> Vec<Candidate> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if !path.is_dir() || name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(binary) = stamped_binary(&path) {
|
||||||
|
found.push(Candidate {
|
||||||
|
binary,
|
||||||
|
source: Source::Managed,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 两层布局 `<vendor>/<version>`:厂商名取自父目录名。
|
||||||
|
if JdkVendor::parse(&name).is_some() {
|
||||||
|
let Ok(children) = std::fs::read_dir(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for child in children.flatten() {
|
||||||
|
if let Some(binary) = stamped_binary(&child.path()) {
|
||||||
|
found.push(Candidate {
|
||||||
|
binary,
|
||||||
|
source: Source::Managed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带戳记、且 `bin/java[.exe]` 在盘上的托管目录。
|
||||||
|
fn stamped_binary(dir: &std::path::Path) -> Option<PathBuf> {
|
||||||
|
if !dir.is_dir() || read_stamp(dir).is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let binary = dir.join("bin").join(java_binary_name());
|
||||||
|
binary.is_file().then_some(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `JAVA_HOME` 与 `PATH`(与 [`discover::candidates_in_roots`] 同一口径)。
|
||||||
|
fn env_candidates() -> Vec<Candidate> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
if let Some(home) = std::env::var_os("JAVA_HOME") {
|
||||||
|
let binary = PathBuf::from(home).join("bin").join(java_binary_name());
|
||||||
|
if binary.is_file() {
|
||||||
|
found.push(Candidate {
|
||||||
|
binary,
|
||||||
|
source: Source::JavaHome,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(binary) = probe::resolve_on_path(java_binary_name()) {
|
||||||
|
found.push(Candidate {
|
||||||
|
binary,
|
||||||
|
source: Source::Path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按路径去重,保留**先出现**的(调用方按优先级排好序)。
|
||||||
|
fn dedupe(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
||||||
|
let mut seen: Vec<String> = Vec::with_capacity(candidates.len());
|
||||||
|
let mut kept = Vec::with_capacity(candidates.len());
|
||||||
|
for candidate in candidates {
|
||||||
|
let key = path_key(&candidate.binary);
|
||||||
|
if seen.contains(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.push(key);
|
||||||
|
kept.push(candidate);
|
||||||
|
}
|
||||||
|
kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐个探测。`on_progress(done, total)` 返回 `false` ⇒ 立刻停(取消),已探测的照样返回。
|
||||||
|
///
|
||||||
|
/// `cancelled()` 在一次探测**进行中**也会被查询:卡住的候选会被 [`super::proc::PROBE_TIMEOUT`]
|
||||||
|
/// 或取消打断,而不是把扫描线程钉死在一个 `-version` 上(见 [`super::proc::run_with_timeout`])。
|
||||||
|
///
|
||||||
|
/// 返回的列表按**大版本升序、同版本按路径**排序,探测不出主版本的排在最后
|
||||||
|
/// (与 [`super::probe::discover_managed`] 的排序口径一致,用户看到的第一行不该是「版本未知」)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn scan<F>(
|
||||||
|
candidates: &[Candidate],
|
||||||
|
mut on_progress: F,
|
||||||
|
cancelled: &dyn Fn() -> bool,
|
||||||
|
) -> Vec<JavaRuntime>
|
||||||
|
where
|
||||||
|
F: FnMut(usize, usize) -> bool,
|
||||||
|
{
|
||||||
|
let total = candidates.len();
|
||||||
|
let mut found: Vec<JavaRuntime> = Vec::with_capacity(total);
|
||||||
|
for (index, candidate) in candidates.iter().enumerate() {
|
||||||
|
if cancelled() || !on_progress(index, total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 发行方要从**整段** `-version` 输出里认(首行只有 `openjdk version "…"`,品牌在第二行)。
|
||||||
|
// 超时与取消都交给这一层:卡住的子进程会被 kill,取消不必等它自己结束。
|
||||||
|
let (runtime, _output) = super::manual::probe_with_timeout(
|
||||||
|
&candidate.binary,
|
||||||
|
candidate.source.origin(),
|
||||||
|
super::proc::PROBE_TIMEOUT,
|
||||||
|
cancelled,
|
||||||
|
);
|
||||||
|
found.push(runtime);
|
||||||
|
}
|
||||||
|
found.sort_by(|left, right| {
|
||||||
|
left.major
|
||||||
|
.unwrap_or(u32::MAX)
|
||||||
|
.cmp(&right.major.unwrap_or(u32::MAX))
|
||||||
|
.then_with(|| left.binary.cmp(&right.binary))
|
||||||
|
});
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 候选 + 扫描一步到位(不与 UI 打交道的调用方用;设置页走两段式)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn scan_roots(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec<JavaRuntime> {
|
||||||
|
let list = candidates(managed_roots, manual, system);
|
||||||
|
scan(&list, |_, _| true, &|| false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp(tag: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"ull-java-staged-{tag}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("临时目录");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 造一个「看起来像 JDK」的目录(`bin/java` 是空文件 ⇒ 探测出「版本未知」,
|
||||||
|
/// 但它确实被扫到了——这正是「列表来自真实扫描」要的证据)。
|
||||||
|
fn fake_jdk(root: &std::path::Path, name: &str) -> PathBuf {
|
||||||
|
let dir = root.join(name);
|
||||||
|
std::fs::create_dir_all(dir.join("bin")).expect("bin");
|
||||||
|
std::fs::write(dir.join("bin").join(java_binary_name()), b"").expect("写 java");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_list_comes_from_the_disk_not_from_a_constant() {
|
||||||
|
let root = temp("list");
|
||||||
|
let one = fake_jdk(&root, "jdk-a");
|
||||||
|
let two = fake_jdk(&root, "jdk-b");
|
||||||
|
|
||||||
|
let found = scan_roots(&[], &[one.clone(), two.clone()], false);
|
||||||
|
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
|
||||||
|
assert!(found
|
||||||
|
.iter()
|
||||||
|
.any(|runtime| runtime.binary == one.join("bin").join(java_binary_name())));
|
||||||
|
|
||||||
|
// 删掉一个:列表必须少一项(证明它读的是盘,不是写死的字符串)。
|
||||||
|
std::fs::remove_dir_all(&one).expect("删 jdk-a");
|
||||||
|
let after = scan_roots(&[], &[one, two], false);
|
||||||
|
assert_eq!(after.len(), 1, "删掉一个 JDK 之后列表必须少一项:{after:?}");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_managed_directory_without_a_stamp_is_not_claimed() {
|
||||||
|
let root = temp("stamp");
|
||||||
|
// 长得像托管目录,但没有戳记 ⇒ 不是我们装的,不去猜。
|
||||||
|
fake_jdk(&root, "jdk-21-temurin");
|
||||||
|
let found = scan_roots(std::slice::from_ref(&root), &[], false);
|
||||||
|
assert!(found.is_empty(), "无戳记目录不该被认领:{found:?}");
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_reports_every_candidate_and_cancel_stops_early() {
|
||||||
|
let root = temp("progress");
|
||||||
|
let paths: Vec<PathBuf> = (0..3)
|
||||||
|
.map(|n| fake_jdk(&root, &format!("jdk-{n}")))
|
||||||
|
.collect();
|
||||||
|
let list = candidates(&[], &paths, false);
|
||||||
|
assert_eq!(list.len(), 3);
|
||||||
|
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
let all = scan(
|
||||||
|
&list,
|
||||||
|
|done, total| {
|
||||||
|
seen.push((done, total));
|
||||||
|
true
|
||||||
|
},
|
||||||
|
&|| false,
|
||||||
|
);
|
||||||
|
assert_eq!(seen, vec![(0, 3), (1, 3), (2, 3)], "每个候选都要报一次进度");
|
||||||
|
assert_eq!(all.len(), 3);
|
||||||
|
|
||||||
|
let stopped = scan(&list, |done, _| done < 1, &|| false);
|
||||||
|
assert_eq!(stopped.len(), 1, "取消之后只留已探测的那一项");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn candidates_dedupe_the_same_jdk_written_two_ways() {
|
||||||
|
let root = temp("dedupe");
|
||||||
|
let jdk = fake_jdk(&root, "jdk-x");
|
||||||
|
let binary = jdk.join("bin").join(java_binary_name());
|
||||||
|
// 同一份 JDK 用「目录」和「可执行文件」两种写法各登记一次。
|
||||||
|
let list = candidates(&[], &[jdk, binary], false);
|
||||||
|
assert_eq!(list.len(), 1, "同一路径只列一次:{list:?}");
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` 与
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
"设置写不进去(磁盘满、没权限,或这个值被拒绝)"
|
||||||
|
),
|
||||||
|
} }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user