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
+3838
-321
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())]),
|
||||
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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ impl DownloadEngine {
|
||||
self.emit(ProgressEvent::SourceFailed {
|
||||
id,
|
||||
source: candidate.source.clone(),
|
||||
kind: err.kind,
|
||||
reason: err.message.clone(),
|
||||
attempt,
|
||||
will_retry: retry,
|
||||
|
||||
@@ -264,6 +264,7 @@ impl DownloadEngine {
|
||||
self.emit(ProgressEvent::SourceFailed {
|
||||
id,
|
||||
source: candidate.source.clone(),
|
||||
kind: err.kind,
|
||||
reason: err.message.clone(),
|
||||
attempt,
|
||||
will_retry: retry,
|
||||
|
||||
@@ -45,6 +45,11 @@ pub enum ProgressEvent {
|
||||
id: DownloadId,
|
||||
/// 源标识。
|
||||
source: String,
|
||||
/// 失败分类(**结构化**:文案层按这个类型选 i18n key,`reason` 只进日志)。
|
||||
///
|
||||
/// M197 补的:在这之前只有 `reason`(core 的中文句子),GUI 要么放弃双语、
|
||||
/// 要么放弃「命中哪个源 / 为什么退避」这一条用户可见信息。
|
||||
kind: DownloadErrorKind,
|
||||
/// 失败原因(短描述)。
|
||||
reason: String,
|
||||
/// 第几次尝试(从 0 起)。
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 可下载 JDK 大版本目录(M197):设置页「下载一个 JDK」的来源表。
|
||||
//!
|
||||
//! 用户原话(2026-11-08):「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
|
||||
//! 这一层只回答两个问题:**可选的有哪些大版本**、**每个大版本由哪几家发行版承接**。
|
||||
//! 它不联网、不落盘、不认识 UI——真正查版本与下载仍在 [`super::install`] / [`super::source`]。
|
||||
//!
|
||||
//! ## 为什么目录要写在这里而不是 UI 里
|
||||
//!
|
||||
//! 「哪几家能装 8」是 [`JdkVendor::supports`] 的知识(Oracle 从 9 起、MS 只发 11/17/21/25),
|
||||
//! 写进 UI 就是第二份真相:两边一旦不一致,界面会给出一个「点了必然失败」的按钮。
|
||||
//! 所以本模块只做「目录 × 发行版能力」的组合,UI 拿到的就是回退链里真实会试的那几家。
|
||||
//!
|
||||
//! ## 基准版本
|
||||
//!
|
||||
//! [`BASELINE_MAJOR`](17)是 D13 定的基准:缺 Java 时先装它。它同时也是目录的一员,
|
||||
//! 但不是唯一一员——`26.1` 起 MC 要 25,`1.12.2` 只能吃 8,所以要能挑。
|
||||
|
||||
use super::vendor::{self, JdkVendor};
|
||||
|
||||
/// D13 的基准 JDK 大版本(没有上下文时「装哪个」的默认答案)。
|
||||
pub const BASELINE_MAJOR: u32 = 17;
|
||||
|
||||
/// 一个可选的大版本。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct JdkChoice {
|
||||
/// 大版本号(`17`)。
|
||||
pub major: u32,
|
||||
/// 是不是 LTS(展示用徽标;不参与任何判定)。
|
||||
pub lts: bool,
|
||||
/// 是不是 D13 基准版本。
|
||||
pub baseline: bool,
|
||||
}
|
||||
|
||||
/// 目录(升序):8 / 11 / 17 / 21 / 25。
|
||||
///
|
||||
/// 8 不是「顺便带上」的:1.12.2 及更早只能吃 Java 8(LaunchWrapper),而 25 是 `26.1` 起
|
||||
/// 的下限(见 `mapping`)。少任何一端,用户在最老的实例上就只能手动去装 JDK。
|
||||
pub const CHOICES: [JdkChoice; 5] = [
|
||||
JdkChoice {
|
||||
major: 8,
|
||||
lts: false,
|
||||
baseline: false,
|
||||
},
|
||||
JdkChoice {
|
||||
major: 11,
|
||||
lts: true,
|
||||
baseline: false,
|
||||
},
|
||||
JdkChoice {
|
||||
major: 17,
|
||||
lts: true,
|
||||
baseline: true,
|
||||
},
|
||||
JdkChoice {
|
||||
major: 21,
|
||||
lts: true,
|
||||
baseline: false,
|
||||
},
|
||||
JdkChoice {
|
||||
major: 25,
|
||||
lts: true,
|
||||
baseline: false,
|
||||
},
|
||||
];
|
||||
|
||||
/// 全部可选大版本(升序)。
|
||||
#[must_use]
|
||||
pub const fn choices() -> &'static [JdkChoice] {
|
||||
&CHOICES
|
||||
}
|
||||
|
||||
/// 目录里有没有这个大版本(`Some` = 这一档可选)。
|
||||
#[must_use]
|
||||
pub fn find(major: u32) -> Option<JdkChoice> {
|
||||
CHOICES.into_iter().find(|choice| choice.major == major)
|
||||
}
|
||||
|
||||
/// 某个大版本能由哪几家发行版承接(顺序 = 回退链顺序)。
|
||||
///
|
||||
/// 与 [`super::install::install`] 的过滤同源——`vendor::order` 给出这条链,`supports` 去掉
|
||||
/// 发不出这个大版本的那几家。于是 GUI 上「能选哪几家」与「真会试哪几家」不会各说一套。
|
||||
#[must_use]
|
||||
pub fn vendors_for(major: u32, priority: &[JdkVendor], domestic: bool) -> Vec<JdkVendor> {
|
||||
vendor::order(priority, domestic)
|
||||
.into_iter()
|
||||
.filter(|vendor| vendor.supports(major))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 「该装哪个大版本」:把当前实例的最低要求对齐到目录里的一档,没有上下文时给基准版本。
|
||||
///
|
||||
/// 对齐方向是**向上取整**(1.17 要 16 → 目录里的 17),因为装低一档一定跑不起来;
|
||||
/// 要求超过目录上限(例如 26.1 的 25 已在目录里;将来 28 就超出)时给基准版本,
|
||||
/// 由页面把「这个版本装什么都不对」说清楚——而不是悄悄装个用不上的。
|
||||
#[must_use]
|
||||
pub fn default_major(required: Option<u32>) -> u32 {
|
||||
let Some(required) = required else {
|
||||
return BASELINE_MAJOR;
|
||||
};
|
||||
CHOICES
|
||||
.iter()
|
||||
.map(|choice| choice.major)
|
||||
.find(|major| *major >= required)
|
||||
.unwrap_or(BASELINE_MAJOR)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{choices, default_major, find, vendors_for, BASELINE_MAJOR};
|
||||
use crate::game::java::vendor::{ids, JdkVendor};
|
||||
|
||||
/// 目录必须覆盖用户点名的三档(8 / 17 / 21),并钉住基准版本。
|
||||
///
|
||||
/// 变异证明:从 `CHOICES` 里删掉 8 那一项 ⇒ 本用例红(`find(8)` 为 `None`)。
|
||||
#[test]
|
||||
fn the_catalog_covers_eight_seventeen_and_twenty_one() {
|
||||
for major in [8, 17, 21] {
|
||||
assert!(find(major).is_some(), "目录里必须有 Java {major}");
|
||||
}
|
||||
assert_eq!(BASELINE_MAJOR, 17, "D13 的基准版本是 17");
|
||||
assert!(find(BASELINE_MAJOR).expect("基准版本在目录里").baseline);
|
||||
assert_eq!(
|
||||
choices().len(),
|
||||
5,
|
||||
"目录是 8/11/17/21/25 五档,改动要同步本断言与 UI 文案"
|
||||
);
|
||||
assert!(
|
||||
choices()
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].major < pair[1].major),
|
||||
"目录必须升序(UI 直接按这个顺序画)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 「8 只能由 Temurin / Zulu 承接」:Oracle 从 9 起、MS 已停发 8。
|
||||
///
|
||||
/// 变异证明:把 `vendors_for` 里的 `supports` 过滤删掉 ⇒ 本用例红(8 会列出 oracle/microsoft)。
|
||||
#[test]
|
||||
fn java_eight_is_only_served_by_the_vendors_that_still_ship_it() {
|
||||
assert_eq!(ids(&vendors_for(8, &[], true)), vec!["temurin", "zulu"]);
|
||||
assert_eq!(
|
||||
ids(&vendors_for(21, &[], true)),
|
||||
vec!["temurin", "oracle", "microsoft", "zulu"],
|
||||
"21 四家都在(国内默认序)"
|
||||
);
|
||||
assert!(
|
||||
!vendors_for(8, &[], true).contains(&JdkVendor::Microsoft),
|
||||
"微软已停发 8"
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户优先级要真的排在最前面(与 `install` 的链同源)。
|
||||
#[test]
|
||||
fn the_user_priority_leads_the_vendor_list() {
|
||||
let priority = vec![JdkVendor::Zulu];
|
||||
assert_eq!(ids(&vendors_for(17, &priority, true))[0], "zulu");
|
||||
assert_eq!(ids(&vendors_for(17, &priority, true)).len(), 4);
|
||||
}
|
||||
|
||||
/// 「该装哪个」对齐到目录里的一档:向上取整,没有上下文给基准,超出目录给基准。
|
||||
#[test]
|
||||
fn the_default_major_rounds_up_into_the_catalog() {
|
||||
assert_eq!(default_major(None), 17);
|
||||
assert_eq!(default_major(Some(8)), 8, "1.12.2 → 8");
|
||||
assert_eq!(default_major(Some(16)), 17, "1.17 要 16 → 目录里的 17");
|
||||
assert_eq!(default_major(Some(17)), 17);
|
||||
assert_eq!(default_major(Some(21)), 21, "1.20.5+ → 21");
|
||||
assert_eq!(default_major(Some(25)), 25, "26.1 → 25");
|
||||
assert_eq!(default_major(Some(99)), 17, "超出目录时退回基准");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! ```
|
||||
|
||||
pub mod api;
|
||||
pub mod catalog;
|
||||
pub mod discover;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
@@ -64,6 +65,9 @@ pub mod zulu;
|
||||
pub use self::api::{
|
||||
latest_url, parse_latest, JdkArchive, JdkRelease, ADOPTIUM_API_BASE, JDK_VENDOR,
|
||||
};
|
||||
pub use self::catalog::{
|
||||
choices as jdk_choices, default_major, vendors_for, JdkChoice, BASELINE_MAJOR,
|
||||
};
|
||||
pub use self::discover::{
|
||||
candidates, candidates_in_roots, scan_java_binaries, standard_candidates, standard_roots,
|
||||
};
|
||||
|
||||
@@ -61,9 +61,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||
///
|
||||
/// 拆成三段只为守 400 行硬线(M157/M159 往金表追加文案后越线);对调用方是**一条**序列,
|
||||
/// 拆成四段只为守 400 行硬线(M157/M159/M197 往金表追加文案后越线);对调用方是**一条**序列,
|
||||
/// `catalog/tests.rs` 的逐字节断言不用关心分段。
|
||||
///
|
||||
/// ⚠️ 加段时**必须**在这里也 chain 上:条数与内容都只能从这一条序列导出(见 [`len`])。
|
||||
@@ -71,6 +71,7 @@ pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static st
|
||||
GOLDEN
|
||||
.iter()
|
||||
.chain(tail::ROWS.iter())
|
||||
.chain(tail_ai::ROWS.iter())
|
||||
.chain(tail2::ROWS.iter())
|
||||
}
|
||||
|
||||
@@ -86,3 +87,4 @@ pub(super) fn len() -> usize {
|
||||
|
||||
mod tail;
|
||||
mod tail2;
|
||||
mod tail_ai;
|
||||
@@ -1,8 +1,12 @@
|
||||
//! 金表的**后半段**(`InstancesLaunchReset` 之后的全部行)。
|
||||
//! 金表的**第二段**(`InstancesLaunchReset` 之后到 M175 的 Java 域结束)。
|
||||
//!
|
||||
//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线;
|
||||
//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。
|
||||
//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。
|
||||
//!
|
||||
//! M197 又拆了一次:本段原本一路写到旧 `tail.rs` 末尾(329 行),加了「下载一个 JDK / 三个设置键」
|
||||
//! 那 65 条文案后会越过 400 行硬线,于是把 **M174 助手域起的后半截** 搬进 [`super::tail_ai`]
|
||||
//! (同样纯搬移、一行不改)。现在的链序是 `golden` → 本段 → `tail_ai` → `tail2`。
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("LoaderVanilla", "Vanilla", "原版"),
|
||||
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
|
||||
@@ -41,9 +45,12 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("SettingsJavaCancelled", "The scan was cancelled; the list below is from the previous run.", "扫描已取消;下面的列表还是上一次的结果。"),
|
||||
("SettingsJavaScanDone", "{count} JDK found", "找到 {count} 个 JDK"),
|
||||
("SettingsJavaNeverScanned", "Not scanned yet", "还没扫描过"),
|
||||
("SettingsJavaNeverScannedBody", "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"),
|
||||
// M197:这句以前写「在那之前启动器不会自己去扫」,与首屏自动扫(`app/state/construct.rs`
|
||||
// 的 `hydrate_java_settings`)互相打脸;现在说的是同一件事。
|
||||
("SettingsJavaNeverScannedBody", "The list is still empty. The launcher scans once by itself when the settings page opens; press Scan now to look again right away.", "列表还是空的。打开设置页时启动器会自己扫一次;也可以点「立即扫描」立刻再扫一遍。"),
|
||||
("SettingsJavaEmptyTitle", "No Java runtime found", "未检测到 Java 运行时"),
|
||||
("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"),
|
||||
// M197:删掉 markdown 反引号(渲染层没有内联代码通道,反引号会原样上屏)。
|
||||
("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with ull java install 21 on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 ull java install 21 装一个。"),
|
||||
("SettingsJavaDefault", "Default", "默认"),
|
||||
("SettingsJavaSetDefault", "Use as default", "设为默认"),
|
||||
("SettingsJavaFits", "Meets the current instance requirement", "满足当前实例的要求"),
|
||||
@@ -56,7 +63,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("SettingsJavaSourceManual", "added by you", "手动添加"),
|
||||
("SettingsJavaAdd", "Add a JDK path", "手动添加 JDK 路径"),
|
||||
("SettingsJavaAddPlaceholder", "C:\\Program Files\\Java\\jdk-21", "例:C:\\Program Files\\Java\\jdk-21"),
|
||||
("SettingsJavaAddHint", "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"),
|
||||
("SettingsJavaAddHint", "A JDK folder or its java executable. We run java -version on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 java -version;答不出话的一律拒绝。"),
|
||||
("SettingsJavaAddAction", "Add", "添加"),
|
||||
("SettingsJavaAdded", "Added {path}", "已添加 {path}"),
|
||||
("SettingsJavaMemory", "Default memory (MB)", "默认内存(MB)"),
|
||||
@@ -88,242 +95,72 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("SettingsJavaReasonAddWriteFailed", "the JDK is fine, but the list file could not be written (disk or permission)", "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"),
|
||||
("SettingsJavaReasonAddDuplicate", "that JDK is already in the list", "这份 JDK 已经在列表里了"),
|
||||
("SettingsJavaReasonWriteFailed", "the settings file could not be written (disk full, no permission, or the value was rejected)", "设置写不进去(磁盘满、没权限,或这个值被拒绝)"),
|
||||
// M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。
|
||||
// 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、
|
||||
// `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与
|
||||
// `CoreTextUntranslated` 之间(顺序 = `Key::ALL`)。
|
||||
("SettingsGroupAssistant", "Assistant", "助手"),
|
||||
("SettingsAiStatusLabel", "Status: {status}", "状态:{status}"),
|
||||
("SettingsAiStatusUnconfigured", "not configured", "未配置"),
|
||||
("SettingsAiStatusConfigured", "configured", "已配置"),
|
||||
("SettingsAiStatusVerified", "verified", "连接已通过"),
|
||||
("SettingsAiStatusTestFailed", "test failed", "测试失败"),
|
||||
("SettingsAiEnable", "Enable the assistant", "启用助手"),
|
||||
("SettingsAiEnableHint", "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。"),
|
||||
("SettingsAiProviderLabel", "Provider", "提供商"),
|
||||
("SettingsAiProviderOpenAi", "OpenAI compatible", "OpenAI 兼容"),
|
||||
("SettingsAiProviderAnthropic", "Anthropic", "Anthropic 原生"),
|
||||
("SettingsAiProviderOllama", "Ollama", "Ollama 原生"),
|
||||
("SettingsAiProviderDisabled", "Disabled", "已禁用"),
|
||||
("SettingsAiBaseUrl", "Endpoint (base_url)", "端点(base_url)"),
|
||||
("SettingsAiBaseUrlPlaceholder", "http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"),
|
||||
("SettingsAiBaseUrlHint", "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。"),
|
||||
("SettingsAiEffective", "Effective: {value}", "生效:{value}"),
|
||||
("SettingsAiModel", "Model name", "模型名称"),
|
||||
("SettingsAiModelPlaceholder", "deepseek-chat", "例:deepseek-chat"),
|
||||
("SettingsAiApiKey", "API key", "API 密钥"),
|
||||
("SettingsAiApiKeyPlaceholder", "Paste the key", "粘贴密钥"),
|
||||
("SettingsAiApiKeyConfigured", "A key is stored ({chars} characters). Leave this empty to keep it.", "已保存密钥({chars} 个字符)。留空则保持不变。"),
|
||||
("SettingsAiApiKeyMissing", "No key stored yet", "还没有保存密钥"),
|
||||
("SettingsAiShow", "Show", "显示"),
|
||||
("SettingsAiHide", "Hide", "隐藏"),
|
||||
("SettingsAiSave", "Save", "保存"),
|
||||
("SettingsAiTest", "Test connection", "测试连接"),
|
||||
("SettingsAiTesting", "Testing…", "正在测试…"),
|
||||
("SettingsAiSaved", "Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"),
|
||||
("SettingsAiRefused", "Not saved: {reason}", "未保存:{reason}"),
|
||||
("SettingsAiUnsaved", "There are unsaved changes.", "有未保存的改动。"),
|
||||
("SettingsAiNoStorage", "No settings file is available in this run, so nothing can be saved or tested.", "这一次运行没有可用的设置文件,保存与测试都做不了。"),
|
||||
("SettingsAiTestDisabled", "Fill in the missing field before testing: {missing}.", "先补上这一项再测试:{missing}。"),
|
||||
("SettingsAiMissingDisabled", "the assistant is disabled", "助手处于关闭状态"),
|
||||
("SettingsAiMissingBaseUrl", "the endpoint address", "端点地址"),
|
||||
("SettingsAiMissingModel", "the model name", "模型名称"),
|
||||
("SettingsAiMissingApiKey", "the API key", "API 密钥"),
|
||||
("SettingsAiReasonProvider", "the provider value is not recognised", "提供商取值不认识"),
|
||||
("SettingsAiReasonBaseUrl", "the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"),
|
||||
("SettingsAiReasonModel", "the model name is required", "必须填模型名称"),
|
||||
("SettingsAiTestPassed", "Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"),
|
||||
("SettingsAiTestAuth", "Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"),
|
||||
("SettingsAiTestModel", "The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"),
|
||||
("SettingsAiTestUnreachable", "The endpoint is unreachable.", "端点不可达。"),
|
||||
("SettingsAiTestTimeout", "Timed out while waiting for the endpoint.", "等端点回应超时了。"),
|
||||
("SettingsAiTestOther", "The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"),
|
||||
("SettingsAiTestNotConfigured", "Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"),
|
||||
("SettingsAiTestTarget", "Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"),
|
||||
("SettingsAiTestDetail", "Detail: {detail}", "详情:{detail}"),
|
||||
("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"),
|
||||
("DownloadTitle", "Version management", "版本管理"),
|
||||
("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"),
|
||||
("DownloadRefresh", "Refresh", "刷新"),
|
||||
("DownloadInstall", "Install", "安装"),
|
||||
("DownloadCancel", "Cancel install", "取消安装"),
|
||||
("DownloadInstalled", "Installed", "已安装"),
|
||||
("DownloadCount", "{count} versions", "{count} 个版本"),
|
||||
("DownloadRowSupporting", "{kind} · released {date}", "{kind} · 发布于 {date}"),
|
||||
("DownloadSelectHint", "Pick a version, then install", "选一个版本再安装"),
|
||||
("DownloadLoading", "Loading versions…", "正在加载版本列表…"),
|
||||
("DownloadLoadingHint", "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。"),
|
||||
("DownloadEmptyTitle", "No versions to show", "没有可显示的版本"),
|
||||
("DownloadEmptyBody", "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。"),
|
||||
("DownloadCategoryAll", "All", "全部"),
|
||||
("DownloadCategoryRelease", "Release", "正式版"),
|
||||
("DownloadCategorySnapshot", "Snapshot", "预览版"),
|
||||
("DownloadCategoryAncient", "Ancient", "远古版"),
|
||||
("DownloadStagePlanning", "Planning", "解析版本"),
|
||||
("DownloadStageDownloading", "Downloading files", "下载文件"),
|
||||
("DownloadStageInstalling", "Building the version", "生成版本"),
|
||||
("DownloadStageRegistering", "Registering the instance", "登记实例"),
|
||||
("DownloadStageDone", "Done", "已完成"),
|
||||
("DownloadStageFailed", "Install failed", "安装失败"),
|
||||
("DownloadStageCancelled", "Install cancelled", "安装已取消"),
|
||||
("DownloadProgress", "{done}/{total} files", "{done}/{total} 文件"),
|
||||
("DownloadProgressUnknown", "Waiting for the first file…", "等第一个文件…"),
|
||||
("DownloadRate", "{speed} · {eta}", "{speed},{eta}"),
|
||||
("DownloadEtaSecs", "{secs}s left", "剩 {secs}s"),
|
||||
("DownloadEtaMins", "{mins}m{secs}s left", "剩 {mins}m{secs}s"),
|
||||
("DownloadFailedHint", "The Logs page has the full reason.", "完整原因在日志页。"),
|
||||
("DownloadOpenLogs", "Open logs", "打开日志"),
|
||||
("DownloadDismiss", "Dismiss", "关闭"),
|
||||
("DownloadOptionsTitle", "Install options", "安装选项"),
|
||||
("DownloadOptionsLoaderVersion", "Loader version", "加载器版本"),
|
||||
("DownloadOptionsLoaderVersionsLoading", "Fetching loader versions…", "正在获取加载器版本…"),
|
||||
("DownloadOptionsLoaderVersionsFailed", "Could not fetch loader versions: {reason}", "取不到加载器版本:{reason}"),
|
||||
("DownloadOptionsLoaderVersionsEmpty", "No loader builds published for {version}", "官方没有为 {version} 发布加载器版本"),
|
||||
("DownloadOptionsInstanceName", "Instance name", "实例名"),
|
||||
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
|
||||
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
|
||||
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
|
||||
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
|
||||
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
|
||||
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||
("LogsOpenFolder", "Open folder", "打开目录"),
|
||||
("LogsClear", "Clear", "清空"),
|
||||
("LogsOutputTitle", "OUTPUT", "输出"),
|
||||
("LogsDiagnosisTitle", "DIAGNOSIS", "诊断"),
|
||||
("LogsConfidenceHigh", "HIGH CONFIDENCE", "高置信度"),
|
||||
("LogsConfidenceMedium", "MEDIUM CONFIDENCE", "中置信度"),
|
||||
("LogsConfidenceLow", "LOW CONFIDENCE", "低置信度"),
|
||||
("LogsSubtitleLive", "Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
|
||||
("LogsConfidence", "Confidence {percent}%", "置信度 {percent}%"),
|
||||
("LogsSuggestedFix", "Suggested fix", "建议动作"),
|
||||
("LogsSummaryLabel", "One-line summary", "一行摘要"),
|
||||
("LogsCopySummary", "Copy summary", "复制摘要"),
|
||||
("LogsEmptyTitle", "No output yet", "还没有输出"),
|
||||
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
|
||||
("LogsEmptyAction", "Choose an instance", "选择实例"),
|
||||
("AgentTitle", "Assistant", "助手"),
|
||||
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
|
||||
("AgentCollapse", "Collapse", "收起"),
|
||||
("AgentDetach", "Pop out", "拆出"),
|
||||
("AgentDock", "Dock back", "收回"),
|
||||
("AgentOpen", "Open assistant", "打开助手"),
|
||||
("AgentStripLabel", "AI", "助手"),
|
||||
("AgentSend", "Send", "发送"),
|
||||
("AgentStop", "Stop", "中断"),
|
||||
("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"),
|
||||
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
|
||||
("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
|
||||
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
|
||||
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
|
||||
("AgentUnread", "{count} unread", "{count} 条未读"),
|
||||
("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"),
|
||||
("AgentWidthLabel", "Width {width}", "宽度 {width}"),
|
||||
("AgentRoleYou", "You", "你"),
|
||||
("AgentRoleAssistant", "Assistant", "助手"),
|
||||
("AgentRoleNotice", "Notice", "提示"),
|
||||
("AgentErrorTitle", "Assistant error", "助手出错"),
|
||||
("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
|
||||
("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
|
||||
("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
|
||||
("AgentConfirmAllow", "Allow", "允许"),
|
||||
("AgentConfirmDeny", "Deny", "拒绝"),
|
||||
("AgentSimulated", "Simulated", "模拟"),
|
||||
("AgentSubtitleReal", "Conversation", "会话"),
|
||||
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
||||
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
|
||||
("SettingsLanguageEn", "English", "英文"),
|
||||
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
|
||||
("SettingsTheme", "Theme", "主题"),
|
||||
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsStartupPage", "Startup page", "启动页"),
|
||||
("SettingsStartupInstances", "Instances", "实例"),
|
||||
("SettingsCloseButton", "Close button", "关闭按钮"),
|
||||
("SettingsCloseToTray", "Exit to tray", "退出到托盘"),
|
||||
("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
|
||||
("SettingsResetSection", "Reset section", "重置本组"),
|
||||
("SettingsSave", "Save", "保存"),
|
||||
("NavAi", "Assistant", "助手"),
|
||||
("SettingsGroupGeneral", "General", "通用"),
|
||||
("SettingsGroupAppearance", "Appearance", "外观"),
|
||||
("SettingsGroupJava", "Java", "Java 运行时"),
|
||||
("SettingsGroupDownloads", "Downloads", "下载"),
|
||||
("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"),
|
||||
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
|
||||
("SettingsOverridesAdd", "Add override", "新增覆盖"),
|
||||
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
||||
("VersionRailTitle", "Version type", "版本类型"),
|
||||
("VersionMajorTitle", "Major version", "主版本"),
|
||||
("VersionMajorAll", "All versions", "全部版本"),
|
||||
("VersionSearchPlaceholder", "Search version numbers", "搜版本号"),
|
||||
("VersionSearchClear", "Clear search", "清空搜索"),
|
||||
("VersionSearchHits", "{count} matches", "{count} 个匹配"),
|
||||
("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"),
|
||||
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
|
||||
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
||||
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
||||
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
||||
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
||||
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
||||
("ContentTitle", "Content", "内容库"),
|
||||
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
|
||||
("ContentSectionVersions", "Version management", "版本管理"),
|
||||
("ContentSectionContent", "Content library", "内容库"),
|
||||
("ContentKindModpack", "Modpacks", "整合包"),
|
||||
("ContentKindMod", "Mods", "Mod"),
|
||||
("ContentKindResourcepack", "Resource packs", "资源包"),
|
||||
("ContentKindShader", "Shaders", "光影"),
|
||||
("ContentSourceModrinth", "Modrinth", "Modrinth 源"),
|
||||
("ContentSourceCurseforge", "CurseForge", "CurseForge 源"),
|
||||
("ContentSortRelevance", "Relevance", "相关度"),
|
||||
("ContentSortDownloads", "Downloads", "下载量"),
|
||||
("ContentSortNewest", "Newest", "最新发布"),
|
||||
("ContentSortUpdated", "Recently updated", "最近更新"),
|
||||
("ContentSearchPlaceholder", "Search content…", "搜索内容…"),
|
||||
("ContentSearch", "Search", "搜索"),
|
||||
("ContentFilterVersion", "Game version", "游戏版本"),
|
||||
("ContentFilterLoader", "Loader", "加载器"),
|
||||
("ContentFilterAny", "Any", "不限"),
|
||||
("ContentCount", "{count} results", "{count} 个结果"),
|
||||
("ContentLoading", "Loading content…", "正在加载内容…"),
|
||||
("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"),
|
||||
("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"),
|
||||
("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"),
|
||||
("ContentFailedTitle", "Could not load content", "内容加载失败"),
|
||||
("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"),
|
||||
("ContentOfflineTitle", "No connection", "未联网"),
|
||||
("ContentOfflineBody", "Content search needs the network. Check your connection, then retry.", "内容检索需要联网。检查网络后重试。"),
|
||||
("ContentRetry", "Retry", "重试"),
|
||||
("ContentLoadMore", "Load more", "加载更多"),
|
||||
("ContentNoMore", "No more results", "没有更多了"),
|
||||
("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"),
|
||||
("ContentDetailDownloads", "{count} downloads", "下载量 {count}"),
|
||||
("ContentDetailUpdated", "Updated {date}", "更新于 {date}"),
|
||||
("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"),
|
||||
("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"),
|
||||
("ContentDetailFetching", "Loading versions…", "正在取版本列表…"),
|
||||
("ContentDetailFetchFailed", "Could not load versions: {reason}", "版本列表取不到:{reason}"),
|
||||
("ContentDetailVersionCount", "{count} versions", "{count} 个版本"),
|
||||
("ContentDetailVersionLine", "{number} · {kind} · {date}", "{number}({kind})· {date}"),
|
||||
("ContentInstall", "Install into instance", "装入实例"),
|
||||
("ContentTargetInstance", "Target instance", "目标实例"),
|
||||
("ContentNoInstances", "No instance to install into", "没有可装入的实例"),
|
||||
("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"),
|
||||
("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"),
|
||||
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
|
||||
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
|
||||
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
|
||||
("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
|
||||
("PackImportTitle", "Import a modpack", "导入整合包"),
|
||||
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
|
||||
("PackDropActive", "Release to probe the pack", "松手开始探测"),
|
||||
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"),
|
||||
("PackPathLabel", "Pack path", "整合包路径"),
|
||||
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
|
||||
("PackImportAction", "Import", "导入"),
|
||||
("PackCancel", "Cancel import", "取消导入"),
|
||||
("PackDismiss", "Dismiss", "关闭"),
|
||||
("PackTargetLabel", "Install into", "装到实例"),
|
||||
("PackTargetNew", "New instance", "新建实例"),
|
||||
("SettingsJavaTooNew", "Above the requirement: this instance can only use Java {maximum} or older (the old loader cannot read newer class files)", "高于要求:当前实例只能用 Java {maximum} 及以下(老加载器吃不了新 class 文件)"),
|
||||
// M197:Java 分组的「下载一个 JDK」与三个设置键(`catalog/settings/java.rs` 里同一批追加)。
|
||||
("SettingsJavaDownloadTitle", "Download a JDK", "下载一个 JDK"),
|
||||
("SettingsJavaDownloadHint", "Downloads go through the built-in mirror table: in China the campus mirrors are tried first (Temurin over Tsinghua TUNA), and GitHub-hosted archives are fetched through gh-proxy. Every archive is verified against its SHA-256 before it is unpacked.", "下载走内置镜像表:国内先试院校镜像(Temurin 走清华 TUNA),GitHub 上的归档套 gh-proxy。落盘前一律按 SHA-256 校验。"),
|
||||
("SettingsJavaDownloadLts", "LTS", "长期支持版(LTS)"),
|
||||
("SettingsJavaDownloadBaseline", "Baseline (D13)", "基准版本(D13)"),
|
||||
("SettingsJavaDownloadVendors", "Vendors: {vendors}", "发行版:{vendors}"),
|
||||
("SettingsJavaDownloadNoVendor", "No vendor publishes this major version on this platform.", "这个平台上没有发行版发这个大版本。"),
|
||||
("SettingsJavaDownloadAction", "Install", "安装"),
|
||||
("SettingsJavaDownloadReinstall", "Install again", "重装"),
|
||||
("SettingsJavaDownloadInstalled", "Already installed: {path}", "已安装:{path}"),
|
||||
("SettingsJavaDownloadNeeded", "The current instance needs {requirement}.", "当前实例需要 {requirement}。"),
|
||||
("SettingsJavaDownloadFits", "Usable by the current instance", "当前实例可用"),
|
||||
("SettingsJavaDownloadTooLow", "The current instance needs Java {minimum} or newer, so installing this one will not start it.", "当前实例需要 Java {minimum} 及以上 —— 装这一档也起不来。"),
|
||||
("SettingsJavaDownloadTooHigh", "The current instance can only use Java {maximum} or older, so installing this one will not start it.", "当前实例只能用 Java {maximum} 及以下 —— 装这一档也起不来。"),
|
||||
("SettingsJavaDownloadRecommended", "Recommended for the current instance", "为当前实例推荐这一档"),
|
||||
("SettingsJavaDownloadBusy", "Installing Java {major}…", "正在安装 Java {major}…"),
|
||||
("SettingsJavaDownloadPhasePreparing", "Looking up the release…", "正在查版本…"),
|
||||
("SettingsJavaDownloadPhaseDownloading", "Downloading {percent}% ({done} of {total})", "下载中 {percent}%({done} / {total})"),
|
||||
("SettingsJavaDownloadPhaseVerifying", "Verifying the archive…", "正在校验归档…"),
|
||||
("SettingsJavaDownloadPhaseExtracting", "Unpacking…", "正在解压…"),
|
||||
("SettingsJavaDownloadSource", "Source: {source}", "来源:{source}"),
|
||||
("SettingsJavaDownloadRetry", "{source} failed ({reason}); retry {attempt} in {seconds}s", "{source} 失败({reason});{seconds} 秒后重试第 {attempt} 次"),
|
||||
("SettingsJavaDownloadSwitch", "Switching source: {from} → {to}", "换源:{from} → {to}"),
|
||||
("SettingsJavaDownloadDone", "Java {major} ({vendor}) is ready at {root} · source {source}", "Java {major}({vendor})已就绪:{root} · 来源 {source}"),
|
||||
("SettingsJavaDownloadDoneReused", "Java {major} ({vendor}) was already installed at {root}; nothing was downloaded.", "Java {major}({vendor})之前已经装好:{root},这一次没有下载。"),
|
||||
("SettingsJavaDownloadFailed", "Java {major} could not be installed; the last source tried was {source}. The full reason is in the log.", "Java {major} 没装成;最后试到的源是 {source}。完整原因在日志里。"),
|
||||
("SettingsJavaDownloadFailedNoSource", "Java {major} could not be installed: the version lookup itself failed. The full reason is in the log.", "Java {major} 没装成:查版本这一步就失败了。完整原因在日志里。"),
|
||||
("SettingsJavaDownloadFailedHint", "Check the network, or change the vendor order below.", "先查网络,或者改下面的发行版顺序。"),
|
||||
("SettingsJavaDownloadTriedHeader", "Sources tried:", "试过的源:"),
|
||||
("SettingsJavaDownloadTried", "{source}: {reason}", "{source}:{reason}"),
|
||||
("SettingsJavaDownloadRefused", "Not started: {reason}", "没开始:{reason}"),
|
||||
("SettingsJavaReasonDownloadBusy", "another JDK install is already running", "已经有一个 JDK 在装了"),
|
||||
("SettingsJavaReasonDownloadUnknownMajor", "that major version is not in the catalog", "目录里没有这个大版本"),
|
||||
("SettingsJavaReasonDownloadNoVendor", "no vendor publishes that major version here", "没有发行版在这里发这个大版本"),
|
||||
("SettingsJavaReasonRootUnresolved", "the JDK install folder cannot be resolved on this system", "这台机器上解析不出 JDK 安装目录"),
|
||||
("SettingsJavaSourceReasonNotFound", "this mirror does not have that file (404)", "这个镜像没有这个文件(404)"),
|
||||
("SettingsJavaSourceReasonServer", "the server answered with a 5xx / 429 error", "服务器报错 5xx / 429"),
|
||||
("SettingsJavaSourceReasonTimeout", "the request timed out", "请求超时"),
|
||||
("SettingsJavaSourceReasonNetwork", "the connection failed", "连不上"),
|
||||
("SettingsJavaSourceReasonIntegrity", "the checksum did not match", "校验不符"),
|
||||
("SettingsJavaSourceReasonIo", "a local disk error", "本地磁盘错误"),
|
||||
("SettingsJavaSourceReasonOther", "the request was rejected", "请求被拒"),
|
||||
("SettingsJavaInstallRoot", "JDK install folder", "JDK 安装目录"),
|
||||
("SettingsJavaInstallRootPlaceholder", "D:\\Java", "例:D:\\Java"),
|
||||
("SettingsJavaInstallRootHint", "Leave it empty to follow the data mode: portable keeps JDKs inside the launcher folder, standard uses the system Java folder. A path here overrides both, and JDKs land in <root>/<vendor>/<version>/.", "留空 = 跟数据目录模式走:portable 放在启动器目录里,standard 放在系统 Java 目录。填了它覆盖前两者,JDK 落在 <root>/<发行方>/<版本>/。"),
|
||||
("SettingsJavaInstallRootEffective", "In effect: {mode} · {path}", "生效:{mode} · {path}"),
|
||||
("SettingsJavaInstallRootLayerSetting", "this comes from java.installRoot", "这一项来自 java.installRoot"),
|
||||
("SettingsJavaInstallRootLayerDataMode", "this comes from core.data_mode ({mode})", "这一项来自 core.data_mode({mode})"),
|
||||
("SettingsJavaInstallRootUnresolved", "The system Java folder cannot be resolved here. Type a folder above, or set core.data_mode back to portable.", "这台机器上解析不出系统标准 Java 目录。在上面填一个目录,或把 core.data_mode 改回 portable。"),
|
||||
("SettingsJavaVendorPriority", "Vendor order", "发行版顺序"),
|
||||
("SettingsJavaVendorOrderEffective", "In effect: {order}", "生效顺序:{order}"),
|
||||
("SettingsJavaVendorOrderLayerSetting", "this comes from java.vendorPriority", "这一项来自 java.vendorPriority"),
|
||||
("SettingsJavaVendorOrderLayerRegion", "this comes from the built-in order for your region ({region})", "这一项来自内置的地区默认序({region})"),
|
||||
("SettingsJavaRegionDomestic", "China", "国内"),
|
||||
("SettingsJavaRegionGlobal", "rest of the world", "国外"),
|
||||
("SettingsJavaVendorUp", "Up", "上移"),
|
||||
("SettingsJavaVendorDown", "Down", "下移"),
|
||||
("SettingsJavaVendorReset", "Reset to the region default", "恢复地区默认序"),
|
||||
("SettingsJavaVendorSupports", "ships {majors}", "发 {majors}"),
|
||||
("SettingsJavaAutoInstall", "Prepare a JDK when none is found", "缺 JDK 时自动准备"),
|
||||
("SettingsJavaAutoInstallHint", "On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Off: you pick everything yourself.", "开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。关:全部自己挑。"),
|
||||
("SettingsJavaAutoInstallOn", "on", "开"),
|
||||
("SettingsJavaAutoInstallOff", "off", "关"),
|
||||
("SettingsJavaPrefsSave", "Save", "保存"),
|
||||
("SettingsJavaPrefsSaved", "Saved and read back from disk: all three keys are in the settings file.", "已保存并从磁盘读回:三个键都进配置文件了。"),
|
||||
("SettingsJavaPrefsUnsaved", "There are unsaved changes in this section.", "这一段有未保存的改动。"),
|
||||
("SettingsJavaReasonInstallRootTooLong", "the folder path is too long", "目录路径太长"),
|
||||
];
|
||||
@@ -0,0 +1,250 @@
|
||||
//! 金表的**第四段**(旧 `tail.rs` 从 M174 的助手域起的那一半;M197 拆出)。
|
||||
//!
|
||||
//! 为什么拆:`tail.rs` 在 M197 加了「下载一个 JDK / 三个设置键」这 70 多条文案后到 400+ 行
|
||||
//! (`guards.yml` 的 `file-size` 会红)。拆分口径与仓库其它 `-p1/-p2` / `tail2` 一致:
|
||||
//! **纯搬移、一行不改**,顺序即 `Key::ALL` 的续段——本段从 M174 的助手域开始,
|
||||
//! 一路到旧 `tail.rs` 末尾(后面还跟着 core_text / download / logs / agent / version /
|
||||
//! content / pack 各行,它们本来就在这个位置)。
|
||||
//!
|
||||
//! 拼装见 [`super::super::golden::rows`](链序:`golden` → `tail` → 本段 → `tail2`)。
|
||||
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
// M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。
|
||||
// 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、
|
||||
// `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与
|
||||
// `CoreTextUntranslated` 之间(顺序 = `Key::ALL`)。
|
||||
("SettingsGroupAssistant", "Assistant", "助手"),
|
||||
("SettingsAiStatusLabel", "Status: {status}", "状态:{status}"),
|
||||
("SettingsAiStatusUnconfigured", "not configured", "未配置"),
|
||||
("SettingsAiStatusConfigured", "configured", "已配置"),
|
||||
("SettingsAiStatusVerified", "verified", "连接已通过"),
|
||||
("SettingsAiStatusTestFailed", "test failed", "测试失败"),
|
||||
("SettingsAiEnable", "Enable the assistant", "启用助手"),
|
||||
("SettingsAiEnableHint", "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。"),
|
||||
("SettingsAiProviderLabel", "Provider", "提供商"),
|
||||
("SettingsAiProviderOpenAi", "OpenAI compatible", "OpenAI 兼容"),
|
||||
("SettingsAiProviderAnthropic", "Anthropic", "Anthropic 原生"),
|
||||
("SettingsAiProviderOllama", "Ollama", "Ollama 原生"),
|
||||
("SettingsAiProviderDisabled", "Disabled", "已禁用"),
|
||||
("SettingsAiBaseUrl", "Endpoint (base_url)", "端点(base_url)"),
|
||||
("SettingsAiBaseUrlPlaceholder", "http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"),
|
||||
("SettingsAiBaseUrlHint", "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。"),
|
||||
("SettingsAiEffective", "Effective: {value}", "生效:{value}"),
|
||||
("SettingsAiModel", "Model name", "模型名称"),
|
||||
("SettingsAiModelPlaceholder", "deepseek-chat", "例:deepseek-chat"),
|
||||
("SettingsAiApiKey", "API key", "API 密钥"),
|
||||
("SettingsAiApiKeyPlaceholder", "Paste the key", "粘贴密钥"),
|
||||
("SettingsAiApiKeyConfigured", "A key is stored ({chars} characters). Leave this empty to keep it.", "已保存密钥({chars} 个字符)。留空则保持不变。"),
|
||||
("SettingsAiApiKeyMissing", "No key stored yet", "还没有保存密钥"),
|
||||
("SettingsAiShow", "Show", "显示"),
|
||||
("SettingsAiHide", "Hide", "隐藏"),
|
||||
("SettingsAiSave", "Save", "保存"),
|
||||
("SettingsAiTest", "Test connection", "测试连接"),
|
||||
("SettingsAiTesting", "Testing…", "正在测试…"),
|
||||
("SettingsAiSaved", "Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"),
|
||||
("SettingsAiRefused", "Not saved: {reason}", "未保存:{reason}"),
|
||||
("SettingsAiUnsaved", "There are unsaved changes.", "有未保存的改动。"),
|
||||
("SettingsAiNoStorage", "No settings file is available in this run, so nothing can be saved or tested.", "这一次运行没有可用的设置文件,保存与测试都做不了。"),
|
||||
("SettingsAiTestDisabled", "Fill in the missing field before testing: {missing}.", "先补上这一项再测试:{missing}。"),
|
||||
("SettingsAiMissingDisabled", "the assistant is disabled", "助手处于关闭状态"),
|
||||
("SettingsAiMissingBaseUrl", "the endpoint address", "端点地址"),
|
||||
("SettingsAiMissingModel", "the model name", "模型名称"),
|
||||
("SettingsAiMissingApiKey", "the API key", "API 密钥"),
|
||||
("SettingsAiReasonProvider", "the provider value is not recognised", "提供商取值不认识"),
|
||||
("SettingsAiReasonBaseUrl", "the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"),
|
||||
("SettingsAiReasonModel", "the model name is required", "必须填模型名称"),
|
||||
("SettingsAiTestPassed", "Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"),
|
||||
("SettingsAiTestAuth", "Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"),
|
||||
("SettingsAiTestModel", "The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"),
|
||||
("SettingsAiTestUnreachable", "The endpoint is unreachable.", "端点不可达。"),
|
||||
("SettingsAiTestTimeout", "Timed out while waiting for the endpoint.", "等端点回应超时了。"),
|
||||
("SettingsAiTestOther", "The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"),
|
||||
("SettingsAiTestNotConfigured", "Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"),
|
||||
("SettingsAiTestTarget", "Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"),
|
||||
("SettingsAiTestDetail", "Detail: {detail}", "详情:{detail}"),
|
||||
("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"),
|
||||
("DownloadTitle", "Version management", "版本管理"),
|
||||
("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"),
|
||||
("DownloadRefresh", "Refresh", "刷新"),
|
||||
("DownloadInstall", "Install", "安装"),
|
||||
("DownloadCancel", "Cancel install", "取消安装"),
|
||||
("DownloadInstalled", "Installed", "已安装"),
|
||||
("DownloadCount", "{count} versions", "{count} 个版本"),
|
||||
("DownloadRowSupporting", "{kind} · released {date}", "{kind} · 发布于 {date}"),
|
||||
("DownloadSelectHint", "Pick a version, then install", "选一个版本再安装"),
|
||||
("DownloadLoading", "Loading versions…", "正在加载版本列表…"),
|
||||
("DownloadLoadingHint", "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。"),
|
||||
("DownloadEmptyTitle", "No versions to show", "没有可显示的版本"),
|
||||
("DownloadEmptyBody", "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。"),
|
||||
("DownloadCategoryAll", "All", "全部"),
|
||||
("DownloadCategoryRelease", "Release", "正式版"),
|
||||
("DownloadCategorySnapshot", "Snapshot", "预览版"),
|
||||
("DownloadCategoryAncient", "Ancient", "远古版"),
|
||||
("DownloadStagePlanning", "Planning", "解析版本"),
|
||||
("DownloadStageDownloading", "Downloading files", "下载文件"),
|
||||
("DownloadStageInstalling", "Building the version", "生成版本"),
|
||||
("DownloadStageRegistering", "Registering the instance", "登记实例"),
|
||||
("DownloadStageDone", "Done", "已完成"),
|
||||
("DownloadStageFailed", "Install failed", "安装失败"),
|
||||
("DownloadStageCancelled", "Install cancelled", "安装已取消"),
|
||||
("DownloadProgress", "{done}/{total} files", "{done}/{total} 文件"),
|
||||
("DownloadProgressUnknown", "Waiting for the first file…", "等第一个文件…"),
|
||||
("DownloadRate", "{speed} · {eta}", "{speed},{eta}"),
|
||||
("DownloadEtaSecs", "{secs}s left", "剩 {secs}s"),
|
||||
("DownloadEtaMins", "{mins}m{secs}s left", "剩 {mins}m{secs}s"),
|
||||
("DownloadFailedHint", "The Logs page has the full reason.", "完整原因在日志页。"),
|
||||
("DownloadOpenLogs", "Open logs", "打开日志"),
|
||||
("DownloadDismiss", "Dismiss", "关闭"),
|
||||
("DownloadOptionsTitle", "Install options", "安装选项"),
|
||||
("DownloadOptionsLoaderVersion", "Loader version", "加载器版本"),
|
||||
("DownloadOptionsLoaderVersionsLoading", "Fetching loader versions…", "正在获取加载器版本…"),
|
||||
("DownloadOptionsLoaderVersionsFailed", "Could not fetch loader versions: {reason}", "取不到加载器版本:{reason}"),
|
||||
("DownloadOptionsLoaderVersionsEmpty", "No loader builds published for {version}", "官方没有为 {version} 发布加载器版本"),
|
||||
("DownloadOptionsInstanceName", "Instance name", "实例名"),
|
||||
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
|
||||
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
|
||||
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
|
||||
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
|
||||
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
|
||||
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||
("LogsOpenFolder", "Open folder", "打开目录"),
|
||||
("LogsClear", "Clear", "清空"),
|
||||
("LogsOutputTitle", "OUTPUT", "输出"),
|
||||
("LogsDiagnosisTitle", "DIAGNOSIS", "诊断"),
|
||||
("LogsConfidenceHigh", "HIGH CONFIDENCE", "高置信度"),
|
||||
("LogsConfidenceMedium", "MEDIUM CONFIDENCE", "中置信度"),
|
||||
("LogsConfidenceLow", "LOW CONFIDENCE", "低置信度"),
|
||||
("LogsSubtitleLive", "Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
|
||||
("LogsConfidence", "Confidence {percent}%", "置信度 {percent}%"),
|
||||
("LogsSuggestedFix", "Suggested fix", "建议动作"),
|
||||
("LogsSummaryLabel", "One-line summary", "一行摘要"),
|
||||
("LogsCopySummary", "Copy summary", "复制摘要"),
|
||||
("LogsEmptyTitle", "No output yet", "还没有输出"),
|
||||
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
|
||||
("LogsEmptyAction", "Choose an instance", "选择实例"),
|
||||
("AgentTitle", "Assistant", "助手"),
|
||||
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
|
||||
("AgentCollapse", "Collapse", "收起"),
|
||||
("AgentDetach", "Pop out", "拆出"),
|
||||
("AgentDock", "Dock back", "收回"),
|
||||
("AgentOpen", "Open assistant", "打开助手"),
|
||||
("AgentStripLabel", "AI", "助手"),
|
||||
("AgentSend", "Send", "发送"),
|
||||
("AgentStop", "Stop", "中断"),
|
||||
("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"),
|
||||
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
|
||||
("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
|
||||
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
|
||||
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
|
||||
("AgentUnread", "{count} unread", "{count} 条未读"),
|
||||
("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"),
|
||||
("AgentWidthLabel", "Width {width}", "宽度 {width}"),
|
||||
("AgentRoleYou", "You", "你"),
|
||||
("AgentRoleAssistant", "Assistant", "助手"),
|
||||
("AgentRoleNotice", "Notice", "提示"),
|
||||
("AgentErrorTitle", "Assistant error", "助手出错"),
|
||||
("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
|
||||
("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
|
||||
("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
|
||||
("AgentConfirmAllow", "Allow", "允许"),
|
||||
("AgentConfirmDeny", "Deny", "拒绝"),
|
||||
("AgentSimulated", "Simulated", "模拟"),
|
||||
("AgentSubtitleReal", "Conversation", "会话"),
|
||||
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
||||
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
|
||||
("SettingsLanguageEn", "English", "英文"),
|
||||
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
|
||||
("SettingsTheme", "Theme", "主题"),
|
||||
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsStartupPage", "Startup page", "启动页"),
|
||||
("SettingsStartupInstances", "Instances", "实例"),
|
||||
("SettingsCloseButton", "Close button", "关闭按钮"),
|
||||
("SettingsCloseToTray", "Exit to tray", "退出到托盘"),
|
||||
("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
|
||||
("SettingsResetSection", "Reset section", "重置本组"),
|
||||
("SettingsSave", "Save", "保存"),
|
||||
("NavAi", "Assistant", "助手"),
|
||||
("SettingsGroupGeneral", "General", "通用"),
|
||||
("SettingsGroupAppearance", "Appearance", "外观"),
|
||||
("SettingsGroupJava", "Java", "Java 运行时"),
|
||||
("SettingsGroupDownloads", "Downloads", "下载"),
|
||||
("SettingsOverridesEmptyTitle", "No per-instance overrides", "没有逐实例覆盖"),
|
||||
("SettingsOverridesEmptyBody", "Overrides let one instance keep its own Java runtime, memory or JVM flags while the rest of the launcher keeps the defaults above.", "逐实例覆盖让某个实例保留自己的 Java 运行时、内存或 JVM 参数,其余实例沿用上面的默认值。"),
|
||||
("SettingsOverridesAdd", "Add override", "新增覆盖"),
|
||||
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
||||
("VersionRailTitle", "Version type", "版本类型"),
|
||||
("VersionMajorTitle", "Major version", "主版本"),
|
||||
("VersionMajorAll", "All versions", "全部版本"),
|
||||
("VersionSearchPlaceholder", "Search version numbers", "搜版本号"),
|
||||
("VersionSearchClear", "Clear search", "清空搜索"),
|
||||
("VersionSearchHits", "{count} matches", "{count} 个匹配"),
|
||||
("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"),
|
||||
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
|
||||
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
||||
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
||||
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
||||
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
||||
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
||||
("ContentTitle", "Content", "内容库"),
|
||||
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
|
||||
("ContentSectionVersions", "Version management", "版本管理"),
|
||||
("ContentSectionContent", "Content library", "内容库"),
|
||||
("ContentKindModpack", "Modpacks", "整合包"),
|
||||
("ContentKindMod", "Mods", "Mod"),
|
||||
("ContentKindResourcepack", "Resource packs", "资源包"),
|
||||
("ContentKindShader", "Shaders", "光影"),
|
||||
("ContentSourceModrinth", "Modrinth", "Modrinth 源"),
|
||||
("ContentSourceCurseforge", "CurseForge", "CurseForge 源"),
|
||||
("ContentSortRelevance", "Relevance", "相关度"),
|
||||
("ContentSortDownloads", "Downloads", "下载量"),
|
||||
("ContentSortNewest", "Newest", "最新发布"),
|
||||
("ContentSortUpdated", "Recently updated", "最近更新"),
|
||||
("ContentSearchPlaceholder", "Search content…", "搜索内容…"),
|
||||
("ContentSearch", "Search", "搜索"),
|
||||
("ContentFilterVersion", "Game version", "游戏版本"),
|
||||
("ContentFilterLoader", "Loader", "加载器"),
|
||||
("ContentFilterAny", "Any", "不限"),
|
||||
("ContentCount", "{count} results", "{count} 个结果"),
|
||||
("ContentLoading", "Loading content…", "正在加载内容…"),
|
||||
("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"),
|
||||
("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"),
|
||||
("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"),
|
||||
("ContentFailedTitle", "Could not load content", "内容加载失败"),
|
||||
("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"),
|
||||
("ContentOfflineTitle", "No connection", "未联网"),
|
||||
("ContentOfflineBody", "Content search needs the network. Check your connection, then retry.", "内容检索需要联网。检查网络后重试。"),
|
||||
("ContentRetry", "Retry", "重试"),
|
||||
("ContentLoadMore", "Load more", "加载更多"),
|
||||
("ContentNoMore", "No more results", "没有更多了"),
|
||||
("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"),
|
||||
("ContentDetailDownloads", "{count} downloads", "下载量 {count}"),
|
||||
("ContentDetailUpdated", "Updated {date}", "更新于 {date}"),
|
||||
("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"),
|
||||
("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"),
|
||||
("ContentDetailFetching", "Loading versions…", "正在取版本列表…"),
|
||||
("ContentDetailFetchFailed", "Could not load versions: {reason}", "版本列表取不到:{reason}"),
|
||||
("ContentDetailVersionCount", "{count} versions", "{count} 个版本"),
|
||||
("ContentDetailVersionLine", "{number} · {kind} · {date}", "{number}({kind})· {date}"),
|
||||
("ContentInstall", "Install into instance", "装入实例"),
|
||||
("ContentTargetInstance", "Target instance", "目标实例"),
|
||||
("ContentNoInstances", "No instance to install into", "没有可装入的实例"),
|
||||
("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"),
|
||||
("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"),
|
||||
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
|
||||
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
|
||||
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
|
||||
("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
|
||||
("PackImportTitle", "Import a modpack", "导入整合包"),
|
||||
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
|
||||
("PackDropActive", "Release to probe the pack", "松手开始探测"),
|
||||
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"),
|
||||
("PackPathLabel", "Pack path", "整合包路径"),
|
||||
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
|
||||
("PackImportAction", "Import", "导入"),
|
||||
("PackCancel", "Cancel import", "取消导入"),
|
||||
("PackDismiss", "Dismiss", "关闭"),
|
||||
("PackTargetLabel", "Install into", "装到实例"),
|
||||
("PackTargetNew", "New instance", "新建实例"),
|
||||
];
|
||||
@@ -30,13 +30,13 @@ macro_rules! settings_java_entries {
|
||||
SettingsJavaScanDone => ("{count} JDK found", "找到 {count} 个 JDK"),
|
||||
SettingsJavaNeverScanned => ("Not scanned yet", "还没扫描过"),
|
||||
SettingsJavaNeverScannedBody => (
|
||||
"Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.",
|
||||
"点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"
|
||||
"The list is still empty. The launcher scans once by itself when the settings page opens; press Scan now to look again right away.",
|
||||
"列表还是空的。打开设置页时启动器会自己扫一次;也可以点「立即扫描」立刻再扫一遍。"
|
||||
),
|
||||
SettingsJavaEmptyTitle => ("No Java runtime found", "未检测到 Java 运行时"),
|
||||
SettingsJavaEmptyBody => (
|
||||
"Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.",
|
||||
"没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"
|
||||
"Nothing matched. Add the path of a JDK you already unpacked below, or install one with ull java install 21 on the command line.",
|
||||
"没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 ull java install 21 装一个。"
|
||||
),
|
||||
// ── 运行时列表
|
||||
SettingsJavaDefault => ("Default", "默认"),
|
||||
@@ -59,8 +59,8 @@ macro_rules! settings_java_entries {
|
||||
"例:C:\\Program Files\\Java\\jdk-21"
|
||||
),
|
||||
SettingsJavaAddHint => (
|
||||
"A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.",
|
||||
"JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"
|
||||
"A JDK folder or its java executable. We run java -version on it; anything that cannot answer is refused.",
|
||||
"JDK 目录或它的 java 可执行文件。会真跑一次 java -version;答不出话的一律拒绝。"
|
||||
),
|
||||
SettingsJavaAddAction => ("Add", "添加"),
|
||||
SettingsJavaAdded => ("Added {path}", "已添加 {path}"),
|
||||
@@ -148,6 +148,170 @@ macro_rules! settings_java_entries {
|
||||
"the settings file could not be written (disk full, no permission, or the value was rejected)",
|
||||
"设置写不进去(磁盘满、没权限,或这个值被拒绝)"
|
||||
),
|
||||
// ── M197:版本与实例要求的匹配(两向)
|
||||
SettingsJavaTooNew => (
|
||||
"Above the requirement: this instance can only use Java {maximum} or older (the old loader cannot read newer class files)",
|
||||
"高于要求:当前实例只能用 Java {maximum} 及以下(老加载器吃不了新 class 文件)"
|
||||
),
|
||||
// ── M197:下载一个 JDK(版本目录 / 下载中 / 失败三态)
|
||||
SettingsJavaDownloadTitle => ("Download a JDK", "下载一个 JDK"),
|
||||
SettingsJavaDownloadHint => (
|
||||
"Downloads go through the built-in mirror table: in China the campus mirrors are tried first (Temurin over Tsinghua TUNA), and GitHub-hosted archives are fetched through gh-proxy. Every archive is verified against its SHA-256 before it is unpacked.",
|
||||
"下载走内置镜像表:国内先试院校镜像(Temurin 走清华 TUNA),GitHub 上的归档套 gh-proxy。落盘前一律按 SHA-256 校验。"
|
||||
),
|
||||
SettingsJavaDownloadLts => ("LTS", "长期支持版(LTS)"),
|
||||
SettingsJavaDownloadBaseline => ("Baseline (D13)", "基准版本(D13)"),
|
||||
SettingsJavaDownloadVendors => ("Vendors: {vendors}", "发行版:{vendors}"),
|
||||
SettingsJavaDownloadNoVendor => (
|
||||
"No vendor publishes this major version on this platform.",
|
||||
"这个平台上没有发行版发这个大版本。"
|
||||
),
|
||||
SettingsJavaDownloadAction => ("Install", "安装"),
|
||||
SettingsJavaDownloadReinstall => ("Install again", "重装"),
|
||||
SettingsJavaDownloadInstalled => ("Already installed: {path}", "已安装:{path}"),
|
||||
SettingsJavaDownloadNeeded => (
|
||||
"The current instance needs {requirement}.",
|
||||
"当前实例需要 {requirement}。"
|
||||
),
|
||||
SettingsJavaDownloadFits => (
|
||||
"Usable by the current instance",
|
||||
"当前实例可用"
|
||||
),
|
||||
SettingsJavaDownloadTooLow => (
|
||||
"The current instance needs Java {minimum} or newer, so installing this one will not start it.",
|
||||
"当前实例需要 Java {minimum} 及以上 —— 装这一档也起不来。"
|
||||
),
|
||||
SettingsJavaDownloadTooHigh => (
|
||||
"The current instance can only use Java {maximum} or older, so installing this one will not start it.",
|
||||
"当前实例只能用 Java {maximum} 及以下 —— 装这一档也起不来。"
|
||||
),
|
||||
SettingsJavaDownloadRecommended => (
|
||||
"Recommended for the current instance",
|
||||
"为当前实例推荐这一档"
|
||||
),
|
||||
SettingsJavaDownloadBusy => ("Installing Java {major}…", "正在安装 Java {major}…"),
|
||||
SettingsJavaDownloadPhasePreparing => ("Looking up the release…", "正在查版本…"),
|
||||
SettingsJavaDownloadPhaseDownloading => (
|
||||
"Downloading {percent}% ({done} of {total})",
|
||||
"下载中 {percent}%({done} / {total})"
|
||||
),
|
||||
SettingsJavaDownloadPhaseVerifying => ("Verifying the archive…", "正在校验归档…"),
|
||||
SettingsJavaDownloadPhaseExtracting => ("Unpacking…", "正在解压…"),
|
||||
SettingsJavaDownloadSource => ("Source: {source}", "来源:{source}"),
|
||||
SettingsJavaDownloadRetry => (
|
||||
"{source} failed ({reason}); retry {attempt} in {seconds}s",
|
||||
"{source} 失败({reason});{seconds} 秒后重试第 {attempt} 次"
|
||||
),
|
||||
SettingsJavaDownloadSwitch => ("Switching source: {from} → {to}", "换源:{from} → {to}"),
|
||||
SettingsJavaDownloadDone => (
|
||||
"Java {major} ({vendor}) is ready at {root} · source {source}",
|
||||
"Java {major}({vendor})已就绪:{root} · 来源 {source}"
|
||||
),
|
||||
SettingsJavaDownloadDoneReused => (
|
||||
"Java {major} ({vendor}) was already installed at {root}; nothing was downloaded.",
|
||||
"Java {major}({vendor})之前已经装好:{root},这一次没有下载。"
|
||||
),
|
||||
SettingsJavaDownloadFailed => (
|
||||
"Java {major} could not be installed; the last source tried was {source}. The full reason is in the log.",
|
||||
"Java {major} 没装成;最后试到的源是 {source}。完整原因在日志里。"
|
||||
),
|
||||
SettingsJavaDownloadFailedNoSource => (
|
||||
"Java {major} could not be installed: the version lookup itself failed. The full reason is in the log.",
|
||||
"Java {major} 没装成:查版本这一步就失败了。完整原因在日志里。"
|
||||
),
|
||||
SettingsJavaDownloadFailedHint => (
|
||||
"Check the network, or change the vendor order below.",
|
||||
"先查网络,或者改下面的发行版顺序。"
|
||||
),
|
||||
SettingsJavaDownloadTriedHeader => ("Sources tried:", "试过的源:"),
|
||||
SettingsJavaDownloadTried => ("{source}: {reason}", "{source}:{reason}"),
|
||||
SettingsJavaDownloadRefused => ("Not started: {reason}", "没开始:{reason}"),
|
||||
SettingsJavaReasonDownloadBusy => (
|
||||
"another JDK install is already running",
|
||||
"已经有一个 JDK 在装了"
|
||||
),
|
||||
SettingsJavaReasonDownloadUnknownMajor => (
|
||||
"that major version is not in the catalog",
|
||||
"目录里没有这个大版本"
|
||||
),
|
||||
SettingsJavaReasonDownloadNoVendor => (
|
||||
"no vendor publishes that major version here",
|
||||
"没有发行版在这里发这个大版本"
|
||||
),
|
||||
SettingsJavaReasonRootUnresolved => (
|
||||
"the JDK install folder cannot be resolved on this system",
|
||||
"这台机器上解析不出 JDK 安装目录"
|
||||
),
|
||||
// ── 源失败原因(结构化,`DownloadErrorKind` 的文案面;core 的原句只进日志)
|
||||
SettingsJavaSourceReasonNotFound => (
|
||||
"this mirror does not have that file (404)",
|
||||
"这个镜像没有这个文件(404)"
|
||||
),
|
||||
SettingsJavaSourceReasonServer => (
|
||||
"the server answered with a 5xx / 429 error",
|
||||
"服务器报错 5xx / 429"
|
||||
),
|
||||
SettingsJavaSourceReasonTimeout => ("the request timed out", "请求超时"),
|
||||
SettingsJavaSourceReasonNetwork => ("the connection failed", "连不上"),
|
||||
SettingsJavaSourceReasonIntegrity => ("the checksum did not match", "校验不符"),
|
||||
SettingsJavaSourceReasonIo => ("a local disk error", "本地磁盘错误"),
|
||||
SettingsJavaSourceReasonOther => ("the request was rejected", "请求被拒"),
|
||||
// ── M197:三个设置键(`java.installRoot` / `java.vendorPriority` / `java.auto_install`)
|
||||
SettingsJavaInstallRoot => ("JDK install folder", "JDK 安装目录"),
|
||||
SettingsJavaInstallRootPlaceholder => ("D:\\Java", "例:D:\\Java"),
|
||||
SettingsJavaInstallRootHint => (
|
||||
"Leave it empty to follow the data mode: portable keeps JDKs inside the launcher folder, standard uses the system Java folder. A path here overrides both, and JDKs land in <root>/<vendor>/<version>/.",
|
||||
"留空 = 跟数据目录模式走:portable 放在启动器目录里,standard 放在系统 Java 目录。填了它覆盖前两者,JDK 落在 <root>/<发行方>/<版本>/。"
|
||||
),
|
||||
SettingsJavaInstallRootEffective => ("In effect: {mode} · {path}", "生效:{mode} · {path}"),
|
||||
SettingsJavaInstallRootLayerSetting => (
|
||||
"this comes from java.installRoot",
|
||||
"这一项来自 java.installRoot"
|
||||
),
|
||||
SettingsJavaInstallRootLayerDataMode => (
|
||||
"this comes from core.data_mode ({mode})",
|
||||
"这一项来自 core.data_mode({mode})"
|
||||
),
|
||||
SettingsJavaInstallRootUnresolved => (
|
||||
"The system Java folder cannot be resolved here. Type a folder above, or set core.data_mode back to portable.",
|
||||
"这台机器上解析不出系统标准 Java 目录。在上面填一个目录,或把 core.data_mode 改回 portable。"
|
||||
),
|
||||
SettingsJavaVendorPriority => ("Vendor order", "发行版顺序"),
|
||||
SettingsJavaVendorOrderEffective => ("In effect: {order}", "生效顺序:{order}"),
|
||||
SettingsJavaVendorOrderLayerSetting => (
|
||||
"this comes from java.vendorPriority",
|
||||
"这一项来自 java.vendorPriority"
|
||||
),
|
||||
SettingsJavaVendorOrderLayerRegion => (
|
||||
"this comes from the built-in order for your region ({region})",
|
||||
"这一项来自内置的地区默认序({region})"
|
||||
),
|
||||
SettingsJavaRegionDomestic => ("China", "国内"),
|
||||
SettingsJavaRegionGlobal => ("rest of the world", "国外"),
|
||||
SettingsJavaVendorUp => ("Up", "上移"),
|
||||
SettingsJavaVendorDown => ("Down", "下移"),
|
||||
SettingsJavaVendorReset => ("Reset to the region default", "恢复地区默认序"),
|
||||
SettingsJavaVendorSupports => ("ships {majors}", "发 {majors}"),
|
||||
SettingsJavaAutoInstall => ("Prepare a JDK when none is found", "缺 JDK 时自动准备"),
|
||||
SettingsJavaAutoInstallHint => (
|
||||
"On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Off: you pick everything yourself.",
|
||||
"开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。关:全部自己挑。"
|
||||
),
|
||||
SettingsJavaAutoInstallOn => ("on", "开"),
|
||||
SettingsJavaAutoInstallOff => ("off", "关"),
|
||||
SettingsJavaPrefsSave => ("Save", "保存"),
|
||||
SettingsJavaPrefsSaved => (
|
||||
"Saved and read back from disk: all three keys are in the settings file.",
|
||||
"已保存并从磁盘读回:三个键都进配置文件了。"
|
||||
),
|
||||
SettingsJavaPrefsUnsaved => (
|
||||
"There are unsaved changes in this section.",
|
||||
"这一段有未保存的改动。"
|
||||
),
|
||||
SettingsJavaReasonInstallRootTooLong => (
|
||||
"the folder path is too long",
|
||||
"目录路径太长"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user