feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)
用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」
- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
`java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
`SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。
顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
This commit is contained in:
1 parent
329c529bea
commit
ce463cc9b5
33 files changed
+3839
-322
No files matched your search
@@ -25,6 +25,10 @@ use super::super::*;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
|
||||
/// M197 的两个落点(各自一个 `impl App` 块):真下载一个 JDK / 三个设置键。
|
||||
mod downloads;
|
||||
mod prefs;
|
||||
|
||||
impl App {
|
||||
/// Java 分组的一条消息。
|
||||
pub(super) fn on_settings_java(
|
||||
@@ -55,6 +59,13 @@ impl App {
|
||||
self.touch_java();
|
||||
}
|
||||
Message::Save => self.save_java_settings(),
|
||||
// M197:真下载一个 JDK + 三个设置键。落点各自一个 `impl App` 块(见 `mod` 声明)。
|
||||
Message::Download(major) => return downloads::on_download(self, major),
|
||||
Message::EditInstallRoot(value) => return prefs::on_edit_install_root(self, value),
|
||||
Message::ToggleAutoInstall(on) => return prefs::on_toggle_auto_install(self, on),
|
||||
Message::MoveVendor { index, up } => return prefs::on_move_vendor(self, index, up),
|
||||
Message::ResetPriority => return prefs::on_reset_priority(self),
|
||||
Message::SavePrefs => return prefs::on_save_prefs(self),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
@@ -99,9 +110,17 @@ impl App {
|
||||
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);
|
||||
// M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)+ 它们的生效值与来源层。
|
||||
let prefs_changed = self
|
||||
.settings
|
||||
.java
|
||||
.prefs
|
||||
.hydrate(pages::settings::java::prefs_store::load(&config));
|
||||
self.settings.java.effective =
|
||||
pages::settings::java::prefs_store::effective(&config, &self.settings.java.prefs);
|
||||
let scan_changed = self.hydrate_java_scan();
|
||||
self.settings.java.context = self.java_context();
|
||||
if changed || scan_changed {
|
||||
if changed || prefs_changed || scan_changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
|
||||
@@ -126,9 +145,16 @@ impl App {
|
||||
}
|
||||
|
||||
/// 把后台扫描的快照灌进状态。返回是否变了。
|
||||
///
|
||||
/// M197 起这一拍**也**灌一次下载作业的快照:`app/update/clock.rs` 每拍只调这一个入口,
|
||||
/// 而复用它比改心跳的调用点便宜(`clock.rs` 不在本 mission 的 scope 里)。
|
||||
/// 代价写在 `settings/java/downloads.rs`:下载期间进度按 1 Hz 刷新。
|
||||
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);
|
||||
let mut changed = self.settings.java.apply_scan(&report);
|
||||
if self.hydrate_java_downloads() {
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! 「下载一个 JDK」的 App 层落点(M197)。
|
||||
//!
|
||||
//! 三件事:
|
||||
//!
|
||||
//! | 函数 | 谁调 | 干什么 |
|
||||
//! |---|---|---|
|
||||
//! | [`App::start_java_download`] | `Download(major)` 消息 | 本地判据(目录/承接方/落点/是否已在跑)→ 起后台作业 |
|
||||
//! | [`App::hydrate_java_downloads`] | 心跳那一拍(经 `hydrate_java_scan`) | 把作业快照灌成视图差量;**刚成功**时重扫 + 按 `java.auto_install` 决定要不要设为默认 |
|
||||
//!
|
||||
//! ## 为什么轮询挂在 `hydrate_java_scan` 上
|
||||
//!
|
||||
//! M175 的扫描已经是「后台线程 + 快照 + 心跳比对」的形状,`app/update/clock.rs` 每拍只调
|
||||
//! `hydrate_java_scan()` 这一个入口。下载作业是同一族东西(后台线程 + 快照),所以复用那一拍,
|
||||
//! `clock.rs` 一个字节都不用改——代价是**下载期间进度按 1 Hz 刷新**(帧时钟的判据在
|
||||
//! `app/subscription.rs` 的 `needs_frames`,那是本 mission scope 之外的文件)。百分比一秒动一次
|
||||
//! 对一次 100 MB 级的下载是够的;要紧的是它**会动**,而不是每秒动 60 次。
|
||||
//!
|
||||
//! ## 装完之后为什么要重扫
|
||||
//!
|
||||
//! 列表里那一行「已安装:…」来自**真扫盘的结论**(M175 的 `ScanSlot`),不是另存的安装记录。
|
||||
//! 装完不重扫,界面上就会出现「下载成功」与「列表里没有它」并存——正是本仓最忌讳的那种自相矛盾。
|
||||
|
||||
use iced::Task;
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::pages;
|
||||
|
||||
use unlucky_core::game::java::catalog;
|
||||
|
||||
use crate::pages::settings::java::{prefs_store, store};
|
||||
|
||||
impl App {
|
||||
/// 下载并安装某个大版本(`Download(major)` 的落点)。
|
||||
///
|
||||
/// 四道本地判据,任何一道不过都在下载区顶上写清原因(点了没反应是禁止的):
|
||||
/// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点;
|
||||
/// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。
|
||||
pub(super) fn start_java_download(&mut self, major: u32) {
|
||||
use pages::settings::java::downloads::{Refusal, Request};
|
||||
|
||||
self.settings.java.download_notice = None;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
|
||||
if catalog::find(major).is_none() {
|
||||
self.settings.java.download_notice = Some(Refusal::UnknownMajor);
|
||||
return;
|
||||
}
|
||||
if self.settings.java.downloads.busy() {
|
||||
self.settings.java.download_notice = Some(Refusal::Busy);
|
||||
return;
|
||||
}
|
||||
let Some(config) = store::config_of(&self.bridge) else {
|
||||
self.settings.java.download_notice = Some(Refusal::NoStorage);
|
||||
return;
|
||||
};
|
||||
let priority = self.settings.java.priority_vendors();
|
||||
let domestic = self.settings.java.effective.domestic;
|
||||
if catalog::vendors_for(major, &priority, domestic).is_empty() {
|
||||
self.settings.java.download_notice = Some(Refusal::NoVendor);
|
||||
return;
|
||||
}
|
||||
let saved_root = self.settings.java.prefs.saved_install_root.clone();
|
||||
let Some(cache_root) = prefs_store::cache_root(&config) else {
|
||||
self.settings.java.download_notice = Some(Refusal::NoStorage);
|
||||
return;
|
||||
};
|
||||
let Some(install_root) = prefs_store::install_root(&config, &saved_root) else {
|
||||
// 有设置文件但解析不出落点(standard 模式读不到系统目录):说这一句,
|
||||
// 而不是含糊的「没有可用的设置文件」。
|
||||
self.settings.java.download_notice = Some(Refusal::RootUnresolved);
|
||||
return;
|
||||
};
|
||||
let started = self.settings.java.downloads.start(Request {
|
||||
major,
|
||||
cache_root,
|
||||
install_root,
|
||||
priority,
|
||||
});
|
||||
if !started {
|
||||
self.settings.java.download_notice = Some(Refusal::Busy);
|
||||
}
|
||||
}
|
||||
|
||||
/// 把作业快照灌成视图差量(心跳那一拍调)。返回是否变了。
|
||||
pub(in crate::app) fn hydrate_java_downloads(&mut self) -> bool {
|
||||
let report = self.settings.java.downloads.report();
|
||||
if report == self.settings.java.download_seen {
|
||||
return false;
|
||||
}
|
||||
let fresh = report
|
||||
.success
|
||||
.clone()
|
||||
.filter(|success| self.settings.java.download_seen.success.as_ref() != Some(success));
|
||||
self.settings.java.download_seen = report;
|
||||
if let Some(success) = fresh {
|
||||
// 新装上的一份:先重扫(列表要出现它),再按 `java.auto_install` 决定默认值。
|
||||
let _ = self.start_java_scan();
|
||||
if self.settings.java.prefs.saved_auto_install
|
||||
&& self.settings.java.selected.trim().is_empty()
|
||||
{
|
||||
self.select_java_default(&success.binary);
|
||||
}
|
||||
}
|
||||
self.view_dirty = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
|
||||
pub(super) fn on_download(app: &mut App, major: u32) -> Task<Message> {
|
||||
app.start_java_download(major);
|
||||
Task::none()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 三个设置键的 App 层落点(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
|
||||
//!
|
||||
//! ## 为什么这三项单独一个「保存」
|
||||
//!
|
||||
//! M175 的「保存」只写内存 + JVM 参数(`java.default_memory_mb` / `java.extra_args`),那是**运行时
|
||||
//! 参数**;这三项是**装到哪、按什么顺序找、缺 JDK 时怎么准备**——改了它们的副作用是「下一次下载 /
|
||||
//! 下一次扫描的落点变了」,与内存那两项混在一个按钮里,用户就不清楚自己刚保存的到底是哪一半。
|
||||
//! 于是两段各有自己的脏标记与保存按钮,各回读各的键。
|
||||
//!
|
||||
//! ## 保存之后为什么立刻重扫
|
||||
//!
|
||||
//! `java.installRoot` 改了 ⇒ 托管根的集合变了 ⇒ 「已检测到的 JDK」那份列表可能整份不同。
|
||||
//! 保存成功就重扫一次,界面不会出现「落点写着新目录,列表还是旧目录里的 JDK」。
|
||||
//!
|
||||
//! ## 回读校验
|
||||
//!
|
||||
//! `prefs_store::save` 返回的是**落盘后从磁盘读回来**的真值,[`Prefs::adopt`] 把草稿与
|
||||
//! 「落盘真值」两半都对齐到它:用户看到的就是磁盘上那份(要是 schema 把值规整过,界面显示的是
|
||||
//! 规整后的结果,而不是他刚敲的那串——这一条正是「改完回读验证已落盘」)。
|
||||
|
||||
use iced::Task;
|
||||
|
||||
use crate::app::message::Message;
|
||||
use crate::app::state::App;
|
||||
use crate::pages;
|
||||
|
||||
use super::super::java::write_notice;
|
||||
|
||||
impl App {
|
||||
/// `java.installRoot` 草稿(只改草稿,不写盘)。
|
||||
pub(super) fn edit_java_install_root(&mut self, value: String) {
|
||||
self.settings.java.prefs.install_root = value;
|
||||
self.settings.java.notice = None;
|
||||
self.settings.java.download_notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// `java.auto_install` 开关(只改草稿)。
|
||||
pub(super) fn toggle_java_auto_install(&mut self, on: bool) {
|
||||
self.settings.java.prefs.auto_install = on;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 发行版顺序上移 / 下移一行。
|
||||
pub(super) fn move_java_vendor(&mut self, index: usize, up: bool) {
|
||||
let domestic = self.settings.java.effective.domestic;
|
||||
if up {
|
||||
self.settings.java.prefs.move_up(index, domestic);
|
||||
} else {
|
||||
self.settings.java.prefs.move_down(index, domestic);
|
||||
}
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 恢复地区默认序(= 把这个键写回空,让地区那一层重新生效)。
|
||||
pub(super) fn reset_java_priority(&mut self) {
|
||||
self.settings.java.prefs.reset_priority();
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 保存三个键:本地校验 → 落盘 → **用读回的真值**刷新草稿与生效值 → 重扫一次。
|
||||
pub(super) fn save_java_prefs(&mut self) {
|
||||
use pages::settings::java::Notice;
|
||||
|
||||
self.view_dirty = true;
|
||||
if let Err(refusal) = self.settings.java.prefs.validate() {
|
||||
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||
return;
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::prefs_store::save(&config, &self.settings.java.prefs) {
|
||||
Ok(snapshot) => {
|
||||
self.settings.java.prefs.adopt(snapshot);
|
||||
self.settings.java.effective = pages::settings::java::prefs_store::effective(
|
||||
&config,
|
||||
&self.settings.java.prefs,
|
||||
);
|
||||
// 落点 / 顺序变了:托管根的集合可能整份不同,立刻重扫一次。
|
||||
// 顺序要紧:`start_java_scan` 会清 `notice`,所以回执写在它之后。
|
||||
let _ = self.start_java_scan();
|
||||
self.settings.java.notice = Some(Notice::PrefsSaved);
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息→落点的那一层(`java.rs` 的 match 里一行转发)。
|
||||
pub(super) fn on_edit_install_root(app: &mut App, value: String) -> Task<Message> {
|
||||
app.edit_java_install_root(value);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `ToggleAutoInstall` 的转发。
|
||||
pub(super) fn on_toggle_auto_install(app: &mut App, on: bool) -> Task<Message> {
|
||||
app.toggle_java_auto_install(on);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `MoveVendor` 的转发。
|
||||
pub(super) fn on_move_vendor(app: &mut App, index: usize, up: bool) -> Task<Message> {
|
||||
app.move_java_vendor(index, up);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `ResetPriority` 的转发。
|
||||
pub(super) fn on_reset_priority(app: &mut App) -> Task<Message> {
|
||||
app.reset_java_priority();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// `SavePrefs` 的转发。
|
||||
pub(super) fn on_save_prefs(app: &mut App) -> Task<Message> {
|
||||
app.save_java_prefs();
|
||||
Task::none()
|
||||
}
|
||||
@@ -161,3 +161,142 @@ fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- M197:三个设置键 + 真下载的落点
|
||||
|
||||
/// 三键改完点保存:**真落盘**、草稿被读回值收尾、不再脏,并且保存顺手重扫一次。
|
||||
///
|
||||
/// 变异证明:`save_java_prefs` 里不调 `prefs.adopt(snapshot)` ⇒ 草稿还是 `" D:/JDKs "`、
|
||||
/// `dirty` 仍为真 ⇒ 本条红;不调 `start_java_scan` ⇒ `scan.report().running` 那条红。
|
||||
#[test]
|
||||
fn saving_the_three_keys_writes_them_and_reads_them_back() {
|
||||
use pages::settings::java::prefs_store;
|
||||
|
||||
let (mut app, path) = bridged_app("java-prefs");
|
||||
// 落点用这个临时数据根下的绝对路径:Windows / macOS / Linux 上都是绝对的,
|
||||
// 断言于是与平台无关(写 "D:/JDKs" 在非 Windows 上是相对路径,会被解析到数据根下)。
|
||||
let home = path.parent().unwrap().parent().unwrap().to_path_buf();
|
||||
let jdks = home.join("JDKs").display().to_string();
|
||||
let _ = app.update(java(pages::settings::java::Message::EditInstallRoot(
|
||||
format!(" {jdks} "),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::ToggleAutoInstall(
|
||||
false,
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::MoveVendor {
|
||||
index: 0,
|
||||
up: false,
|
||||
}));
|
||||
assert!(app.settings.java.prefs.dirty(), "改过三键就该是脏的");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::SavePrefs));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.notice,
|
||||
Some(pages::settings::java::Notice::PrefsSaved)
|
||||
);
|
||||
assert!(
|
||||
!app.settings.java.prefs.dirty(),
|
||||
"保存并把读回值当草稿之后不该还脏"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.prefs.install_root, jdks,
|
||||
"草稿必须等于磁盘上那份(trim 过的),不是用户刚敲的原样"
|
||||
);
|
||||
assert!(
|
||||
!app.settings.java.prefs.saved_auto_install,
|
||||
"auto_install 要真变成关"
|
||||
);
|
||||
|
||||
// 冷读:另起一次 load,确认三个键都在文件里。
|
||||
let snapshot = prefs_store::load(&path);
|
||||
assert_eq!(snapshot.install_root, jdks);
|
||||
assert!(!snapshot.auto_install);
|
||||
assert_eq!(snapshot.priority.len(), 4, "下移之后落盘的是整条链");
|
||||
assert_ne!(snapshot.priority[0], "temurin", "顺序真的被改过");
|
||||
assert!(
|
||||
app.settings.java.scan.report().running,
|
||||
"改了落点必须重扫一次,否则列表还停在旧目录里"
|
||||
);
|
||||
// 生效值也跟着换了层级(设了 installRoot ⇒ 落点来自设置)。
|
||||
assert_eq!(
|
||||
app.settings.java.effective.root, jdks,
|
||||
"生效落点必须是刚保存的那一个"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// `java.auto_install` 开着且还没有默认 JDK:装完之后自动把新装的那份设为默认。
|
||||
///
|
||||
/// 变异证明:把 `hydrate_java_downloads` 里那段 `saved_auto_install && selected.is_empty()`
|
||||
/// 的判据删掉 ⇒ 本条红(`selected` 还是空);把关掉那一档的断言反过来说明判据不是恒真。
|
||||
#[test]
|
||||
fn auto_install_adopts_the_fresh_jdk_when_no_default_is_set() {
|
||||
use pages::settings::java::downloads::{Report, Success};
|
||||
|
||||
let (mut app, path) = bridged_app("java-adopt");
|
||||
app.settings.java.prefs.saved_auto_install = true;
|
||||
app.settings.java.selected = String::new();
|
||||
app.settings.java.downloads.seed_report(Report {
|
||||
job: None,
|
||||
tries: Vec::new(),
|
||||
failure: None,
|
||||
success: Some(Success {
|
||||
major: 21,
|
||||
vendor: "Eclipse Temurin",
|
||||
root: "D:/jdks/temurin/21".to_string(),
|
||||
binary: "D:/jdks/temurin/21/bin/java.exe".to_string(),
|
||||
source: Some("tuna".to_string()),
|
||||
reused: false,
|
||||
}),
|
||||
});
|
||||
assert!(app.hydrate_java_downloads(), "快照变了就要重建视图");
|
||||
assert_eq!(
|
||||
app.settings.java.selected, "D:/jdks/temurin/21/bin/java.exe",
|
||||
"auto_install 开着 + 没有默认 JDK ⇒ 新装的那份自动成为默认"
|
||||
);
|
||||
let snapshot = pages::settings::java::store::load(&path);
|
||||
assert_eq!(
|
||||
snapshot.selected, "D:/jdks/temurin/21/bin/java.exe",
|
||||
"而且真写进了 java.selected_path"
|
||||
);
|
||||
|
||||
// 关掉 auto_install 时同样的成功快照**不**改默认。
|
||||
let (mut app, path) = bridged_app("java-adopt-off");
|
||||
app.settings.java.prefs.saved_auto_install = false;
|
||||
app.settings.java.selected = String::new();
|
||||
app.settings.java.downloads.seed_report(Report {
|
||||
job: None,
|
||||
tries: Vec::new(),
|
||||
failure: None,
|
||||
success: Some(Success {
|
||||
major: 17,
|
||||
vendor: "Eclipse Temurin",
|
||||
root: "D:/jdks/temurin/17".to_string(),
|
||||
binary: "D:/jdks/temurin/17/bin/java.exe".to_string(),
|
||||
source: None,
|
||||
reused: true,
|
||||
}),
|
||||
});
|
||||
assert!(app.hydrate_java_downloads());
|
||||
assert_eq!(app.settings.java.selected, "", "关掉就不许替用户改默认");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// 点一个目录里没有的大版本:立刻在下载区顶上写清原因,**不**起线程。
|
||||
///
|
||||
/// 变异证明:把 `start_java_download` 里 `catalog::find(major).is_none()` 那道判据删掉 ⇒
|
||||
/// `download_notice` 为 `None`,本条红(点了却什么都不说是禁止的)。
|
||||
#[test]
|
||||
fn an_unknown_major_is_refused_on_the_spot() {
|
||||
use pages::settings::java::downloads::Refusal;
|
||||
|
||||
let (mut app, path) = bridged_app("java-dl-refuse");
|
||||
let _ = app.update(java(pages::settings::java::Message::Download(99)));
|
||||
assert_eq!(
|
||||
app.settings.java.download_notice,
|
||||
Some(Refusal::UnknownMajor)
|
||||
);
|
||||
assert!(!app.settings.java.downloads.busy(), "不该起任何作业");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
@@ -20,7 +20,13 @@
|
||||
//! `rows` 仍留成空切片:它是 `Group` 的字段(另外五组在用),而本组的 `body` 分发永远命中
|
||||
//! [`view::body`],空切片不会被画出来——宁可空着,也不留一句会撒谎的演示值。
|
||||
|
||||
mod context;
|
||||
mod download_view;
|
||||
pub(crate) mod downloads;
|
||||
mod fields;
|
||||
pub(crate) mod prefs;
|
||||
pub(crate) mod prefs_store;
|
||||
mod prefs_view;
|
||||
mod row;
|
||||
mod seed;
|
||||
mod state;
|
||||
@@ -73,6 +79,23 @@ pub enum Message {
|
||||
EditJvm(String),
|
||||
/// 保存内存 + JVM 参数(校验 + 落盘 + 回读)
|
||||
Save,
|
||||
/// 下载并安装某个大版本的 JDK(M197:真下载,后台线程跑)
|
||||
Download(u32),
|
||||
/// `java.installRoot` 草稿
|
||||
EditInstallRoot(String),
|
||||
/// `java.auto_install` 开关
|
||||
ToggleAutoInstall(bool),
|
||||
/// 发行版顺序上移 / 下移一行(`index` 是有效顺序里的下标)
|
||||
MoveVendor {
|
||||
/// 有效顺序里的下标
|
||||
index: usize,
|
||||
/// `true` = 上移,`false` = 下移
|
||||
up: bool,
|
||||
},
|
||||
/// 发行版顺序恢复地区默认序(= 把这个键写回空)
|
||||
ResetPriority,
|
||||
/// 保存三个设置键(`installRoot` / `vendorPriority` / `auto_install`)
|
||||
SavePrefs,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 当前实例对 Java 的**要求**与「某个大版本够不够」的判定(M197 从 `state.rs` 拆出)。
|
||||
//!
|
||||
//! M175 只回答了「满不满足」(一个 `bool`),于是**方向说不清**:1.12.2 只能吃 Java 8,
|
||||
//! 用户手里是 Java 21 时旧文案写的是「低于要求:当前实例需要 Java 8 及以上」——那句话自相矛盾
|
||||
//! (21 当然 ≥ 8)。M197 的第三条任务(「不许静默失败,要说清这个版本需要 Java X,当前是 Y」)
|
||||
//! 要求两个方向各说各的,所以这里改成带方向的 [`Fit`]。
|
||||
//!
|
||||
//! 判据本身来自 `core::game::java::mapping`(静态表 + 版本 JSON 的 `javaVersion`),
|
||||
//! 本文件只是把它翻成页面的三态;**不复制**那张表。
|
||||
|
||||
/// 当前实例对 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)
|
||||
}
|
||||
|
||||
/// 某个大版本**怎么不满足**(满足时给 [`Fit::Ok`])。
|
||||
///
|
||||
/// 两个方向的判据与 `mapping::check` 一致(下限来自版本清单 / 静态表,上限只对老版本存在)。
|
||||
#[must_use]
|
||||
pub fn fit(&self, major: u32) -> Fit {
|
||||
if major < self.minimum {
|
||||
return Fit::TooOld {
|
||||
required: self.minimum,
|
||||
actual: major,
|
||||
};
|
||||
}
|
||||
match self.maximum {
|
||||
Some(maximum) if major > maximum => Fit::TooNew {
|
||||
maximum,
|
||||
actual: major,
|
||||
},
|
||||
_ => Fit::Ok,
|
||||
}
|
||||
}
|
||||
|
||||
/// 要求的人读形态(`Java 21+` / `Java 8`)——**不含**"当前是什么",那是 [`Fit`] 的活。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self.maximum {
|
||||
Some(maximum) if maximum == self.minimum => format!("Java {}", self.minimum),
|
||||
Some(maximum) => format!("Java {}–{}", self.minimum, maximum),
|
||||
None => format!("Java {}+", self.minimum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「这个大版本够不够用」的三态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Fit {
|
||||
/// 满足要求
|
||||
Ok,
|
||||
/// 太低(当前实例要更老的之上的版本)
|
||||
TooOld {
|
||||
/// 需要的最低主版本
|
||||
required: u32,
|
||||
/// 实际主版本
|
||||
actual: u32,
|
||||
},
|
||||
/// 太高(老加载器吃不了新的 class 文件)
|
||||
TooNew {
|
||||
/// 允许的最高主版本
|
||||
maximum: u32,
|
||||
/// 实际主版本
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Fit {
|
||||
/// 是不是「不满足」。
|
||||
#[must_use]
|
||||
pub const fn is_mismatch(self) -> bool {
|
||||
!matches!(self, Self::Ok)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效内存来自哪一层。
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Fit, Requirement};
|
||||
|
||||
fn legacy() -> Requirement {
|
||||
Requirement {
|
||||
instance: "老服".to_string(),
|
||||
version: "1.12.2".to_string(),
|
||||
minimum: 8,
|
||||
maximum: Some(8),
|
||||
}
|
||||
}
|
||||
|
||||
fn modern() -> Requirement {
|
||||
Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.5".to_string(),
|
||||
minimum: 21,
|
||||
maximum: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个方向各说各的:Java 21 对 1.12.2 是**太高**,不是「低于要求」。
|
||||
///
|
||||
/// M175 的 `satisfied()` 只给布尔值,文案于是必然写错一个方向(复审实测到的现象)。
|
||||
/// 变异证明:把 `fit` 里的 `maximum` 分支删掉(一律 `Fit::Ok`)⇒ 本用例红。
|
||||
#[test]
|
||||
fn the_direction_of_the_mismatch_is_reported() {
|
||||
assert_eq!(
|
||||
legacy().fit(21),
|
||||
Fit::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
modern().fit(17),
|
||||
Fit::TooOld {
|
||||
required: 21,
|
||||
actual: 17
|
||||
}
|
||||
);
|
||||
assert_eq!(modern().fit(21), Fit::Ok);
|
||||
assert_eq!(modern().fit(25), Fit::Ok, "不封顶时更高的版本也满足");
|
||||
assert_eq!(legacy().fit(8), Fit::Ok);
|
||||
assert!(legacy().fit(21).is_mismatch());
|
||||
assert!(!modern().fit(21).is_mismatch());
|
||||
}
|
||||
|
||||
/// 要求的人读形态(给下载项 / 运行时行共用)。
|
||||
#[test]
|
||||
fn the_requirement_reads_as_a_range() {
|
||||
assert_eq!(legacy().describe(), "Java 8");
|
||||
assert_eq!(modern().describe(), "Java 21+");
|
||||
assert_eq!(
|
||||
Requirement {
|
||||
minimum: 8,
|
||||
maximum: Some(16),
|
||||
..modern()
|
||||
}
|
||||
.describe(),
|
||||
"Java 8–16",
|
||||
"有上限时区间两端都要写出来(1.17 的 8–16 就是一个真实区间)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! 「下载一个 JDK」的视图(M197):**版本选择列表 / 下载中 / 失败**三态。
|
||||
//!
|
||||
//! 三态各自能说清什么,是这一屏的全部要求:
|
||||
//!
|
||||
//! | 态 | 必须出现的 |
|
||||
//! |---|---|
|
||||
//! | 选择列表 | 8 / 11 / 17 / 21 / 25 每一档 + 它由哪几家发行版承接 + 与当前实例要求的关系 |
|
||||
//! | 下载中 | 大版本 + 阶段 + 百分比(分母未知就不给)+ 当前源 + 最近一次「哪个源失败、多久后重试」 |
|
||||
//! | 失败 | 大版本 + 最后试到的源 + 「完整原因在日志里」+ 下一步(查网络 / 改顺序) |
|
||||
//!
|
||||
//! 「与当前实例要求的关系」是任务书第三条:**不许静默失败**。老版本 MC 要 8、新版本要 21/25,
|
||||
//! 用户在列表里能直接看到「装这一档也起不来」([`Fit`] 的两向措辞),不必等启动那一刻才发现。
|
||||
//!
|
||||
//! 列表里的「已安装」判定来自**真扫盘的结论**(`state.runtimes`),不是另存一份安装记录。
|
||||
|
||||
mod result;
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_core::download::DownloadErrorKind;
|
||||
use unlucky_core::game::java::catalog::{self, JdkChoice};
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame::{divider, text_role_owned};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::downloads::{Event, Job, Phase};
|
||||
use super::state::{Fit, State};
|
||||
use super::view::msg;
|
||||
use super::Message as JavaMessage;
|
||||
|
||||
/// 一整块(标题 + 口径 + 目录 + 结果)。
|
||||
pub(super) fn section<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let report = state.downloads.report();
|
||||
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
catalog.text(Key::SettingsJavaDownloadTitle).to_string(),
|
||||
ui.color(Role::OnSurface),
|
||||
));
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsJavaDownloadHint).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
if let Some(refusal) = state.download_notice {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadRefused,
|
||||
&[("reason", catalog.text(refusal.label()))],
|
||||
),
|
||||
ui.color(Role::Error),
|
||||
));
|
||||
}
|
||||
if let Some(required) = &state.context.required {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadNeeded,
|
||||
&[("requirement", required.describe().as_str())],
|
||||
),
|
||||
ui.color(Role::OnSurface),
|
||||
));
|
||||
}
|
||||
|
||||
let recommended = super::downloads::recommend(
|
||||
state.context.required.as_ref(),
|
||||
&state.runtimes,
|
||||
state.prefs.saved_auto_install,
|
||||
);
|
||||
let busy = report.job.is_some();
|
||||
for choice in catalog::choices() {
|
||||
block = block.push(choice_row(
|
||||
ui,
|
||||
catalog,
|
||||
state,
|
||||
*choice,
|
||||
recommended == Some(choice.major),
|
||||
busy,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(job) = &report.job {
|
||||
block = block.push(divider(ui));
|
||||
block = block.push(running(ui, catalog, job));
|
||||
}
|
||||
if let Some(success) = &report.success {
|
||||
block = block.push(divider(ui));
|
||||
block = block.push(result::done(ui, catalog, success));
|
||||
}
|
||||
if let Some(failure) = &report.failure {
|
||||
block = block.push(divider(ui));
|
||||
block = block.push(result::failed(ui, catalog, failure, &report.tries));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 目录里的一行:大版本 + 徽标 + 承接的发行版 + 与当前实例的关系 + 安装按钮。
|
||||
fn choice_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
choice: JdkChoice,
|
||||
recommended: bool,
|
||||
busy: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let title = format!("Java {}", choice.major);
|
||||
let mut badges = row![].spacing(ui.space(Space::X1));
|
||||
if choice.lts {
|
||||
badges = badges.push(chip(
|
||||
ui,
|
||||
Chip::badge(catalog.text(Key::SettingsJavaDownloadLts)),
|
||||
));
|
||||
}
|
||||
if choice.baseline {
|
||||
badges = badges.push(chip(
|
||||
ui,
|
||||
Chip::badge(catalog.text(Key::SettingsJavaDownloadBaseline)),
|
||||
));
|
||||
}
|
||||
if recommended {
|
||||
badges = badges.push(chip(
|
||||
ui,
|
||||
Chip::badge(catalog.text(Key::SettingsJavaDownloadRecommended)),
|
||||
));
|
||||
}
|
||||
|
||||
let vendors = catalog::vendors_for(
|
||||
choice.major,
|
||||
&state.priority_vendors(),
|
||||
state.effective.domestic,
|
||||
);
|
||||
let vendors_line = if vendors.is_empty() {
|
||||
catalog.text(Key::SettingsJavaDownloadNoVendor).to_string()
|
||||
} else {
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadVendors,
|
||||
&[(
|
||||
"vendors",
|
||||
vendors
|
||||
.iter()
|
||||
.map(|vendor| vendor.display())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ")
|
||||
.as_str(),
|
||||
)],
|
||||
)
|
||||
};
|
||||
|
||||
let installed = state
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.major == Some(choice.major));
|
||||
|
||||
let mut block = column![row![
|
||||
container(
|
||||
column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
recommended,
|
||||
title,
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
badges,
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
install_button(ui, catalog, choice.major, installed.is_some(), busy),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
vendors_line,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
if let Some(fit) = state.fit(Some(choice.major)) {
|
||||
let (text, color) = fit_line(catalog, ui, fit);
|
||||
block = block.push(text_role_owned(ui, TypeRole::BodySmall, false, text, color));
|
||||
}
|
||||
if let Some(runtime) = installed {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadInstalled,
|
||||
&[("path", runtime.display_home().as_str())],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 「装哪一档与当前实例合不合」那一行(两向措辞,来自 [`Fit`])。
|
||||
fn fit_line(catalog: Catalog, ui: &Ui, fit: Fit) -> (String, iced::Color) {
|
||||
match fit {
|
||||
Fit::Ok => (
|
||||
catalog.text(Key::SettingsJavaDownloadFits).to_string(),
|
||||
ui.color(Role::Primary),
|
||||
),
|
||||
Fit::TooOld { required, .. } => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadTooLow,
|
||||
&[("minimum", required.to_string().as_str())],
|
||||
),
|
||||
ui.color(Role::Error),
|
||||
),
|
||||
Fit::TooNew { maximum, .. } => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadTooHigh,
|
||||
&[("maximum", maximum.to_string().as_str())],
|
||||
),
|
||||
ui.color(Role::Error),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装按钮:已在跑的作业期间一律灰掉(同时只允许一个),已装过的写成「重装」。
|
||||
fn install_button<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
major: u32,
|
||||
installed: bool,
|
||||
busy: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let label = catalog.text(if installed {
|
||||
Key::SettingsJavaDownloadReinstall
|
||||
} else {
|
||||
Key::SettingsJavaDownloadAction
|
||||
});
|
||||
if busy {
|
||||
return chip(ui, Chip::badge(label));
|
||||
}
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(label, false, msg(JavaMessage::Download(major))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 下载中:阶段 + 百分比 + 当前源 + 最近一次退避 / 换源。
|
||||
fn running<'a>(ui: &'a Ui, catalog: Catalog, job: &Job) -> Element<'a, Message> {
|
||||
let header = catalog.render(
|
||||
Key::SettingsJavaDownloadBusy,
|
||||
&[("major", job.major.to_string().as_str())],
|
||||
);
|
||||
let phase = match job.phase {
|
||||
Phase::Preparing => catalog
|
||||
.text(Key::SettingsJavaDownloadPhasePreparing)
|
||||
.to_string(),
|
||||
Phase::Downloading => match job.percent() {
|
||||
Some(percent) => catalog.render(
|
||||
Key::SettingsJavaDownloadPhaseDownloading,
|
||||
&[
|
||||
("percent", percent.to_string().as_str()),
|
||||
("done", result::mega(job.downloaded).as_str()),
|
||||
("total", result::mega(job.total.unwrap_or(0)).as_str()),
|
||||
],
|
||||
),
|
||||
// 分母未知:只说已经下了多少,不给假的百分比。
|
||||
None => result::mega(job.downloaded),
|
||||
},
|
||||
Phase::Verifying => catalog
|
||||
.text(Key::SettingsJavaDownloadPhaseVerifying)
|
||||
.to_string(),
|
||||
Phase::Extracting => catalog
|
||||
.text(Key::SettingsJavaDownloadPhaseExtracting)
|
||||
.to_string(),
|
||||
};
|
||||
let mut block = column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
header,
|
||||
ui.color(Role::Primary),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
phase,
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
if let Some(source) = &job.source {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadSource,
|
||||
&[("source", source.as_str())],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
if let Some(event) = &job.last {
|
||||
let text = event_line(catalog, event);
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 「哪个源失败 / 多久后重试」或「换源」那一条。
|
||||
fn event_line(catalog: Catalog, event: &Event) -> String {
|
||||
match event {
|
||||
Event::Retry {
|
||||
source,
|
||||
kind,
|
||||
attempt,
|
||||
retry_in_ms,
|
||||
} => catalog.render(
|
||||
Key::SettingsJavaDownloadRetry,
|
||||
&[
|
||||
("source", source.as_str()),
|
||||
("reason", source_reason(catalog, *kind).as_str()),
|
||||
("attempt", attempt.to_string().as_str()),
|
||||
(
|
||||
"seconds",
|
||||
format!(
|
||||
"{:.1}",
|
||||
f64::from(u32::try_from(*retry_in_ms).unwrap_or(u32::MAX)) / 1000.0
|
||||
)
|
||||
.as_str(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Event::Switched { from, to } => catalog.render(
|
||||
Key::SettingsJavaDownloadSwitch,
|
||||
&[("from", from.as_str()), ("to", to.as_str())],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DownloadErrorKind` → 页面文案(core 的原句只进日志,见 `downloads.rs`)。
|
||||
pub(super) fn source_reason(catalog: Catalog, kind: DownloadErrorKind) -> String {
|
||||
let key = match kind {
|
||||
DownloadErrorKind::NotFound => Key::SettingsJavaSourceReasonNotFound,
|
||||
DownloadErrorKind::ServerError => Key::SettingsJavaSourceReasonServer,
|
||||
DownloadErrorKind::Timeout => Key::SettingsJavaSourceReasonTimeout,
|
||||
DownloadErrorKind::Network => Key::SettingsJavaSourceReasonNetwork,
|
||||
DownloadErrorKind::Integrity => Key::SettingsJavaSourceReasonIntegrity,
|
||||
DownloadErrorKind::Io => Key::SettingsJavaSourceReasonIo,
|
||||
DownloadErrorKind::InvalidRequest
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => Key::SettingsJavaSourceReasonOther,
|
||||
};
|
||||
catalog.text(key).to_string()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 「下载成功 / 下载失败」那两行 + 字节格式化(M197)。
|
||||
//!
|
||||
//! 从 `download_view.rs` 拆出来守 400 行硬线:那个文件只剩「目录 / 下载中」两态,
|
||||
//! 结果两态与单位换算在这里。
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::pages::frame::text_role_owned;
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::downloads::{Failure, Success, Try};
|
||||
|
||||
/// 成功:装到哪 + 命中的源(或「复用已装,没走网络」)。
|
||||
pub(super) fn done<'a>(ui: &'a Ui, catalog: Catalog, success: &Success) -> Element<'a, Message> {
|
||||
let text = if success.reused {
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadDoneReused,
|
||||
&[
|
||||
("major", success.major.to_string().as_str()),
|
||||
("vendor", success.vendor),
|
||||
("root", success.root.as_str()),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadDone,
|
||||
&[
|
||||
("major", success.major.to_string().as_str()),
|
||||
("vendor", success.vendor),
|
||||
("root", success.root.as_str()),
|
||||
("source", success.source.as_deref().unwrap_or("?")),
|
||||
],
|
||||
)
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
}
|
||||
|
||||
/// 失败:大版本 + 试过哪些源(各自的失败分类)+ 下一步(core 的原句只进日志)。
|
||||
pub(super) fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
failure: &Failure,
|
||||
tries: &[Try],
|
||||
) -> Element<'a, Message> {
|
||||
let text = match (&failure.source, failure.kind) {
|
||||
(Some(source), _) => catalog.render(
|
||||
Key::SettingsJavaDownloadFailed,
|
||||
&[
|
||||
("major", failure.major.to_string().as_str()),
|
||||
("source", source.as_str()),
|
||||
],
|
||||
),
|
||||
(None, _) => catalog.render(
|
||||
Key::SettingsJavaDownloadFailedNoSource,
|
||||
&[("major", failure.major.to_string().as_str())],
|
||||
),
|
||||
};
|
||||
let mut block = column![text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::Error)
|
||||
)]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
// 「试过哪些源、各自为什么失败」:任务书那条「失败要能说出命中哪个源与退避原因」在
|
||||
// **最终失败**那一刻的落点(过程中的实时退避在 `download_view::running` 那一块)。
|
||||
if !tries.is_empty() {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog
|
||||
.text(Key::SettingsJavaDownloadTriedHeader)
|
||||
.to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
for attempt in tries.iter().take(4) {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsJavaDownloadTried,
|
||||
&[
|
||||
("source", attempt.source.as_str()),
|
||||
(
|
||||
"reason",
|
||||
super::source_reason(catalog, attempt.kind).as_str(),
|
||||
),
|
||||
],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
}
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog
|
||||
.text(Key::SettingsJavaDownloadFailedHint)
|
||||
.to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 字节 → 「88.4 MB」(分母未知时也只说下了多少)。
|
||||
pub(super) fn mega(bytes: u64) -> String {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let mb = bytes as f64 / (1024.0 * 1024.0);
|
||||
format!("{mb:.1} MB")
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! 「下载一个 JDK」的作业与状态(M197)。
|
||||
//!
|
||||
//! 用户原话:「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。
|
||||
//! (国内用户走 github release 记得配置加速镜像)」——本文件负责**真下**:选好大版本之后
|
||||
//! 起一个后台线程跑 `core::game::java::install`(查版本 → 回退链 → 下载引擎 → 解压 → 探测),
|
||||
//! 界面按快照读进度。
|
||||
//!
|
||||
//! ## 为什么又是「后台线程 + 快照」
|
||||
//!
|
||||
//! 与 M175 的扫描同一条理由(`store.rs` 里记了完整推导):本 crate 的执行器是 `thread-pool`,
|
||||
//! 而一次 JDK 下载要的是**进度(百分比)+ 失败换源原因 + 不卡 UI**。快照的代价是一把短锁 +
|
||||
//! 每拍一次比较;好处是窗口关掉之后不会往死掉的 channel 里写。
|
||||
//!
|
||||
//! ## 进度与「失败退避原因」从哪来
|
||||
//!
|
||||
//! 引擎的 [`ProgressEmitter`] 会把**每一次尝试**都吐出来:
|
||||
//!
|
||||
//! | 事件 | 界面拿它说什么 |
|
||||
//! |---|---|
|
||||
//! | `Started { total_bytes }` | 分母(未知就不显示百分比,不做假进度) |
|
||||
//! | `Progress { downloaded }` | 百分比 |
|
||||
//! | `SourceFailed { source, kind, attempt, retry_in_ms }` | 「清华 TUNA 失败(服务器报错);2 秒后重试第 2 次」 |
|
||||
//! | `SourceSwitched { from, to }` | 「换源:A → B」 |
|
||||
//! | `Completed { source }` | 「命中的源」(**装完之后要能说出来**) |
|
||||
//! | `Verifying` | 校验阶段 |
|
||||
//!
|
||||
//! `SourceFailed` 里的 `reason` 是 core 的中文句子,**只进 stderr 日志**;界面按结构化的
|
||||
//! `kind` 选 i18n 文案(M175 r2 P3-3 的同一条纪律:core 的句子不进双语界面)。
|
||||
//!
|
||||
//! ## 为什么不复用 `download::shared::engine_for`
|
||||
//!
|
||||
//! `engine_for` 给的是 `Arc<DownloadEngine>`,而 `with_progress` 要**拿走**引擎(`self`)。
|
||||
//! 要让共享引擎带「这一次作业的」进度观察者,得把引擎内部的 `progress` 改成可插拔的观察者
|
||||
//! 名单——那是引擎侧的范围(M193 的镜像/并发那一摊),本 mission 不碰。代价写在
|
||||
//! [`engine::engine_with`] 上:这一次作业有一条自己的连接池,但**盘上的**缓存与健康度仍然共用。
|
||||
//!
|
||||
//! ## 同时只允许一个
|
||||
//!
|
||||
//! [`Slot::start`] 的判据是「线程还活着」(`running`)。理由与扫描的取消位一致:两个作业抢同一个
|
||||
//! 归档缓存目录会互相搬走对方的中间文件(`PartFile` 的会话命名只隔进程,不隔线程)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::{DownloadErrorKind, ProgressEmitter, ProgressEvent};
|
||||
use unlucky_core::game::java::{
|
||||
install, InstallRequest, InstalledJdk, JavaError, JavaErrorKind, JavaInstallRoot, JdkVendor,
|
||||
};
|
||||
|
||||
/// 推荐判据(`java.auto_install` 的消费者)与引擎装配各自一个文件,见模块文档。
|
||||
mod engine;
|
||||
mod recommend;
|
||||
mod refusal;
|
||||
|
||||
pub use recommend::recommend;
|
||||
pub use refusal::Refusal;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// 一次作业的阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
/// 查版本(还没开始下字节)
|
||||
Preparing,
|
||||
/// 下归档
|
||||
Downloading,
|
||||
/// 校验摘要
|
||||
Verifying,
|
||||
/// 解压 + 探测(下载已经完成)
|
||||
Extracting,
|
||||
}
|
||||
|
||||
/// 一条「退避 / 换源」记录(界面上那一行)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Event {
|
||||
/// 某个源失败(`retry_in_ms = 0` 表示不再重试、直接换源)
|
||||
Retry {
|
||||
/// 源标识(`tuna` / `github` / `huawei` …)
|
||||
source: String,
|
||||
/// 结构化失败分类(文案层按它选 i18n key)
|
||||
kind: DownloadErrorKind,
|
||||
/// 第几次尝试(从 1 起,给人看)
|
||||
attempt: u32,
|
||||
/// 下一次重试的等待(毫秒)
|
||||
retry_in_ms: u64,
|
||||
},
|
||||
/// 换源
|
||||
Switched {
|
||||
/// 原源
|
||||
from: String,
|
||||
/// 新源
|
||||
to: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 正在跑的作业。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Job {
|
||||
/// 目标大版本
|
||||
pub major: u32,
|
||||
/// 阶段
|
||||
pub phase: Phase,
|
||||
/// 已下载字节
|
||||
pub downloaded: u64,
|
||||
/// 总字节(`None` = 服务器没给长度,不显示百分比)
|
||||
pub total: Option<u64>,
|
||||
/// 当前 / 命中的源
|
||||
pub source: Option<String>,
|
||||
/// 最近一条换源 / 退避记录
|
||||
pub last: Option<Event>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
fn new(major: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
phase: Phase::Preparing,
|
||||
downloaded: 0,
|
||||
total: None,
|
||||
source: None,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 百分比(`None` = 分母未知)。
|
||||
#[must_use]
|
||||
pub fn percent(&self) -> Option<u32> {
|
||||
let total = self.total.filter(|total| *total > 0)?;
|
||||
let percent = self.downloaded.saturating_mul(100) / total;
|
||||
Some(u32::try_from(percent.min(100)).unwrap_or(100))
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次失败(`kind` 是 core 的稳定分类,文案层自己翻)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Failure {
|
||||
/// 目标大版本
|
||||
pub major: u32,
|
||||
/// core 的错误分类
|
||||
pub kind: JavaErrorKind,
|
||||
/// 最后试到的源(查版本阶段就失败时没有)
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
/// 一次成功。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Success {
|
||||
/// 目标大版本
|
||||
pub major: u32,
|
||||
/// 实际装上的发行版(展示名)
|
||||
pub vendor: &'static str,
|
||||
/// 托管目录
|
||||
pub root: String,
|
||||
/// `bin/java[.exe]`
|
||||
pub binary: String,
|
||||
/// 命中的下载源(`None` = 复用已装,没走网络)
|
||||
pub source: Option<String>,
|
||||
/// 是否完全复用了已装的那一份
|
||||
pub reused: bool,
|
||||
}
|
||||
|
||||
/// 「这一次作业试过的一个源」(失败态逐条列出来:哪个源、为什么)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Try {
|
||||
/// 源标识
|
||||
pub source: String,
|
||||
/// 结构化失败分类
|
||||
pub kind: DownloadErrorKind,
|
||||
}
|
||||
|
||||
/// 界面读的那份快照。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// 正在跑的作业(`None` = 没在跑)
|
||||
pub job: Option<Job>,
|
||||
/// 这一次作业试过的源(按首次失败顺序,同一源只留一条)
|
||||
pub tries: Vec<Try>,
|
||||
/// 上一次失败
|
||||
pub failure: Option<Failure>,
|
||||
/// 上一次成功
|
||||
pub success: Option<Success>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// 记一跳失败(同一源只留第一条:界面要的是「试过哪些」,不是重试次数)。
|
||||
fn record_try(&mut self, source: &str, kind: DownloadErrorKind) {
|
||||
if self.tries.iter().any(|known| known.source == source) {
|
||||
return;
|
||||
}
|
||||
self.tries.push(Try {
|
||||
source: source.to_string(),
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次作业的入参(app 层算好:装到哪、按什么顺序、缓存放哪)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
/// 目标大版本
|
||||
pub major: u32,
|
||||
/// 下载缓存根(`<home>/cache`,与其它下载共用)
|
||||
pub cache_root: PathBuf,
|
||||
/// 托管落点(`JavaInstallRoot::resolve` 的结果)
|
||||
pub install_root: JavaInstallRoot,
|
||||
/// 用户优先级(`java.vendorPriority`;空 = 地区默认序)
|
||||
pub priority: Vec<JdkVendor>,
|
||||
}
|
||||
|
||||
/// 作业槽:快照 + 「线程还活着」。
|
||||
///
|
||||
/// 内部是 `Arc`:`State` 会被整体替换(hydrate / 截图种子),而作业线程要活得比某一次赋值更久。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Slot {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
report: Mutex<Report>,
|
||||
running: AtomicBool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// 当前快照。
|
||||
#[must_use]
|
||||
pub fn report(&self) -> Report {
|
||||
self.inner
|
||||
.report
|
||||
.lock()
|
||||
.map(|report| report.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 有没有作业在跑。
|
||||
#[must_use]
|
||||
pub fn busy(&self) -> bool {
|
||||
self.inner.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 发起一次安装(已在跑 ⇒ 什么都不做,返回 `false`)。
|
||||
pub fn start(&self, request: Request) -> bool {
|
||||
if self.inner.running.swap(true, Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
if let Ok(mut report) = self.inner.report.lock() {
|
||||
*report = Report {
|
||||
job: Some(Job::new(request.major)),
|
||||
tries: Vec::new(),
|
||||
failure: None,
|
||||
success: None,
|
||||
};
|
||||
}
|
||||
let slot = self.clone();
|
||||
std::thread::spawn(move || slot.run(request));
|
||||
true
|
||||
}
|
||||
|
||||
/// 线程体:挂进度发射器 → 跑 install → 把结论写回快照。
|
||||
fn run(self, request: Request) {
|
||||
let (emitter, mut events) = ProgressEmitter::channel();
|
||||
let feed = self.clone();
|
||||
// 抽干事件的那条线:`blocking_recv` 在普通线程上不 panic(tokio 只禁异步上下文里调)。
|
||||
let drain = std::thread::spawn(move || {
|
||||
while let Some(event) = events.blocking_recv() {
|
||||
feed.apply(event);
|
||||
}
|
||||
});
|
||||
let engine = engine::engine_with(&request.cache_root, emitter);
|
||||
let install_request = InstallRequest::new(request.major).with_priority(request.priority);
|
||||
let result = block_on(install(&engine, &request.install_root, install_request));
|
||||
// 引擎丢掉 ⇒ 发射器丢掉 ⇒ 抽干线程读到 `None` 自然退出。
|
||||
drop(engine);
|
||||
let _ = drain.join();
|
||||
self.finish(result, request.major);
|
||||
self.inner.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 一条引擎事件 → 快照(纯函数式:界面只读结论)。
|
||||
fn apply(&self, event: ProgressEvent) {
|
||||
let Ok(mut report) = self.inner.report.lock() else {
|
||||
return;
|
||||
};
|
||||
// 先记「这一跳试过哪个源、为什么失败」——失败态要在界面上逐条列出来。
|
||||
if let ProgressEvent::SourceFailed { source, kind, .. } = &event {
|
||||
report.record_try(source, *kind);
|
||||
}
|
||||
let Some(job) = report.job.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
ProgressEvent::Started { total_bytes, .. } => {
|
||||
job.phase = Phase::Downloading;
|
||||
job.total = total_bytes;
|
||||
}
|
||||
ProgressEvent::Progress {
|
||||
downloaded,
|
||||
total_bytes,
|
||||
..
|
||||
} => {
|
||||
job.phase = Phase::Downloading;
|
||||
job.downloaded = downloaded;
|
||||
if total_bytes.is_some() {
|
||||
job.total = total_bytes;
|
||||
}
|
||||
}
|
||||
ProgressEvent::SourceFailed {
|
||||
source,
|
||||
kind,
|
||||
reason,
|
||||
attempt,
|
||||
retry_in_ms,
|
||||
..
|
||||
} => {
|
||||
// core 的原句只进日志:界面要双语,不能直接吐中文句子。
|
||||
eprintln!("[java] 源 {source} 失败({}):{reason}", kind.as_str());
|
||||
job.source = Some(source.clone());
|
||||
job.last = Some(Event::Retry {
|
||||
source,
|
||||
kind,
|
||||
attempt: attempt.saturating_add(1),
|
||||
retry_in_ms,
|
||||
});
|
||||
}
|
||||
ProgressEvent::SourceSwitched { from, to, .. } => {
|
||||
job.source = Some(to.clone());
|
||||
job.last = Some(Event::Switched { from, to });
|
||||
}
|
||||
ProgressEvent::Verifying { .. } => job.phase = Phase::Verifying,
|
||||
ProgressEvent::Completed { source, .. } => {
|
||||
job.phase = Phase::Extracting;
|
||||
job.source = Some(source);
|
||||
}
|
||||
ProgressEvent::Failed { kind, message, .. } => {
|
||||
eprintln!("[java] 下载失败({}):{message}", kind.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// install 的结论 → 快照。
|
||||
fn finish(&self, result: Result<InstalledJdk, JavaError>, major: u32) {
|
||||
let Ok(mut report) = self.inner.report.lock() else {
|
||||
return;
|
||||
};
|
||||
let source = report.job.as_ref().and_then(|job| job.source.clone());
|
||||
report.job = None;
|
||||
match result {
|
||||
Ok(installed) => {
|
||||
report.failure = None;
|
||||
report.success = Some(Success {
|
||||
major: installed.major,
|
||||
vendor: installed.vendor.display(),
|
||||
root: installed.root.display().to_string(),
|
||||
binary: installed.binary().display().to_string(),
|
||||
source: installed.downloaded.clone().or(source),
|
||||
reused: installed.reused(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"[java] Java {major} 安装失败({}):{}",
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
);
|
||||
report.success = None;
|
||||
report.failure = Some(Failure {
|
||||
major,
|
||||
kind: error.kind(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图种子用:把快照钉成某一档(只给 `--screenshot` 走,见 `seed.rs`)。
|
||||
///
|
||||
/// 产品路径不调它:真进度只能来自 [`Slot::run`] 那条线程。
|
||||
pub fn seed_report(&self, report: Report) {
|
||||
if let Ok(mut slot) = self.inner.report.lock() {
|
||||
*slot = report;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例。
|
||||
//!
|
||||
//! 从 `downloads.rs` 拆出来(行数 + 口径独立:这里只说「为什么不用共享引擎」)。
|
||||
//! 装配口径与 `core::download::shared::build` 一致,只多挂一个 `ProgressEmitter`。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::region::{decide_from, load_cached};
|
||||
use unlucky_core::download::region_system::detect_signals;
|
||||
use unlucky_core::download::{
|
||||
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, ProgressEmitter,
|
||||
};
|
||||
|
||||
/// 带进度发射器的引擎(装配口径与 `download::shared::build` 一致,只多挂一个 emitter)。
|
||||
///
|
||||
/// 为什么不再走 `shared::engine_for`:那个登记处交出的是 `Arc<DownloadEngine>`,而挂进度要
|
||||
/// 拿走引擎(`with_progress(self)`)。要让共享引擎带「按作业」的观察者,得改引擎内部的
|
||||
/// `progress` 字段为观察者名单——那是引擎侧(M193 的镜像/并发那摊)的范围。
|
||||
/// 代价:一次 JDK 安装有自己的连接池;**盘上的** `health.json` / `index.json` / `region.json`
|
||||
/// 是同一个,所以换源经验仍然跨作业累积。
|
||||
pub(super) fn engine_with(cache_root: &Path, progress: ProgressEmitter) -> DownloadEngine {
|
||||
let now = now_ms();
|
||||
let profile = load_cached(&cache_root.join("region.json"), now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = std::fs::create_dir_all(cache_root);
|
||||
profile
|
||||
});
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(profile),
|
||||
Arc::new(HealthRegistry::open_in(cache_root, now)),
|
||||
Arc::new(ContentCache::open(cache_root)),
|
||||
)
|
||||
.with_progress(progress)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 「现在该推荐哪一档」(M197):`java.auto_install` 的唯一消费者。
|
||||
//!
|
||||
//! 从 `downloads.rs` 拆出来(那个文件加了作业槽与事件映射之后越过 400 行硬线)。
|
||||
//! 纯函数:只读「当前实例要求 + 已扫到的运行时 + 这个键」,不联网、不落盘。
|
||||
|
||||
use unlucky_core::game::java::catalog;
|
||||
|
||||
use crate::pages::settings::java::context::Requirement;
|
||||
use crate::pages::settings::java::row::RuntimeRow;
|
||||
|
||||
/// 「现在该推荐哪一档」:`java.auto_install` 的真实消费者。
|
||||
///
|
||||
/// 语义(就是界面上那句 hint):**开着**时,只有当「扫到的 JDK 里没有一个满足当前实例的要求」
|
||||
/// (没有实例时 = 一个 JDK 都没有)才推荐一档;推荐的是当前实例最低要求对齐到目录的那一档
|
||||
/// ([`catalog::default_major`],没有实例时 = 基准 17)。**关掉**就一律不推荐,用户自己挑。
|
||||
///
|
||||
/// 为什么不直接自动开始下载:那是 180 MiB 起步、且用户可能只想手动指定 JAVA_HOME。
|
||||
/// 「替你选好一档 + 一句话说明」把决定权留在用户手上,同时把「该装哪个」这件知识给出去。
|
||||
#[must_use]
|
||||
pub fn recommend(
|
||||
required: Option<&Requirement>,
|
||||
runtimes: &[RuntimeRow],
|
||||
auto_install: bool,
|
||||
) -> Option<u32> {
|
||||
if !auto_install {
|
||||
return None;
|
||||
}
|
||||
let satisfied = match required {
|
||||
Some(required) => runtimes.iter().any(|runtime| {
|
||||
runtime
|
||||
.major
|
||||
.is_some_and(|major| required.satisfied_by(major))
|
||||
}),
|
||||
None => !runtimes.is_empty(),
|
||||
};
|
||||
if satisfied {
|
||||
return None;
|
||||
}
|
||||
Some(catalog::default_major(
|
||||
required.map(|required| required.minimum),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 「下载按钮为什么没开始」的四档原因(M197)。
|
||||
//!
|
||||
//! 从 `downloads.rs` 拆出来守 400 行硬线(与 `downloads/tests.rs` 同一种拆法)。
|
||||
//! 点了没反应是禁止的:每一种拒绝都要在下载区顶上写清是哪一档。
|
||||
|
||||
/// 「没开始」的四种原因(每一种都要在界面上说清,不许点了没反应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// 目录里没有这个大版本
|
||||
UnknownMajor,
|
||||
/// 这一档在这台机器上没有发行版承接
|
||||
NoVendor,
|
||||
/// 已经有一个作业在跑
|
||||
Busy,
|
||||
/// 没有可写的设置文件
|
||||
NoStorage,
|
||||
/// 有设置文件,但安装根解析不出来(standard 模式拿不到 %APPDATA% 之类的系统目录)
|
||||
RootUnresolved,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// 文案 key(页面自己的拒绝原因,与 `state::Refusal` 同一套渲染路径)。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> unlucky_i18n::Key {
|
||||
use unlucky_i18n::Key;
|
||||
match self {
|
||||
Self::UnknownMajor => Key::SettingsJavaReasonDownloadUnknownMajor,
|
||||
Self::NoVendor => Key::SettingsJavaReasonDownloadNoVendor,
|
||||
Self::Busy => Key::SettingsJavaReasonDownloadBusy,
|
||||
Self::NoStorage => Key::SettingsJavaNoStorage,
|
||||
Self::RootUnresolved => Key::SettingsJavaReasonRootUnresolved,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 「下载一个 JDK」的作业与状态(M197):纯事件 → 快照的映射守卫。
|
||||
//!
|
||||
//! 从 `downloads.rs` 拆出来守 400 行硬线(与 `tests/runtime.rs` 同一种拆法:断言跟着它守的
|
||||
//! 东西走,模块文件只留实现)。这些用例**不联网**:喂的是引擎会吐的那几种 `ProgressEvent`。
|
||||
use super::{Event, Failure, Job, Phase, Report, Slot};
|
||||
use unlucky_core::download::types::DownloadId;
|
||||
use unlucky_core::download::{DownloadErrorKind, ProgressEvent};
|
||||
use unlucky_core::game::java::JavaErrorKind;
|
||||
|
||||
/// 一条下载事件链 → 界面能看到「百分比 + 命中哪个源」。
|
||||
///
|
||||
/// 变异证明:把 `apply` 里 `ProgressEvent::Progress` 那一臂的 `job.downloaded = downloaded`
|
||||
/// 删掉 ⇒ 本用例红(百分比恒为 0)。
|
||||
#[test]
|
||||
fn progress_events_fill_the_percent_and_the_source() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job::new(21)),
|
||||
..Report::default()
|
||||
});
|
||||
let id = DownloadId::next();
|
||||
slot.apply(ProgressEvent::Started {
|
||||
id,
|
||||
kind: unlucky_core::download::ResourceKind::JavaRuntime,
|
||||
url: "https://example.invalid/jdk.zip".to_string(),
|
||||
dest: "jdk.zip".to_string(),
|
||||
candidates: 2,
|
||||
total_bytes: Some(200),
|
||||
});
|
||||
slot.apply(ProgressEvent::Progress {
|
||||
id,
|
||||
downloaded: 50,
|
||||
total_bytes: Some(200),
|
||||
});
|
||||
let job = slot.report().job.expect("作业还在");
|
||||
assert_eq!(job.phase, Phase::Downloading);
|
||||
assert_eq!(job.percent(), Some(25), "50/200 = 25%");
|
||||
slot.apply(ProgressEvent::Completed {
|
||||
id,
|
||||
bytes: 200,
|
||||
from_cache: false,
|
||||
source: "tuna".to_string(),
|
||||
elapsed_ms: 10,
|
||||
});
|
||||
let job = slot.report().job.expect("作业还在");
|
||||
assert_eq!(job.phase, Phase::Extracting, "下完就该进解压阶段");
|
||||
assert_eq!(job.source.as_deref(), Some("tuna"), "命中哪个源要说得出");
|
||||
}
|
||||
|
||||
/// 分母未知时**不做假进度**(`percent()` 给 `None`)。
|
||||
#[test]
|
||||
fn an_unknown_total_shows_no_percent() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job::new(17)),
|
||||
..Report::default()
|
||||
});
|
||||
slot.apply(ProgressEvent::Progress {
|
||||
id: DownloadId::next(),
|
||||
downloaded: 1024,
|
||||
total_bytes: None,
|
||||
});
|
||||
assert_eq!(slot.report().job.expect("作业").percent(), None);
|
||||
}
|
||||
|
||||
/// 退避与换源要留在快照里(界面那一行「A 失败;2 秒后重试第 2 次」/「换源」)。
|
||||
///
|
||||
/// 变异证明:把 `SourceFailed` 那一臂的 `job.last = Some(Event::Retry { .. })` 删掉
|
||||
/// ⇒ 本用例红。
|
||||
#[test]
|
||||
fn retries_and_switches_are_kept_for_the_ui() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job::new(8)),
|
||||
..Report::default()
|
||||
});
|
||||
let id = DownloadId::next();
|
||||
slot.apply(ProgressEvent::SourceFailed {
|
||||
id,
|
||||
source: "tuna".to_string(),
|
||||
kind: DownloadErrorKind::ServerError,
|
||||
reason: "服务器 503".to_string(),
|
||||
attempt: 1,
|
||||
will_retry: true,
|
||||
retry_in_ms: 2_000,
|
||||
});
|
||||
assert_eq!(
|
||||
slot.report().job.expect("作业").last,
|
||||
Some(Event::Retry {
|
||||
source: "tuna".to_string(),
|
||||
kind: DownloadErrorKind::ServerError,
|
||||
attempt: 2,
|
||||
retry_in_ms: 2_000,
|
||||
})
|
||||
);
|
||||
slot.apply(ProgressEvent::SourceSwitched {
|
||||
id,
|
||||
from: "tuna".to_string(),
|
||||
to: "ustc".to_string(),
|
||||
});
|
||||
let job = slot.report().job.expect("作业");
|
||||
assert_eq!(job.source.as_deref(), Some("ustc"));
|
||||
assert_eq!(
|
||||
job.last,
|
||||
Some(Event::Switched {
|
||||
from: "tuna".to_string(),
|
||||
to: "ustc".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// 「该推荐哪一档」:`java.auto_install` 的真实消费者(界面上那行「为当前实例推荐这一档」)。
|
||||
///
|
||||
/// 变异证明:把 `recommend` 开头 `if !auto_install { return None; }` 删掉 ⇒ 关掉那一档红;
|
||||
/// 把 `satisfied` 判据写成恒 `false` ⇒ 「已满足就不推荐」那一档红。
|
||||
#[test]
|
||||
fn auto_install_decides_whether_a_major_is_recommended() {
|
||||
use super::recommend;
|
||||
use crate::pages::settings::java::context::Requirement;
|
||||
use crate::pages::settings::java::row::RuntimeRow;
|
||||
|
||||
fn row(major: u32) -> RuntimeRow {
|
||||
RuntimeRow {
|
||||
binary: format!("C:/jdk-{major}/bin/java.exe"),
|
||||
version: format!("openjdk version \"{major}.0.1\""),
|
||||
major: Some(major),
|
||||
vendor: Some("Temurin".to_string()),
|
||||
source: "system",
|
||||
}
|
||||
}
|
||||
|
||||
let required = Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.5".to_string(),
|
||||
minimum: 21,
|
||||
maximum: None,
|
||||
};
|
||||
let none: &[RuntimeRow] = &[];
|
||||
assert_eq!(recommend(Some(&required), none, true), Some(21));
|
||||
assert_eq!(recommend(Some(&required), none, false), None);
|
||||
let satisfied = [row(21)];
|
||||
assert_eq!(recommend(Some(&required), &satisfied, true), None);
|
||||
let too_old = [row(17)];
|
||||
assert_eq!(
|
||||
recommend(Some(&required), &too_old, true),
|
||||
Some(21),
|
||||
"手里只有 17、实例要 21 时照样推荐 21"
|
||||
);
|
||||
assert_eq!(
|
||||
recommend(None, none, true),
|
||||
Some(17),
|
||||
"没有实例时推荐基准 17"
|
||||
);
|
||||
assert_eq!(recommend(None, &satisfied, true), None);
|
||||
}
|
||||
|
||||
/// 真下载一次(`--ignored`,需要网络):装到临时目录并确认 `bin/java` 存在。
|
||||
///
|
||||
/// 这是「真入口」的端到端证据:`Slot::start` → `engine_with`(镜像表 / 地区 / 健康度)→
|
||||
/// `java::install`(查版本 → 回退链 → 下载 → 解压 → 探测)。默认不跑:它会下 ~180 MB。
|
||||
#[test]
|
||||
#[ignore = "真下载(网络 + 数百 MB),用 cargo test -- --ignored 手动跑"]
|
||||
fn the_real_download_installs_a_usable_jdk() {
|
||||
use super::{Request, Slot};
|
||||
use unlucky_core::game::java::JavaInstallRoot;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ull-java-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("cache")).expect("temp");
|
||||
let slot = Slot::default();
|
||||
assert!(slot.start(Request {
|
||||
major: 21,
|
||||
cache_root: dir.join("cache"),
|
||||
install_root: JavaInstallRoot::custom(dir.join("jdks")),
|
||||
priority: Vec::new(),
|
||||
}));
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(900);
|
||||
while slot.busy() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
assert!(!slot.busy(), "作业应在 15 分钟内结束");
|
||||
let report = slot.report();
|
||||
if let Some(failure) = &report.failure {
|
||||
panic!("安装失败:{failure:?}");
|
||||
}
|
||||
let success = report.success.expect("应成功");
|
||||
assert!(
|
||||
std::path::Path::new(&success.binary).is_file(),
|
||||
"bin/java 应存在:{}",
|
||||
success.binary
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 失败要留档:分类 + 大版本 + 最后试到的源(界面靠这三样说清「为什么没装成」)。
|
||||
///
|
||||
/// 变异证明:把 `finish` 的 `Err` 分支里 `report.failure = Some(...)` 删掉 ⇒ 本用例红。
|
||||
#[test]
|
||||
fn a_failure_is_kept_with_its_kind_and_source() {
|
||||
let slot = Slot::default();
|
||||
slot.seed_report(Report {
|
||||
job: Some(Job {
|
||||
source: Some("tuna".to_string()),
|
||||
..Job::new(21)
|
||||
}),
|
||||
..Report::default()
|
||||
});
|
||||
slot.finish(
|
||||
Err(unlucky_core::game::java::JavaError::new(
|
||||
JavaErrorKind::Metadata,
|
||||
"没有装成".to_string(),
|
||||
)),
|
||||
21,
|
||||
);
|
||||
let report = slot.report();
|
||||
assert!(report.success.is_none());
|
||||
assert!(report.job.is_none(), "失败之后不该还挂着作业");
|
||||
let failure: Failure = report.failure.expect("失败要留档");
|
||||
assert_eq!(failure.kind, JavaErrorKind::Metadata);
|
||||
assert_eq!(failure.major, 21);
|
||||
assert_eq!(failure.source.as_deref(), Some("tuna"));
|
||||
}
|
||||
|
||||
/// **占位符不许漏**:`Catalog::render` 缺参数时会把 `{name}` 留下 `(name)`,而那张图已经能看见
|
||||
/// (M197 出图时实测到 `试过 (tried) 家发行版`——模板里有一个调用点没给的占位符)。
|
||||
///
|
||||
/// 这条对**本页自己渲染的每一条下载文案**做同一件事:拿真实参数渲一遍,断言输出里没有
|
||||
/// 「`(` + 标识符 + `)`」这种残留。ASCII 括号是判据:本页这些模板里没有合法的 ASCII 括号短语
|
||||
/// (中文文案用全角「()」),所以命中即漏参。
|
||||
///
|
||||
/// 变异证明:把 `SettingsJavaDownloadFailed` 的模板改回「`(tried {n} vendors…)`」那种带
|
||||
/// `{tried}` 的写法 ⇒ 本用例红(渲出 `(tried)`)。M197 就是这么发现它的。
|
||||
#[test]
|
||||
fn every_download_template_renders_without_leftover_placeholders() {
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
let catalog = Lang::En.catalog();
|
||||
let cases: [(&str, Key, &[(&str, &str)]); 6] = [
|
||||
(
|
||||
"failed",
|
||||
Key::SettingsJavaDownloadFailed,
|
||||
&[("major", "21"), ("source", "tuna")],
|
||||
),
|
||||
(
|
||||
"failed-no-source",
|
||||
Key::SettingsJavaDownloadFailedNoSource,
|
||||
&[("major", "21")],
|
||||
),
|
||||
(
|
||||
"done",
|
||||
Key::SettingsJavaDownloadDone,
|
||||
&[
|
||||
("major", "21"),
|
||||
("vendor", "Eclipse Temurin"),
|
||||
("root", "D:/jdks/21"),
|
||||
("source", "tuna"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"done-reused",
|
||||
Key::SettingsJavaDownloadDoneReused,
|
||||
&[
|
||||
("major", "21"),
|
||||
("vendor", "Eclipse Temurin"),
|
||||
("root", "D:/jdks/21"),
|
||||
],
|
||||
),
|
||||
("busy", Key::SettingsJavaDownloadBusy, &[("major", "21")]),
|
||||
(
|
||||
"retry",
|
||||
Key::SettingsJavaDownloadRetry,
|
||||
&[
|
||||
("source", "tuna"),
|
||||
("reason", "server error"),
|
||||
("attempt", "2"),
|
||||
("seconds", "2.0"),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (tag, key, args) in cases {
|
||||
let text = catalog.render(key, args);
|
||||
let mut rest = text.as_str();
|
||||
while let Some(open) = rest.find('(') {
|
||||
let after = &rest[open + 1..];
|
||||
let close = after.find(')');
|
||||
if let Some(close) = close {
|
||||
let inner = &after[..close];
|
||||
assert!(
|
||||
inner.is_empty()
|
||||
|| !inner.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
"{tag} 渲染后还留着占位符 `({inner})`:{text}"
|
||||
);
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,7 @@ pub(super) fn notice_line<'a>(
|
||||
catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]),
|
||||
false,
|
||||
),
|
||||
Notice::PrefsSaved => (catalog.text(Key::SettingsJavaPrefsSaved).to_string(), false),
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 三个设置键的可编辑状态(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
|
||||
//!
|
||||
//! 用户原话(2026-11-08):「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」
|
||||
//! M175 把 Java 页接上了真运行时,但明确没做这三个键;本文件只负责**草稿**与**生效值**两半。
|
||||
//!
|
||||
//! ## 为什么又是「草稿 / 落盘真值」两层
|
||||
//!
|
||||
//! 与 `state.rs` 的内存草稿同一条理由:输入框里的是**文本**(用户可能刚敲了 `D:\Ja`),
|
||||
//! 「现在生效什么」是**磁盘上的真值**。混成一个字段就会重演「显示的就是生效的」——而这三项里
|
||||
//! `installRoot` 与 `vendorPriority` 都是**可以留空**的键,留空的语义是「跟数据目录模式走 /
|
||||
//! 跟地区默认序走」,只有拿落盘值去 resolve 一次才说得清到底落在哪、按什么顺序找。
|
||||
//!
|
||||
//! ## 「从哪一层来」怎么表达
|
||||
//!
|
||||
//! [`Effective`] 是 app 层算好的**只读结论**(`prefs_store::effective`):有效的安装根 + 它是
|
||||
//! 来自 `java.installRoot` 还是 `core.data_mode`;有效的发行版顺序 + 它来自 `java.vendorPriority`
|
||||
//! 还是地区默认序。渲染器只负责把这两个枚举翻成一句话,不再自己判断层级。
|
||||
//!
|
||||
//! ## 优先级草稿为什么是「整条顺序」而不是「用户写的那几家」
|
||||
//!
|
||||
//! `vendor::order` 的语义是**补集追加**(只写 oracle 就把它提前、其余按地区默认序补齐)。
|
||||
//! 于是「上移 / 下移」这种操作只有在**看得见的那条完整顺序**上才有意义:用户看到 4 行,
|
||||
//! 拖动其中一行,落盘的就是他自己排的那条完整链。清空([`Prefs::reset_priority`])= 回到
|
||||
//! 「跟地区默认序走」,这也是这个键留空时唯一说得通的语义。
|
||||
|
||||
use unlucky_core::game::java::vendor::{self, JdkVendor};
|
||||
|
||||
use super::state::Refusal;
|
||||
|
||||
/// `java.installRoot` 的最大长度:与 `core::setting::keys` 里那个键登记的上限一致(512)。
|
||||
pub const MAX_INSTALL_ROOT: usize = 512;
|
||||
|
||||
/// `java.auto_install` 的出厂默认值(schema 默认 `true`)。
|
||||
pub const DEFAULT_AUTO_INSTALL: bool = true;
|
||||
|
||||
/// 三键的草稿 + 落盘真值。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Prefs {
|
||||
/// `java.installRoot` 草稿(空 = 跟数据目录模式走)
|
||||
pub install_root: String,
|
||||
/// 落盘的 `java.installRoot`
|
||||
pub saved_install_root: String,
|
||||
/// `java.auto_install` 草稿
|
||||
pub auto_install: bool,
|
||||
/// 落盘的 `java.auto_install`
|
||||
pub saved_auto_install: bool,
|
||||
/// `java.vendorPriority` 草稿(稳定 id,顺序即优先级;空 = 地区默认序)
|
||||
pub priority: Vec<&'static str>,
|
||||
/// 落盘的 `java.vendorPriority`
|
||||
pub saved_priority: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
/// 出厂值 = schema 的默认值(**不是**零值:`auto_install` 出厂是 `true`)。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
install_root: String::new(),
|
||||
saved_install_root: String::new(),
|
||||
auto_install: DEFAULT_AUTO_INSTALL,
|
||||
saved_auto_install: DEFAULT_AUTO_INSTALL,
|
||||
priority: Vec::new(),
|
||||
saved_priority: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 有效的安装根从哪一层来。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RootLayer {
|
||||
/// 设置键 `java.installRoot`
|
||||
Setting,
|
||||
/// 数据目录模式(`core.data_mode`)派生的落点
|
||||
DataMode,
|
||||
}
|
||||
|
||||
/// 有效的发行版顺序从哪一层来。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OrderLayer {
|
||||
/// 设置键 `java.vendorPriority`
|
||||
Setting,
|
||||
/// 地区默认序(国内 / 国外两套内置序)
|
||||
Region,
|
||||
}
|
||||
|
||||
/// app 层每次 hydrate 重算的只读结论(渲染器只翻文案,不再判断层级)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Effective {
|
||||
/// 有效的安装根(人读路径)
|
||||
pub root: String,
|
||||
/// 落点模式(`custom` / `standard` / `portable`)
|
||||
pub root_mode: &'static str,
|
||||
/// 安装根来自哪一层
|
||||
pub root_layer: RootLayer,
|
||||
/// 解析不出标准目录(`standard` 模式拿不到 `%APPDATA%` / `HOME`)
|
||||
pub root_unresolved: bool,
|
||||
/// 有效的发行版顺序(展示名)
|
||||
pub order: Vec<&'static str>,
|
||||
/// 顺序来自哪一层
|
||||
pub order_layer: OrderLayer,
|
||||
/// 这台机器被判成国内环境(地区默认序取的是国内那套)
|
||||
pub domestic: bool,
|
||||
}
|
||||
|
||||
impl Default for Effective {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root: String::new(),
|
||||
root_mode: "portable",
|
||||
root_layer: RootLayer::DataMode,
|
||||
root_unresolved: false,
|
||||
order: Vec::new(),
|
||||
order_layer: OrderLayer::Region,
|
||||
domestic: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `launcher.json` 读回来的三键真值。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Snapshot {
|
||||
/// `java.installRoot`
|
||||
pub install_root: String,
|
||||
/// `java.auto_install`
|
||||
pub auto_install: bool,
|
||||
/// `java.vendorPriority`(稳定 id)
|
||||
pub priority: Vec<String>,
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// 用真值刷新(**脏草稿不覆盖**,与 `State::hydrate` 同一条纪律)。返回是否变了。
|
||||
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
|
||||
let priority: Vec<&'static str> = snapshot
|
||||
.priority
|
||||
.iter()
|
||||
.filter_map(|text| JdkVendor::parse(text).map(JdkVendor::id))
|
||||
.collect();
|
||||
let changed = self.saved_install_root != snapshot.install_root
|
||||
|| self.saved_auto_install != snapshot.auto_install
|
||||
|| self.saved_priority != priority;
|
||||
self.saved_install_root = snapshot.install_root.clone();
|
||||
self.saved_auto_install = snapshot.auto_install;
|
||||
self.saved_priority = priority.clone();
|
||||
if !self.dirty() {
|
||||
self.install_root = snapshot.install_root;
|
||||
self.auto_install = snapshot.auto_install;
|
||||
self.priority = priority;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 保存成功后用**读回的真值**收尾:草稿与落盘值两半都对它。
|
||||
///
|
||||
/// 为什么草稿也要改:这就是「改完回读验证已落盘」的界面形态——用户看到的就是磁盘上那份。
|
||||
/// 如果 schema 把值规整过(去空格、拒绝某个枚举值),界面上显示的是规整后的结果,
|
||||
/// 而不是他刚敲的那串原样(假装没变过才是最坏的)。
|
||||
pub fn adopt(&mut self, snapshot: Snapshot) {
|
||||
let priority: Vec<&'static str> = snapshot
|
||||
.priority
|
||||
.iter()
|
||||
.filter_map(|text| JdkVendor::parse(text).map(JdkVendor::id))
|
||||
.collect();
|
||||
self.install_root = snapshot.install_root.clone();
|
||||
self.auto_install = snapshot.auto_install;
|
||||
self.priority = priority.clone();
|
||||
self.saved_install_root = snapshot.install_root;
|
||||
self.saved_auto_install = snapshot.auto_install;
|
||||
self.saved_priority = priority;
|
||||
}
|
||||
|
||||
/// 这一段有没有未保存的改动。
|
||||
#[must_use]
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.install_root.trim() != self.saved_install_root.trim()
|
||||
|| self.auto_install != self.saved_auto_install
|
||||
|| self.priority != self.saved_priority
|
||||
}
|
||||
|
||||
/// 保存前的本地校验(过不了就不写盘)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 路径超过 schema 登记的长度上限时返回 [`Refusal::InstallRootTooLong`]。
|
||||
pub fn validate(&self) -> Result<(), Refusal> {
|
||||
if self.install_root.trim().chars().count() > MAX_INSTALL_ROOT {
|
||||
return Err(Refusal::InstallRootTooLong);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 草稿的优先级链 → 稳定 id 列表(落盘用:空列表 = 跟地区默认序走)。
|
||||
#[must_use]
|
||||
pub fn priority_ids(&self) -> Vec<String> {
|
||||
self.priority.iter().map(|id| (*id).to_string()).collect()
|
||||
}
|
||||
|
||||
/// 草稿的优先级链 → 有效顺序(`vendor::order` 的补集追加语义,与真正下载时同源)。
|
||||
#[must_use]
|
||||
pub fn order(&self, domestic: bool) -> Vec<JdkVendor> {
|
||||
let parsed = self
|
||||
.priority
|
||||
.iter()
|
||||
.filter_map(|id| JdkVendor::parse(id))
|
||||
.collect::<Vec<_>>();
|
||||
vendor::order(&parsed, domestic)
|
||||
}
|
||||
|
||||
/// 上移一行(`index` 是**有效顺序**里的下标)。
|
||||
///
|
||||
/// 第一次动就把草稿物化成整条顺序:用户拖的是他看见的那 4 行,落盘的就该是这 4 行的顺序。
|
||||
/// `domestic` 由调用方给(app 层已经算过一次地区判定,这里不重复探测)。
|
||||
pub fn move_up(&mut self, index: usize, domestic: bool) {
|
||||
self.materialize(domestic);
|
||||
if index > 0 && index < self.priority.len() {
|
||||
self.priority.swap(index, index - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下移一行。
|
||||
pub fn move_down(&mut self, index: usize, domestic: bool) {
|
||||
self.materialize(domestic);
|
||||
if index + 1 < self.priority.len() {
|
||||
self.priority.swap(index, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// 草稿物化:把「留空 = 地区默认序」展开成那条完整链(已物化时不动)。
|
||||
fn materialize(&mut self, domestic: bool) {
|
||||
if self.priority.len() != JdkVendor::default_order(domestic).len() {
|
||||
self.priority = self.order(domestic).iter().map(|id| id.id()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复地区默认序(= 把这个键写回空,让地区那一层重新生效)。
|
||||
pub fn reset_priority(&mut self) {
|
||||
self.priority.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Prefs;
|
||||
use unlucky_core::game::java::vendor::{ids, JdkVendor};
|
||||
|
||||
/// 出厂值 = schema 默认(`auto_install` 为真、另两键留空)。
|
||||
///
|
||||
/// 变异证明:把 `Prefs::default` 里 `auto_install` 改成 `false` ⇒ 本用例红。
|
||||
#[test]
|
||||
fn the_defaults_match_the_schema() {
|
||||
let prefs = Prefs::default();
|
||||
assert!(prefs.auto_install);
|
||||
assert!(prefs.install_root.is_empty());
|
||||
assert!(prefs.priority.is_empty());
|
||||
assert!(!prefs.dirty(), "出厂值不算脏");
|
||||
assert_eq!(
|
||||
prefs.order(true),
|
||||
vec![
|
||||
JdkVendor::Temurin,
|
||||
JdkVendor::Oracle,
|
||||
JdkVendor::Microsoft,
|
||||
JdkVendor::Zulu
|
||||
],
|
||||
"留空 = 国内默认序"
|
||||
);
|
||||
}
|
||||
|
||||
/// 上移 / 下移真的改顺序,而且第一条上移是空操作。
|
||||
///
|
||||
/// 变异证明:把 `move_up` 里的 `swap(index, index - 1)` 写成 `swap(index, index + 1)`
|
||||
/// ⇒ 本用例红。
|
||||
#[test]
|
||||
fn moving_a_vendor_rewrites_the_order() {
|
||||
let mut prefs = Prefs::default();
|
||||
// 空白草稿上直接下移第一条:物化成整条链后再交换。
|
||||
prefs.move_down(0, true);
|
||||
assert_eq!(
|
||||
ids(&prefs.order(true)),
|
||||
vec!["oracle", "temurin", "microsoft", "zulu"],
|
||||
"下移第一条 = 与第二条交换"
|
||||
);
|
||||
assert_eq!(prefs.priority.len(), 4, "物化之后是整条链");
|
||||
prefs.move_up(0, true);
|
||||
assert_eq!(ids(&prefs.order(true))[0], "oracle", "第一条上移是空操作");
|
||||
prefs.move_up(1, true);
|
||||
assert_eq!(ids(&prefs.order(true))[0], "temurin");
|
||||
assert!(prefs.dirty(), "动过就该脏");
|
||||
}
|
||||
|
||||
/// 恢复地区默认序 = 草稿清空,且不再脏(与落盘的空值一致)。
|
||||
#[test]
|
||||
fn resetting_goes_back_to_the_region_default() {
|
||||
let mut prefs = Prefs::default();
|
||||
prefs.move_down(0, true);
|
||||
assert!(prefs.dirty());
|
||||
prefs.reset_priority();
|
||||
assert!(!prefs.dirty());
|
||||
assert_eq!(ids(&prefs.order(true))[0], "temurin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 三个设置键的真读写(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
|
||||
//!
|
||||
//! 与 `store.rs`(M175 的内存 / JVM / 默认 JDK)同一条通路:`core::setting::store::SettingStore`
|
||||
//! —— 读一份 → 改几个键 → 存 → **再从磁盘读回来**。写盘只认 `Saved` 一档,其余一律当失败
|
||||
//! (`WriteError` 的三档与 `store.rs` 同一套类型,界面文案也同一套)。
|
||||
//!
|
||||
//! ## 「生效值 + 从哪一层来」为什么在这里算
|
||||
//!
|
||||
//! 两个键都可能**留空**,而留空的语义分别指向另外两层:
|
||||
//!
|
||||
//! | 键 | 留空 = | 生效值的第二层 |
|
||||
//! |---|---|---|
|
||||
//! | `java.installRoot` | 按 `core.data_mode` 派生落点 | `JavaInstallRoot::resolve`(portable / standard) |
|
||||
//! | `java.vendorPriority` | 按地区默认序 | `vendor::order(&[], domestic)` |
|
||||
//!
|
||||
//! 所以「现在到底装到哪 / 按什么顺序找」只有把**落盘值**代进 core 的纯函数才说得清。
|
||||
//! 这两个纯函数本身就是真正下载时用的那一对(`install` 也走 `resolve` 与 `order`),
|
||||
//! 于是界面上的「生效」与真跑起来的行为不会各说一套。
|
||||
//!
|
||||
//! ## 地区判定只做一次
|
||||
//!
|
||||
//! `domestic` 从 `cache/region.json` 读(引擎建过一次就有);读不到时按环境信号判一次并写回
|
||||
//! ——与 `shared::build` 的做法一致。它**不联网**(`region_system` 只跑 `tzutil` / `reg`),
|
||||
//! 且本函数只在首屏与配置文件变化时被调用,不进心跳。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
|
||||
use unlucky_core::download::region_system::detect_signals;
|
||||
use unlucky_core::game::java::vendor::{self, JdkVendor};
|
||||
use unlucky_core::game::java::{JavaInstallRoot, LocationMode};
|
||||
use unlucky_core::platform::data_dir::DataMode;
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::platform::paths::{Platform, ProcessEnv};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
|
||||
|
||||
use super::prefs::{Effective, OrderLayer, Prefs, RootLayer, Snapshot};
|
||||
use super::store::{unwritable, WriteError};
|
||||
|
||||
/// 读三键真值。
|
||||
#[must_use]
|
||||
pub fn load(config: &Path) -> Snapshot {
|
||||
let store = SettingStore::load(config, launcher_schema());
|
||||
snapshot(store.doc())
|
||||
}
|
||||
|
||||
/// 写三键,返回**落盘后读回来**的真值。
|
||||
///
|
||||
/// # Errors
|
||||
/// 本地校验不过([`WriteError::Refused`])、文件只读 / 不可信([`WriteError::Unwritable`])
|
||||
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
|
||||
pub fn save(config: &Path, prefs: &Prefs) -> Result<Snapshot, WriteError> {
|
||||
prefs.validate().map_err(WriteError::Refused)?;
|
||||
let mut store = SettingStore::load(config, launcher_schema());
|
||||
let root = prefs.install_root.trim().to_string();
|
||||
let ids = prefs.priority_ids();
|
||||
// `java.vendorPriority` 是数组键,而 `SettingStore::set` 只收 `serde_json::Value`——
|
||||
// app-iced 的依赖表里没有 serde_json(本 mission 也改不了 manifest),所以不现造一个
|
||||
// `Value`:从文档里取**当前那个数组**(schema 默认就是空数组)清空再填,形状与 schema 一致。
|
||||
let mut array = store
|
||||
.doc()
|
||||
.effective("java.vendorPriority")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
{
|
||||
let Some(items) = array.as_array_mut() else {
|
||||
return Err(WriteError::Schema(
|
||||
"java.vendorPriority 当下不是数组(schema 的默认值是空数组)".to_string(),
|
||||
));
|
||||
};
|
||||
items.clear();
|
||||
for id in &ids {
|
||||
items.push(id.as_str().into());
|
||||
}
|
||||
}
|
||||
store
|
||||
.set("java.installRoot", root.into())
|
||||
.and_then(|()| store.set("java.auto_install", prefs.auto_install.into()))
|
||||
.and_then(|()| store.set("java.vendorPriority", array))
|
||||
.map_err(|error| WriteError::Schema(format!("{error}")))?;
|
||||
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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效值 + 来源层(渲染器只翻文案)。
|
||||
#[must_use]
|
||||
pub fn effective(config: &Path, prefs: &Prefs) -> Effective {
|
||||
let store = SettingStore::load(config, launcher_schema());
|
||||
let doc = store.doc();
|
||||
let data_mode = doc
|
||||
.effective_str("core.data_mode")
|
||||
.and_then(DataMode::parse)
|
||||
.unwrap_or_else(|| DataMode::default_for(Platform::current()));
|
||||
let override_root = {
|
||||
let text = prefs.saved_install_root.trim();
|
||||
(!text.is_empty()).then(|| PathBuf::from(text))
|
||||
};
|
||||
let layout = home_of(config).map(|home| LauncherLayout::new(home));
|
||||
let (root, root_mode, root_layer, root_unresolved) = match &layout {
|
||||
Some(layout) => match JavaInstallRoot::resolve(
|
||||
data_mode,
|
||||
layout,
|
||||
override_root.as_deref(),
|
||||
Platform::current(),
|
||||
&ProcessEnv,
|
||||
) {
|
||||
Ok(resolved) => (
|
||||
resolved.root.display().to_string(),
|
||||
resolved.mode.as_str(),
|
||||
layer_of(resolved.mode),
|
||||
false,
|
||||
),
|
||||
Err(error) => {
|
||||
eprintln!("[java] 解析安装根失败:{error}");
|
||||
(
|
||||
String::new(),
|
||||
data_mode.as_str(),
|
||||
if override_root.is_some() {
|
||||
RootLayer::Setting
|
||||
} else {
|
||||
RootLayer::DataMode
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
},
|
||||
None => (String::new(), data_mode.as_str(), RootLayer::DataMode, true),
|
||||
};
|
||||
|
||||
let domestic = domestic_of(config);
|
||||
let priority = prefs
|
||||
.saved_priority
|
||||
.iter()
|
||||
.filter_map(|id| JdkVendor::parse(id))
|
||||
.collect::<Vec<_>>();
|
||||
let order = vendor::order(&priority, domestic);
|
||||
Effective {
|
||||
root,
|
||||
root_mode,
|
||||
root_layer,
|
||||
root_unresolved,
|
||||
order: order.iter().map(|vendor| vendor.display()).collect(),
|
||||
order_layer: if priority.is_empty() {
|
||||
OrderLayer::Region
|
||||
} else {
|
||||
OrderLayer::Setting
|
||||
},
|
||||
domestic,
|
||||
}
|
||||
}
|
||||
|
||||
/// 现在的地区判定(`cache/region.json` 优先,没有就判一次并写回)。
|
||||
///
|
||||
/// 与 `core::download::shared::build` 同源:同一个 `region.json`、同一套信号。
|
||||
/// 读不到缓存目录(配置路径不像 `<home>/config/launcher.json`)时按**国内**算——
|
||||
/// 那是 D23 出厂口径,也是唯一一个「不知道还更保守」的取值(镜像先试不会更差)。
|
||||
#[must_use]
|
||||
pub fn domestic_of(config: &Path) -> bool {
|
||||
let Some(home) = home_of(config) else {
|
||||
return true;
|
||||
};
|
||||
let cache = LauncherLayout::new(home).cache();
|
||||
let now = now_ms();
|
||||
let path = cache.join("region.json");
|
||||
if let Some(profile) = load_cached(&path, now) {
|
||||
return profile.is_domestic();
|
||||
}
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = std::fs::create_dir_all(&cache);
|
||||
let _ = store_cached(&path, &profile);
|
||||
profile.is_domestic()
|
||||
}
|
||||
|
||||
/// 托管 JDK 该装到哪(下载作业用;与界面上的「生效」同一个函数)。
|
||||
#[must_use]
|
||||
pub fn install_root(config: &Path, saved_install_root: &str) -> Option<JavaInstallRoot> {
|
||||
let store = SettingStore::load(config, launcher_schema());
|
||||
let data_mode = store
|
||||
.doc()
|
||||
.effective_str("core.data_mode")
|
||||
.and_then(DataMode::parse)
|
||||
.unwrap_or_else(|| DataMode::default_for(Platform::current()));
|
||||
let home = home_of(config)?;
|
||||
let layout = LauncherLayout::new(home);
|
||||
let override_root = {
|
||||
let text = saved_install_root.trim();
|
||||
(!text.is_empty()).then(|| PathBuf::from(text))
|
||||
};
|
||||
JavaInstallRoot::resolve(
|
||||
data_mode,
|
||||
&layout,
|
||||
override_root.as_deref(),
|
||||
Platform::current(),
|
||||
&ProcessEnv,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 下载引擎的缓存根(`<home>/cache`):让 JDK 归档与其它下载共用同一份缓存 / 健康度。
|
||||
#[must_use]
|
||||
pub fn cache_root(config: &Path) -> Option<PathBuf> {
|
||||
home_of(config).map(|home| LauncherLayout::new(home).cache())
|
||||
}
|
||||
|
||||
/// launcher home(`<home>/config/launcher.json` 的上一级再上一级)。
|
||||
fn home_of(config: &Path) -> Option<&Path> {
|
||||
config.parent().and_then(Path::parent)
|
||||
}
|
||||
|
||||
/// 落点模式 → 「从哪一层来」。
|
||||
fn layer_of(mode: LocationMode) -> RootLayer {
|
||||
match mode {
|
||||
LocationMode::Custom => RootLayer::Setting,
|
||||
LocationMode::Standard | LocationMode::Portable => RootLayer::DataMode,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文档 → 快照。
|
||||
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
|
||||
Snapshot {
|
||||
install_root: doc
|
||||
.effective_str("java.installRoot")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string(),
|
||||
auto_install: doc
|
||||
.effective_bool("java.auto_install")
|
||||
.unwrap_or(super::prefs::DEFAULT_AUTO_INSTALL),
|
||||
priority: doc
|
||||
.effective("java.vendorPriority")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! 三个设置键的**编辑区**(M197):`java.installRoot` / `java.vendorPriority` / `java.auto_install`。
|
||||
//!
|
||||
//! 每一段都是同一种形状:**能改的控件 + 现在生效什么 + 这个生效值从哪一层来**。第三件事是任务书
|
||||
//! 点名的(「两者都要能解释『生效值现在是什么、从哪一层来』」),所以三段的最后一行永远不是空的:
|
||||
//!
|
||||
//! | 段 | 控件 | 生效值从哪来 |
|
||||
//! |---|---|---|
|
||||
//! | 安装目录 | 文本框(可以留空) | `java.installRoot` 或 `core.data_mode` |
|
||||
//! | 发行版顺序 | 4 行 + 上移 / 下移 + 恢复默认 | `java.vendorPriority` 或地区默认序 |
|
||||
//! | 缺 JDK 时自动准备 | 开 / 关两片 | 设置本身(出厂开) |
|
||||
//!
|
||||
//! 文案一律走 [`Catalog`](本组要 `Lang`,走 `settings/mod.rs` 的按下标分发);渲染器自己不判断
|
||||
//! 层级,`RootLayer` / `OrderLayer` 是 app 层算好的结论。
|
||||
|
||||
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::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame::{divider, text_role_owned};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::prefs::{Effective, OrderLayer, RootLayer};
|
||||
use super::state::State;
|
||||
use super::view::msg;
|
||||
use super::Message as JavaMessage;
|
||||
|
||||
/// 三段 + 保存按钮(整块的标题由设置页的外壳给,这里不再画)。
|
||||
pub(super) fn section<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
block = block.push(install_root(ui, catalog, state));
|
||||
block = block.push(divider(ui));
|
||||
block = block.push(vendor_order(ui, catalog, state));
|
||||
block = block.push(divider(ui));
|
||||
block = block.push(auto_install(ui, catalog, state));
|
||||
block = block.push(actions(ui, catalog, state));
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// `java.installRoot`:文本框 + 生效落点 + 来源层。
|
||||
fn install_root<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let prefs = &state.prefs;
|
||||
let field = Field::new(&prefs.install_root)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaInstallRoot))
|
||||
.placeholder(catalog.text(Key::SettingsJavaInstallRootPlaceholder))
|
||||
.supporting(catalog.text(Key::SettingsJavaInstallRootHint));
|
||||
let field = if prefs.validate().is_err() {
|
||||
field.error()
|
||||
} else {
|
||||
field
|
||||
};
|
||||
let effective = effective_line(ui, catalog, &state.effective);
|
||||
column![
|
||||
text_field(ui, field, |value| msg(JavaMessage::EditInstallRoot(value))),
|
||||
effective,
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 「生效:模式 · 路径」+「从哪一层来」(解析不出标准目录时说清下一步)。
|
||||
fn effective_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
effective: &'a Effective,
|
||||
) -> Element<'a, Message> {
|
||||
if effective.root_unresolved {
|
||||
return text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog
|
||||
.text(Key::SettingsJavaInstallRootUnresolved)
|
||||
.to_string(),
|
||||
ui.color(Role::Error),
|
||||
);
|
||||
}
|
||||
let text = catalog.render(
|
||||
Key::SettingsJavaInstallRootEffective,
|
||||
&[
|
||||
("mode", effective.root_mode),
|
||||
("path", effective.root.as_str()),
|
||||
],
|
||||
);
|
||||
let layer = match effective.root_layer {
|
||||
RootLayer::Setting => catalog
|
||||
.text(Key::SettingsJavaInstallRootLayerSetting)
|
||||
.to_string(),
|
||||
RootLayer::DataMode => catalog.render(
|
||||
Key::SettingsJavaInstallRootLayerDataMode,
|
||||
&[("mode", effective.root_mode)],
|
||||
),
|
||||
};
|
||||
column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
layer,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `java.vendorPriority`:4 行顺序 + 上移 / 下移 + 恢复地区默认序。
|
||||
fn vendor_order<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let prefs = &state.prefs;
|
||||
let order = prefs.order(state.effective.domestic);
|
||||
let mut list = column![text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
catalog.text(Key::SettingsJavaVendorPriority).to_string(),
|
||||
ui.color(Role::OnSurface),
|
||||
),]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill);
|
||||
|
||||
for (index, vendor) in order.iter().enumerate() {
|
||||
list = list.push(vendor_row(ui, catalog, index, *vendor));
|
||||
}
|
||||
|
||||
let joined = state.effective.order.join(" → ");
|
||||
let layer = match state.effective.order_layer {
|
||||
OrderLayer::Setting => catalog
|
||||
.text(Key::SettingsJavaVendorOrderLayerSetting)
|
||||
.to_string(),
|
||||
OrderLayer::Region => catalog.render(
|
||||
Key::SettingsJavaVendorOrderLayerRegion,
|
||||
&[(
|
||||
"region",
|
||||
catalog
|
||||
.text(if state.effective.domestic {
|
||||
Key::SettingsJavaRegionDomestic
|
||||
} else {
|
||||
Key::SettingsJavaRegionGlobal
|
||||
})
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)],
|
||||
),
|
||||
};
|
||||
list = list.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsJavaVendorOrderEffective,
|
||||
&[("order", joined.as_str())],
|
||||
),
|
||||
ui.color(Role::OnSurface),
|
||||
));
|
||||
list = list.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
layer,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
// 「恢复地区默认序」只在真的排过顺序时才有意义(已经跟地区走时它是个空操作)。
|
||||
if !prefs.priority.is_empty() {
|
||||
list = list.push(buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsJavaVendorReset),
|
||||
msg(JavaMessage::ResetPriority),
|
||||
),
|
||||
));
|
||||
}
|
||||
list.into()
|
||||
}
|
||||
|
||||
/// 一家发行版一行:名 + 它发哪些大版本 + 上移 / 下移。
|
||||
fn vendor_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
index: usize,
|
||||
vendor: unlucky_core::game::java::JdkVendor,
|
||||
) -> Element<'a, Message> {
|
||||
let majors = vendor
|
||||
.hint_majors()
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let supporting = catalog.render(
|
||||
Key::SettingsJavaVendorSupports,
|
||||
&[("majors", majors.as_str())],
|
||||
);
|
||||
// 第一条的「上移」与最后一条的「下移」都是空操作:照旧可点,点了不改顺序也不标脏
|
||||
// (比灰掉更省心——灰掉的 chip 在 424 宽的列里同样占位,用户还得去猜为什么灰)。
|
||||
let up = chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsJavaVendorUp),
|
||||
false,
|
||||
msg(JavaMessage::MoveVendor { index, up: true }),
|
||||
),
|
||||
);
|
||||
let down = chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsJavaVendorDown),
|
||||
false,
|
||||
msg(JavaMessage::MoveVendor { index, up: false }),
|
||||
),
|
||||
);
|
||||
row![
|
||||
container(
|
||||
column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
index == 0,
|
||||
format!("{}. {}", index + 1, vendor.display()),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
supporting,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
up,
|
||||
down,
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// `java.auto_install`:开 / 关两片 + 「它到底做什么」那句。
|
||||
fn auto_install<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let on = state.prefs.auto_install;
|
||||
let chips = row![
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsJavaAutoInstallOn),
|
||||
on,
|
||||
msg(JavaMessage::ToggleAutoInstall(true)),
|
||||
),
|
||||
),
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsJavaAutoInstallOff),
|
||||
!on,
|
||||
msg(JavaMessage::ToggleAutoInstall(false)),
|
||||
),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1));
|
||||
column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
catalog.text(Key::SettingsJavaAutoInstall).to_string(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
chips,
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsJavaAutoInstallHint).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 保存按钮 + 「有未保存改动」那行。
|
||||
fn actions<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let label = catalog.text(Key::SettingsJavaPrefsSave);
|
||||
let save = if state.prefs.dirty() && state.storage {
|
||||
buttons::filled(ui, Button::new(label, msg(JavaMessage::SavePrefs)))
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled(label))
|
||||
};
|
||||
let hint = if state.prefs.dirty() {
|
||||
catalog.text(Key::SettingsJavaPrefsUnsaved).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
column![
|
||||
save,
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出
|
||||
//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。
|
||||
//!
|
||||
//! M197 加了三档,对应「下载一个 JDK」的三态(任务书要的三张图):`download`(版本选择列表)/
|
||||
//! `downloading`(下载中:百分比 + 当前源 + 一次退避)/ `download-failed`(失败:最后试到的源)。
|
||||
//! 三档都把三个设置键摆成生产上真会出现的值(自定义落点 + 自己排的发行版顺序 + 自动准备开着),
|
||||
//! 于是一张图里能同时看到「版本目录」与「三个键」。
|
||||
//!
|
||||
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
|
||||
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
|
||||
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
|
||||
@@ -69,7 +74,108 @@ pub fn build(raw: &str) -> Option<State> {
|
||||
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
|
||||
return Some(seeded);
|
||||
}
|
||||
// ── M197:「下载一个 JDK」的三态
|
||||
"download" => {
|
||||
let mut seeded = build("two")?;
|
||||
seeded.context = download_context(21);
|
||||
seeded.prefs = seeded_prefs();
|
||||
seeded.effective = seeded_effective();
|
||||
return Some(seeded);
|
||||
}
|
||||
"downloading" => {
|
||||
let seeded = build("download")?;
|
||||
seeded.downloads.seed_report(super::downloads::Report {
|
||||
job: Some(super::downloads::Job {
|
||||
major: 21,
|
||||
phase: super::downloads::Phase::Downloading,
|
||||
downloaded: 96_468_992,
|
||||
total: Some(210_763_776),
|
||||
source: Some("tuna".to_string()),
|
||||
last: Some(super::downloads::Event::Retry {
|
||||
source: "tuna".to_string(),
|
||||
kind: unlucky_core::download::DownloadErrorKind::ServerError,
|
||||
attempt: 2,
|
||||
retry_in_ms: 2_000,
|
||||
}),
|
||||
}),
|
||||
tries: vec![super::downloads::Try {
|
||||
source: "tuna".to_string(),
|
||||
kind: unlucky_core::download::DownloadErrorKind::ServerError,
|
||||
}],
|
||||
failure: None,
|
||||
success: None,
|
||||
});
|
||||
return Some(seeded);
|
||||
}
|
||||
"download-failed" => {
|
||||
let seeded = build("download")?;
|
||||
seeded.downloads.seed_report(super::downloads::Report {
|
||||
job: None,
|
||||
// 出图要能看见「试过哪些源、各自为什么」:两条真实形态的失败。
|
||||
tries: vec![
|
||||
super::downloads::Try {
|
||||
source: "tuna".to_string(),
|
||||
kind: unlucky_core::download::DownloadErrorKind::NotFound,
|
||||
},
|
||||
super::downloads::Try {
|
||||
source: "github".to_string(),
|
||||
kind: unlucky_core::download::DownloadErrorKind::Timeout,
|
||||
},
|
||||
],
|
||||
success: None,
|
||||
failure: Some(super::downloads::Failure {
|
||||
major: 21,
|
||||
kind: unlucky_core::game::java::JavaErrorKind::Network,
|
||||
source: Some("github".to_string()),
|
||||
}),
|
||||
});
|
||||
return Some(seeded);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
|
||||
/// 出图用的「当前实例要求」(`1.20.5` 起要 21)。
|
||||
fn download_context(minimum: u32) -> Context {
|
||||
Context {
|
||||
required: Some(Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.5".to_string(),
|
||||
minimum,
|
||||
maximum: None,
|
||||
}),
|
||||
layer: Some(Layer::Global),
|
||||
effective_memory_mb: Some(4096),
|
||||
}
|
||||
}
|
||||
|
||||
/// 出图用的三键:自定义落点 + 自己排的顺序(第一行不是默认的那家)+ 自动准备开着。
|
||||
fn seeded_prefs() -> super::prefs::Prefs {
|
||||
super::prefs::Prefs {
|
||||
install_root: "D:/Java".to_string(),
|
||||
saved_install_root: "D:/Java".to_string(),
|
||||
auto_install: true,
|
||||
saved_auto_install: true,
|
||||
priority: vec!["microsoft", "temurin", "oracle", "zulu"],
|
||||
saved_priority: vec!["microsoft", "temurin", "oracle", "zulu"],
|
||||
}
|
||||
}
|
||||
|
||||
/// 出图用的生效值(与 [`seeded_prefs`] 一致:落点与顺序都来自设置)。
|
||||
fn seeded_effective() -> super::prefs::Effective {
|
||||
super::prefs::Effective {
|
||||
root: "D:/Java".to_string(),
|
||||
root_mode: "custom",
|
||||
root_layer: super::prefs::RootLayer::Setting,
|
||||
root_unresolved: false,
|
||||
order: vec![
|
||||
"Microsoft OpenJDK",
|
||||
"Eclipse Temurin",
|
||||
"Oracle OpenJDK",
|
||||
"Azul Zulu",
|
||||
],
|
||||
order_layer: super::prefs::OrderLayer::Setting,
|
||||
domestic: true,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
|
||||
pub use super::row::RuntimeRow;
|
||||
|
||||
/// M197 起 `Requirement` / `Layer` / `Context` / `Fit` 住在 [`super::context`](本文件加了三键状态
|
||||
/// 与下载槽之后越过 400 行硬线),三键的草稿与生效值住在 [`super::prefs`]——这里一并转出去,
|
||||
/// 调用方仍然只认识 `pages::settings::java::{Context, Layer, Requirement}` 这一组名字。
|
||||
pub use super::context::{Context, Fit, Layer, Requirement};
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
/// `java.default_memory_mb` 的下限(MB)。
|
||||
@@ -44,52 +49,6 @@ pub struct Progress {
|
||||
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 {
|
||||
@@ -108,6 +67,8 @@ pub enum Notice {
|
||||
DefaultSet(String),
|
||||
/// 手动路径已加入名单
|
||||
Added(String),
|
||||
/// 三个设置键已落盘并读回(M197)
|
||||
PrefsSaved,
|
||||
}
|
||||
|
||||
/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。
|
||||
@@ -158,6 +119,8 @@ pub enum Refusal {
|
||||
AddWriteFailed,
|
||||
/// 已经加过了
|
||||
AddDuplicate,
|
||||
/// `java.installRoot` 超过 schema 登记的长度上限(512)
|
||||
InstallRootTooLong,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
@@ -176,6 +139,7 @@ impl Refusal {
|
||||
Self::AddTooLong => Key::SettingsJavaReasonAddTooLong,
|
||||
Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed,
|
||||
Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate,
|
||||
Self::InstallRootTooLong => Key::SettingsJavaReasonInstallRootTooLong,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +179,16 @@ pub struct State {
|
||||
pub notice: Option<Notice>,
|
||||
/// 当前实例的上下文(版本门槛 / 生效内存层级)
|
||||
pub context: Context,
|
||||
/// 三个设置键的草稿 + 落盘真值(M197:`installRoot` / `vendorPriority` / `auto_install`)
|
||||
pub prefs: super::prefs::Prefs,
|
||||
/// 上述三键的**生效值 + 来源层**(app 层每次 hydrate 重算)
|
||||
pub effective: super::prefs::Effective,
|
||||
/// 「下载一个 JDK」的作业槽(后台线程写、界面读)
|
||||
pub downloads: super::downloads::Slot,
|
||||
/// 上一次「下载按钮被拒」的原因(点了没反应是禁止的)
|
||||
pub download_notice: Option<super::downloads::Refusal>,
|
||||
/// 已经处理过的那份下载快照(用来判「刚成功」——装完要重扫一次并把新 JDK 收进来)
|
||||
pub download_seen: super::downloads::Report,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
@@ -236,6 +210,11 @@ impl Default for State {
|
||||
storage: false,
|
||||
notice: None,
|
||||
context: Context::default(),
|
||||
prefs: super::prefs::Prefs::default(),
|
||||
effective: super::prefs::Effective::default(),
|
||||
downloads: super::downloads::Slot::default(),
|
||||
download_notice: None,
|
||||
download_seen: super::downloads::Report::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,13 +272,16 @@ impl State {
|
||||
changed
|
||||
}
|
||||
|
||||
/// 某个大版本够不够当前实例用。
|
||||
/// 某个大版本够不够当前实例用(`None` = 没有实例 / 那个版本探测不出版本号)。
|
||||
///
|
||||
/// 返回的是**带方向**的三态:老版本 MC 只能用 Java 8,「Java 21 太高」与「Java 8 太低」
|
||||
/// 在界面上是两句话(M197 的第三条任务不许静默失败)。
|
||||
#[must_use]
|
||||
pub fn satisfied(&self, major: Option<u32>) -> Option<bool> {
|
||||
pub fn fit(&self, major: Option<u32>) -> Option<Fit> {
|
||||
let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else {
|
||||
return None;
|
||||
};
|
||||
Some(required.satisfied_by(major))
|
||||
Some(required.fit(major))
|
||||
}
|
||||
|
||||
/// 保存前的本地校验(过不了就不写盘)。
|
||||
@@ -329,6 +311,19 @@ impl State {
|
||||
self.context.layer.clone().unwrap_or(Layer::Global)
|
||||
}
|
||||
|
||||
/// 生效的(落盘的)发行版优先级,给「这一档能由哪几家承接」用。
|
||||
///
|
||||
/// 用**落盘值**而不是草稿:列表边上那句「发行版:…」描述的是真跑起来会试的那条链;
|
||||
/// 草稿只影响「保存之后」。
|
||||
#[must_use]
|
||||
pub fn priority_vendors(&self) -> Vec<unlucky_core::game::java::JdkVendor> {
|
||||
self.prefs
|
||||
.saved_priority
|
||||
.iter()
|
||||
.filter_map(|id| unlucky_core::game::java::JdkVendor::parse(id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 现在能不能点保存。
|
||||
#[must_use]
|
||||
pub fn can_save(&self) -> bool {
|
||||
|
||||
@@ -298,7 +298,7 @@ fn finish(mut store: SettingStore, config: &Path) -> Result<Snapshot, WriteError
|
||||
}
|
||||
|
||||
/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。
|
||||
fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
|
||||
pub(super) fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
|
||||
use unlucky_core::setting::store::SkipReason;
|
||||
match reason {
|
||||
SkipReason::NotDirty => Unwritable::NotDirty,
|
||||
|
||||
@@ -240,3 +240,6 @@ fn the_memory_bounds_mirror_the_schema() {
|
||||
}
|
||||
#[path = "tests/runtime.rs"]
|
||||
mod runtime;
|
||||
|
||||
#[path = "tests/prefs.rs"]
|
||||
mod prefs;
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State};
|
||||
use super::super::state::{Fit, Layer, Notice, Requirement, RuntimeRow, State};
|
||||
use super::super::store;
|
||||
use super::{config, fake_jdk, root};
|
||||
|
||||
@@ -174,7 +174,7 @@ fn the_requirement_explains_why_a_runtime_fits_or_not() {
|
||||
selected: "C:/jdk-8/bin/java.exe".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论");
|
||||
assert_eq!(state.fit(Some(8)), None, "没有要求时不下结论");
|
||||
|
||||
state.context.required = Some(Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
@@ -182,8 +182,15 @@ fn the_requirement_explains_why_a_runtime_fits_or_not() {
|
||||
minimum: 17,
|
||||
maximum: None,
|
||||
});
|
||||
assert_eq!(state.satisfied(Some(8)), Some(false));
|
||||
assert_eq!(state.satisfied(Some(21)), Some(true));
|
||||
assert_eq!(
|
||||
state.fit(Some(8)),
|
||||
Some(Fit::TooOld {
|
||||
required: 17,
|
||||
actual: 8
|
||||
}),
|
||||
"太低要说「需要 17」"
|
||||
);
|
||||
assert_eq!(state.fit(Some(21)), Some(Fit::Ok));
|
||||
assert_eq!(
|
||||
state.default_row().map(|row| row.binary.clone()),
|
||||
Some("C:/jdk-8/bin/java.exe".to_string()),
|
||||
@@ -213,6 +220,43 @@ fn the_screenshot_seeds_cover_the_three_states() {
|
||||
assert!(State::screenshot_seed("nonsense").is_none());
|
||||
}
|
||||
|
||||
/// M197 的三档:版本选择列表 / 下载中 / 下载失败——出图靠它们,所以每档都要有**可断言的特征**。
|
||||
///
|
||||
/// 变异证明:把 `seed::build` 里 `"downloading"` 那一档的 `seed_report` 换成 `Report::default()`
|
||||
/// ⇒ 第 2 段红(作业没了,图上就只剩目录)。
|
||||
#[test]
|
||||
fn the_download_seeds_cover_the_three_states() {
|
||||
use super::super::downloads::Phase;
|
||||
|
||||
let list = State::screenshot_seed("download").expect("download 档");
|
||||
assert!(
|
||||
list.prefs.saved_auto_install && !list.prefs.saved_priority.is_empty(),
|
||||
"目录那一档要把三键摆成真值(自动准备开着 + 自己排过顺序)"
|
||||
);
|
||||
assert!(list.downloads.report().job.is_none(), "选择列表不是下载中");
|
||||
|
||||
let running = State::screenshot_seed("downloading").expect("downloading 档");
|
||||
let job = running.downloads.report().job.expect("下载中要有作业");
|
||||
assert_eq!(job.phase, Phase::Downloading);
|
||||
assert_eq!(job.percent(), Some(45), "96.4 MiB / 210.7 MiB ≈ 45%");
|
||||
assert_eq!(job.source.as_deref(), Some("tuna"), "要说得出当前源");
|
||||
assert!(
|
||||
job.last.is_some(),
|
||||
"退避那一行也要有(图上要看见「失败 / 重试」)"
|
||||
);
|
||||
|
||||
let failed = State::screenshot_seed("download-failed").expect("download-failed 档");
|
||||
let report = failed.downloads.report();
|
||||
let failure = report.failure.expect("失败档要有失败");
|
||||
assert_eq!(failure.major, 21);
|
||||
assert_eq!(failure.source.as_deref(), Some("github"));
|
||||
assert_eq!(
|
||||
report.tries.len(),
|
||||
2,
|
||||
"失败态要列出「试过哪些源」:tuna + github"
|
||||
);
|
||||
}
|
||||
|
||||
/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。
|
||||
///
|
||||
/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 三个设置键的真读写(M197):落盘 / 冷读回 / 生效值与来源层。
|
||||
//!
|
||||
//! 每一条都能变红(报告的「变异证明」逐条给了改哪一行会红):
|
||||
//!
|
||||
//! | 守卫 | 断言 | 改回什么会红 |
|
||||
//! |---|---|---|
|
||||
//! | 三键真落盘 | [`the_three_keys_are_written_and_read_back`] | 删掉 `prefs_store::save` 里任一 `store.set(...)` |
|
||||
//! | 回读即真值 | 同上(`adopt` 之后草稿 = 磁盘值) | 让 `save` 返回草稿而不是 `load(config)` |
|
||||
//! | 落点层级 | [`an_empty_install_root_follows_the_data_mode`] | 让 `effective` 忽略 `java.installRoot` |
|
||||
//! | 顺序层级 | [`an_empty_priority_follows_the_region`] | 让 `order_layer` 恒为 `Region` |
|
||||
//! | 超长路径不写盘 | [`an_overlong_install_root_is_refused_before_writing`] | 删掉 `Prefs::validate` 的长度判据 |
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
|
||||
use super::super::prefs::{OrderLayer, Prefs, RootLayer, MAX_INSTALL_ROOT};
|
||||
use super::super::prefs_store;
|
||||
use super::super::state::Refusal;
|
||||
use super::super::store::WriteError;
|
||||
use super::{config, root};
|
||||
|
||||
/// 把 `core.data_mode` 显式写成某一档(测试不依赖这台机器的出厂默认)。
|
||||
fn write_data_mode(file: &Path, mode: &str) {
|
||||
let mut store = SettingStore::load(file, launcher_schema());
|
||||
store
|
||||
.set("core.data_mode", mode.to_string().into())
|
||||
.expect("schema 认识 core.data_mode");
|
||||
store.save().expect("写盘");
|
||||
}
|
||||
|
||||
/// 三键真写进 `launcher.json`,再从盘上冷读一次(= 重启后)仍一致。
|
||||
///
|
||||
/// 变异证明:删掉 `prefs_store::save` 里 `store.set("java.auto_install", …)` 那一行 ⇒ 本用例红
|
||||
/// (`snapshot.auto_install` 回到 schema 默认 `true`);把 `Ok(load(config))` 改成 `Ok(*prefs)`
|
||||
/// 之类「回显草稿」⇒ 前后空格那条断言红。
|
||||
#[test]
|
||||
fn the_three_keys_are_written_and_read_back() {
|
||||
let dir = root("prefs-save");
|
||||
let file = config(&dir);
|
||||
|
||||
let mut prefs = Prefs::default();
|
||||
// 前后带空格:落盘值会被 trim,回读的草稿必须显示**磁盘上那份**。
|
||||
prefs.install_root = " D:/JDKs ".to_string();
|
||||
prefs.auto_install = false;
|
||||
prefs.priority = vec!["zulu", "temurin"];
|
||||
assert!(prefs.dirty(), "改过三键就该是脏的");
|
||||
|
||||
let snapshot = prefs_store::save(&file, &prefs).expect("三键应能落盘");
|
||||
assert_eq!(snapshot.install_root, "D:/JDKs", "落盘值已 trim");
|
||||
assert!(!snapshot.auto_install);
|
||||
assert_eq!(snapshot.priority, vec!["zulu", "temurin"]);
|
||||
|
||||
prefs.adopt(snapshot.clone());
|
||||
assert_eq!(prefs.install_root, "D:/JDKs", "回读后的草稿 = 磁盘值");
|
||||
assert!(!prefs.dirty(), "回读之后不再脏");
|
||||
|
||||
// 冷读(另一个 store 实例,模拟重启)。
|
||||
let again = prefs_store::load(&file);
|
||||
assert_eq!(again, snapshot, "从盘上读回来的与写进去的一致");
|
||||
}
|
||||
|
||||
/// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。
|
||||
///
|
||||
/// 变异证明:把 `prefs_store::effective` 里 `override_root` 那段删掉(当作永远没设)⇒ 后半段红。
|
||||
#[test]
|
||||
fn an_empty_install_root_follows_the_data_mode() {
|
||||
let dir = root("prefs-root");
|
||||
let file = config(&dir);
|
||||
write_data_mode(&file, "portable");
|
||||
|
||||
let prefs = Prefs::default();
|
||||
let effective = prefs_store::effective(&file, &prefs);
|
||||
assert_eq!(effective.root_layer, RootLayer::DataMode);
|
||||
assert_eq!(effective.root_mode, "portable");
|
||||
assert!(
|
||||
effective.root.ends_with("java-managed"),
|
||||
"portable 落点是数据根里的 java-managed:{}",
|
||||
effective.root
|
||||
);
|
||||
assert!(
|
||||
effective.root.starts_with(&dir.display().to_string()),
|
||||
"portable 落点必须在这个数据根里:{}",
|
||||
effective.root
|
||||
);
|
||||
|
||||
let custom = Prefs {
|
||||
install_root: "D:/JDKs".to_string(),
|
||||
saved_install_root: "D:/JDKs".to_string(),
|
||||
..Prefs::default()
|
||||
};
|
||||
let effective = prefs_store::effective(&file, &custom);
|
||||
assert_eq!(effective.root_layer, RootLayer::Setting);
|
||||
assert_eq!(effective.root_mode, "custom");
|
||||
assert_eq!(effective.root, "D:/JDKs", "设了就按它来,不再拼数据根");
|
||||
}
|
||||
|
||||
/// `java.vendorPriority` 留空 ⇒ 地区默认序;排过 ⇒ 顺序与来源层都变。
|
||||
///
|
||||
/// 变异证明:把 `order_layer` 的判据改成恒 `Region` ⇒ 后半段红。
|
||||
#[test]
|
||||
fn an_empty_priority_follows_the_region() {
|
||||
let dir = root("prefs-order");
|
||||
let file = config(&dir);
|
||||
|
||||
let prefs = Prefs::default();
|
||||
let effective = prefs_store::effective(&file, &prefs);
|
||||
assert_eq!(effective.order_layer, OrderLayer::Region);
|
||||
assert_eq!(effective.order.len(), 4, "四家都要在链上");
|
||||
|
||||
let sorted = Prefs {
|
||||
priority: vec!["zulu"],
|
||||
saved_priority: vec!["zulu"],
|
||||
..Prefs::default()
|
||||
};
|
||||
let effective = prefs_store::effective(&file, &sorted);
|
||||
assert_eq!(effective.order_layer, OrderLayer::Setting);
|
||||
assert_eq!(effective.order[0], "Azul Zulu", "排过的在第一");
|
||||
assert_eq!(effective.order.len(), 4, "补集追加,其余仍在链上");
|
||||
}
|
||||
|
||||
/// 超长路径在**写盘前**被拒:文件都不该被创建。
|
||||
///
|
||||
/// 变异证明:删掉 `Prefs::validate` 的长度判据 ⇒ 本用例红(会走 schema 的路径长度上限,
|
||||
/// 得到 `WriteError::Schema` 而不是 `Refused`,且断言 `!file.exists()` 仍成立但错误类型不符)。
|
||||
#[test]
|
||||
fn an_overlong_install_root_is_refused_before_writing() {
|
||||
let dir = root("prefs-long");
|
||||
let file = config(&dir);
|
||||
let prefs = Prefs {
|
||||
install_root: "x".repeat(MAX_INSTALL_ROOT + 1),
|
||||
..Prefs::default()
|
||||
};
|
||||
let error = prefs_store::save(&file, &prefs).expect_err("超长路径应被拒");
|
||||
assert_eq!(error, WriteError::Refused(Refusal::InstallRootTooLong));
|
||||
assert!(!file.exists(), "被本地校验拒掉的保存不许碰磁盘");
|
||||
}
|
||||
@@ -53,6 +53,12 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
|
||||
stacked = stacked.push(runtime_list(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(add_row(ui, catalog, state));
|
||||
// M197:真下载一个 JDK(版本选择列表 / 下载中 / 失败三态都在这一块里)。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(super::download_view::section(ui, catalog, state));
|
||||
// M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(super::prefs_view::section(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(memory_field(ui, catalog, state));
|
||||
stacked = stacked.push(jvm_field(ui, catalog, state));
|
||||
@@ -197,25 +203,27 @@ fn runtime_row<'a>(
|
||||
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) => (
|
||||
// M197:**带方向**的三态。M175 的布尔判定对「1.12.2 + Java 21」会说「低于要求:需要 Java 8
|
||||
// 及以上」——21 当然 ≥ 8,那句话自相矛盾;现在两个方向各说各的。
|
||||
let (verdict, verdict_color) = match state.fit(runtime.major) {
|
||||
Some(super::state::Fit::Ok) => (
|
||||
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),
|
||||
)
|
||||
}
|
||||
Some(super::state::Fit::TooOld { required, .. }) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaTooOld,
|
||||
&[("minimum", required.to_string().as_str())],
|
||||
),
|
||||
ui.color(Role::Error),
|
||||
),
|
||||
Some(super::state::Fit::TooNew { maximum, .. }) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaTooNew,
|
||||
&[("maximum", maximum.to_string().as_str())],
|
||||
),
|
||||
ui.color(Role::Error),
|
||||
),
|
||||
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user