diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index 69d8671..f8b615e 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -26,6 +26,10 @@ use crate::app::diag::diag; 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( @@ -56,6 +60,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() @@ -100,9 +111,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)` 只关掉**这条生产入口的自动发起** @@ -127,9 +146,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; } diff --git a/crates/app-iced/src/app/update/settings/java/downloads.rs b/crates/app-iced/src/app/update/settings/java/downloads.rs new file mode 100644 index 0000000..2583b91 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/java/downloads.rs @@ -0,0 +1,118 @@ +//! 「下载一个 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::{gh_proxy_of_config, 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, + // `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认, + // `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。 + gh_proxy: gh_proxy_of_config(&config), + }); + 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 { + app.start_java_download(major); + Task::none() +} diff --git a/crates/app-iced/src/app/update/settings/java/prefs.rs b/crates/app-iced/src/app/update/settings/java/prefs.rs new file mode 100644 index 0000000..b02fbd7 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/java/prefs.rs @@ -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 { + app.edit_java_install_root(value); + Task::none() +} + +/// `ToggleAutoInstall` 的转发。 +pub(super) fn on_toggle_auto_install(app: &mut App, on: bool) -> Task { + app.toggle_java_auto_install(on); + Task::none() +} + +/// `MoveVendor` 的转发。 +pub(super) fn on_move_vendor(app: &mut App, index: usize, up: bool) -> Task { + app.move_java_vendor(index, up); + Task::none() +} + +/// `ResetPriority` 的转发。 +pub(super) fn on_reset_priority(app: &mut App) -> Task { + app.reset_java_priority(); + Task::none() +} + +/// `SavePrefs` 的转发。 +pub(super) fn on_save_prefs(app: &mut App) -> Task { + app.save_java_prefs(); + Task::none() +} diff --git a/crates/app-iced/src/app/update/settings/tests/java.rs b/crates/app-iced/src/app/update/settings/tests/java.rs index 9ff9e55..c97e760 100644 --- a/crates/app-iced/src/app/update/settings/tests/java.rs +++ b/crates/app-iced/src/app/update/settings/tests/java.rs @@ -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(); +} diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs index ff6341a..e023bd4 100644 --- a/crates/app-iced/src/pages/settings/java.rs +++ b/crates/app-iced/src/pages/settings/java.rs @@ -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 { diff --git a/crates/app-iced/src/pages/settings/java/context.rs b/crates/app-iced/src/pages/settings/java/context.rs new file mode 100644 index 0000000..2bff47d --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/context.rs @@ -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, +} + +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, + /// 当前生效内存来自哪一层 + pub layer: Option, + /// 当前生效内存(三级回落之后的数值) + pub effective_memory_mb: Option, +} + +#[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 就是一个真实区间)" + ); + } +} diff --git a/crates/app-iced/src/pages/settings/java/download_view.rs b/crates/app-iced/src/pages/settings/java/download_view.rs new file mode 100644 index 0000000..1a5f76b --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/download_view.rs @@ -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::>() + .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() +} diff --git a/crates/app-iced/src/pages/settings/java/download_view/result.rs b/crates/app-iced/src/pages/settings/java/download_view/result.rs new file mode 100644 index 0000000..e956989 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/download_view/result.rs @@ -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") +} diff --git a/crates/app-iced/src/pages/settings/java/downloads.rs b/crates/app-iced/src/pages/settings/java/downloads.rs new file mode 100644 index 0000000..0535e2f --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads.rs @@ -0,0 +1,389 @@ +//! 「下载一个 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`,而 `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 engine::{gh_proxy_of_config, GhProxy}; +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, + /// 当前 / 命中的源 + pub source: Option, + /// 最近一条换源 / 退避记录 + pub last: Option, +} + +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 { + 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, +} + +/// 一次成功。 +#[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, + /// 是否完全复用了已装的那一份 + 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, + /// 这一次作业试过的源(按首次失败顺序,同一源只留一条) + pub tries: Vec, + /// 上一次失败 + pub failure: Option, + /// 上一次成功 + pub success: Option, +} + +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, + /// 下载缓存根(`/cache`,与其它下载共用) + pub cache_root: PathBuf, + /// 托管落点(`JavaInstallRoot::resolve` 的结果) + pub install_root: JavaInstallRoot, + /// 用户优先级(`java.vendorPriority`;空 = 地区默认序) + pub priority: Vec, + /// `download.gh_proxy`(`Builtin` = 跟随内置默认;`Off` = 关;`Prefix` = 覆盖前缀) + pub gh_proxy: GhProxy, +} + +/// 作业槽:快照 + 「线程还活着」。 +/// +/// 内部是 `Arc`:`State` 会被整体替换(hydrate / 截图种子),而作业线程要活得比某一次赋值更久。 +#[derive(Debug, Clone, Default)] +pub struct Slot { + inner: Arc, +} + +#[derive(Debug, Default)] +struct Inner { + report: Mutex, + 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, &request.gh_proxy); + 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, 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; + } + } +} diff --git a/crates/app-iced/src/pages/settings/java/downloads/engine.rs b/crates/app-iced/src/pages/settings/java/downloads/engine.rs new file mode 100644 index 0000000..1d77c5b --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads/engine.rs @@ -0,0 +1,110 @@ +//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例 + `download.gh_proxy` 的真消费者。 +//! +//! 从 `downloads.rs` 拆出来(行数 + 口径独立:这里只说「为什么不用共享引擎」)。 +//! 装配口径与 `core::download::shared::build` 一致,只多挂一个 `ProgressEmitter`。 +//! +//! ## gh-proxy 的取值链(任务书那句「(默认值可改)」的落点) +//! +//! | `download.gh_proxy` | [`GhProxy`] | 效果 | +//! |---|---|---| +//! | 空(出厂) | [`GhProxy::Builtin`] | 跟随内置默认:`region.json` 判成镜像优先时用 `mirrors.json` 里的 `gh-proxy.com` | +//! | `off` / `none` / `direct` / `disable` | [`GhProxy::Off`] | `RegionProfile::with_gh_proxy(None)` —— 一个 gh-proxy 候选都不生成 | +//! | 其它非空值 | [`GhProxy::Prefix`] | 用这个前缀覆盖(仍然只在镜像优先/国内判定时启用) | +//! +//! **为什么只改 `RegionProfile`**:真正决定「要不要生成 gh-proxy 候选」的是 +//! `MirrorTable::gh_proxy_candidates`,它读的是 `region.gh_proxy` 与 `region.mode`;表里的 +//! `gh_proxies` 只是「已知可用前缀」的留档(**无生产读者**)。M193 已经把 `set_gh_proxy` / +//! `default_gh_proxy` 从表上删掉了,原因正是它们改的是那张没人读的表。所以本文件只动档案, +//! 并在 `downloads/tests.rs` 里用**候选链出现该前缀**来证明它真的生效。 +//! +//! 内置默认值本身没有动(`mirrors.json` 仍是 `https://gh-proxy.com/`):空串这一档等于 +//! 「老文件没有这个键」的行为。 + +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, +}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +/// `download.gh_proxy` 的三种取值。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum GhProxy { + /// 跟随内置默认(`region.json` 的判定 + `mirrors.json` 的前缀) + #[default] + Builtin, + /// 明确关掉(`with_gh_proxy(None)`) + Off, + /// 用这个前缀覆盖 + Prefix(String), +} + +/// 设置原文 → 取值(纯函数:`off` 与 URL 的边界在这里定死,不散落在别处)。 +/// +/// `off` / `none` / `direct` / `disable` 都认(大小写不敏感、前后空白忽略):用户拿哪个词表达 +/// 「关掉」都合理,专门要求某一个反而会造出「填了没生效」的坑。 +#[must_use] +pub fn gh_proxy_from(raw: &str) -> GhProxy { + let text = raw.trim(); + if text.is_empty() { + return GhProxy::Builtin; + } + let lowered = text.to_ascii_lowercase(); + if matches!( + lowered.as_str(), + "off" | "none" | "direct" | "disable" | "disabled" | "no" + ) { + return GhProxy::Off; + } + GhProxy::Prefix(text.to_string()) +} + +/// 从 `launcher.json` 读 `download.gh_proxy`(读不到 / 没有文件 = 跟随内置默认)。 +#[must_use] +pub fn gh_proxy_of_config(config: &Path) -> GhProxy { + let store = SettingStore::load(config, launcher_schema()); + gh_proxy_from( + store + .doc() + .effective_str("download.gh_proxy") + .unwrap_or_default(), + ) +} + +/// 带进度发射器的引擎(装配口径与 `download::shared::build` 一致,只多挂一个 emitter + gh-proxy 覆盖)。 +/// +/// 为什么不再走 `shared::engine_for`:那个登记处交出的是 `Arc`,而挂进度要 +/// 拿走引擎(`with_progress(self)`)。要让共享引擎带「按作业」的观察者,得改引擎内部的 +/// `progress` 字段为观察者名单——那是引擎侧(M193 的镜像/并发那摊)的范围。 +/// 代价:一次 JDK 安装有自己的连接池;**盘上的** `health.json` / `index.json` / `region.json` +/// 是同一个,所以换源经验仍然跨作业累积。 +pub(super) fn engine_with( + cache_root: &Path, + progress: ProgressEmitter, + gh_proxy: &GhProxy, +) -> 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 + }); + let profile = match gh_proxy { + GhProxy::Builtin => profile, + GhProxy::Off => profile.with_gh_proxy(None), + GhProxy::Prefix(prefix) => profile.with_gh_proxy(Some(prefix)), + }; + 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) +} diff --git a/crates/app-iced/src/pages/settings/java/downloads/recommend.rs b/crates/app-iced/src/pages/settings/java/downloads/recommend.rs new file mode 100644 index 0000000..9085b84 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads/recommend.rs @@ -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 { + 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), + )) +} diff --git a/crates/app-iced/src/pages/settings/java/downloads/refusal.rs b/crates/app-iced/src/pages/settings/java/downloads/refusal.rs new file mode 100644 index 0000000..a281c99 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads/refusal.rs @@ -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, + } + } +} diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests.rs b/crates/app-iced/src/pages/settings/java/downloads/tests.rs new file mode 100644 index 0000000..f7b6a59 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads/tests.rs @@ -0,0 +1,313 @@ +//! 「下载一个 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(), + gh_proxy: super::GhProxy::Builtin, + })); + // 失败/panic 也要清理:这条用例会真下 ~180 MB,留在系统 temp 里是最贵的垃圾。 + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(dir.clone()); + + 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 + ); +} + +/// 失败要留档:分类 + 大版本 + 最后试到的源(界面靠这三样说清「为什么没装成」)。 +/// +/// 变异证明:把 `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(); + /// 一条用例:标签 / key / 渲染参数。 + type Case<'a> = (&'a str, Key, &'a [(&'a str, &'a str)]); + + let cases: [Case<'_>; 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; + } + } +} + +/// `download.gh_proxy` 的两条守卫(M197):取值映射 + 真消费者。拆到子模块守 400 行硬线。 +#[path = "tests/gh_proxy.rs"] +mod gh_proxy; diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs b/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs new file mode 100644 index 0000000..5684af7 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs @@ -0,0 +1,118 @@ +//! `download.gh_proxy` 的两条守卫(M197):取值映射 + 「真的接上了」。 +//! +//! 从 `downloads/tests.rs` 拆出来守 400 行硬线(拆法与 `agents::tests` 一致:断言跟着它守的 +//! 东西走)。这里只说一件事:设置值必须**改变候选链**,而不只是被读出来。 + +// --------------------------------------------------------------- P2-3:`download.gh_proxy` 的取值与真消费者 + +/// 设置原文 → 三态:空 = 跟随内置默认,`off` 一族 = 关,其余 = 覆盖前缀。 +/// +/// 变异证明:删掉 `gh_proxy_from` 里那段 `matches!(lowered, "off" | …)` ⇒ 本用例红 +/// (`off` 会被当成一个前缀 `off`,拼出 `offhttps://…` 这种 URL)。 +#[test] +fn the_gh_proxy_setting_maps_to_three_states() { + use crate::pages::settings::java::downloads::engine::gh_proxy_from; + use crate::pages::settings::java::downloads::GhProxy; + + assert_eq!(gh_proxy_from(""), GhProxy::Builtin, "空 = 跟随内置默认"); + assert_eq!(gh_proxy_from(" "), GhProxy::Builtin); + for off in [ + "off", "OFF", " none ", "direct", "Disable", "disabled", "no", + ] { + assert_eq!(gh_proxy_from(off), GhProxy::Off, "{off} 应表示关掉"); + } + assert_eq!( + gh_proxy_from(" https://gh.example/ "), + GhProxy::Prefix("https://gh.example/".to_string()), + "前后空白要去掉,值原样保留" + ); +} + +/// **真消费者**:`download.gh_proxy` 落盘 → 引擎档案 → **候选链里真的出现该前缀**。 +/// +/// 三步都要成立才算「接了」:① 键读得出来;② 档案被覆盖;③ `expand()` 用它生成候选。 +/// 只断言 ① 会漏掉「读了但没用」(本 mission 修的就是这一类),只断言 ② 会漏掉「expand 不认 +/// 档案里的前缀」。 +/// +/// 变异证明:让 `engine_with` 忽略 `gh_proxy` 参数(`let profile = profile;`)⇒ ②③ 两条红; +/// 把 `gh_proxy_of_config` 写成恒 `Builtin` ⇒ ① 先红。 +#[test] +fn the_gh_proxy_setting_reaches_the_candidate_chain() { + use crate::pages::settings::java::downloads::engine::engine_with; + use crate::pages::settings::java::downloads::{gh_proxy_of_config, GhProxy}; + use unlucky_core::download::cache::now_ms; + use unlucky_core::download::region::{store_cached, RegionProfile}; + use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + use unlucky_core::download::ProgressEmitter; + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + + let dir = std::env::temp_dir().join(format!("ull-gh-proxy-{}-{}", std::process::id(), line!())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("cache")).expect("cache"); + std::fs::create_dir_all(dir.join("config")).expect("config"); + let config = dir.join("config").join("launcher.json"); + + // ① 键读得出来 + let mut store = SettingStore::load(&config, launcher_schema()); + store + .set( + "download.gh_proxy", + "https://gh.example/".to_string().into(), + ) + .expect("schema 认识 download.gh_proxy"); + store.save().expect("写盘"); + assert_eq!( + gh_proxy_of_config(&config), + GhProxy::Prefix("https://gh.example/".to_string()) + ); + + // region.json 钉成镜像优先:否则 gh-proxy 候选本来就不生成,测不出「设了有没有用」。 + let cache = dir.join("cache"); + store_cached( + &cache.join("region.json"), + &RegionProfile::mirror_first(now_ms()), + ) + .expect("写 region.json"); + + let engine = engine_with( + &cache, + ProgressEmitter::silent(), + &gh_proxy_of_config(&config), + ); + // ② 档案被覆盖 + assert_eq!( + engine.region().gh_proxy.as_deref(), + Some("https://gh.example/"), + "设置值必须覆盖档案里的前缀" + ); + + // ③ 候选链里真的出现这个前缀 + let request = DownloadRequest::new( + ResourceKind::Library, + "https://github.com/owner/repo/releases/download/v1/a.jar".to_string(), + dir.join("a.jar"), + ); + let plan = engine.plan(&request, 0); + assert!( + plan.iter() + .any(|candidate| candidate.url.starts_with("https://gh.example/")), + "gh-proxy 候选没出现在候选链里:{:?}", + plan.iter() + .map(|candidate| candidate.url.clone()) + .collect::>() + ); + + // 关掉那一档:一个代理候选都不生成(同一份 region.json,只换设置值)。 + let off = engine_with(&cache, ProgressEmitter::silent(), &GhProxy::Off); + assert_eq!(off.region().gh_proxy, None, "off 必须把前缀清掉"); + let plan = off.plan(&request, 0); + assert!( + !plan + .iter() + .any(|candidate| candidate.url.starts_with("https://gh.example/")), + "关掉之后不该再有 gh-proxy 候选" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/app-iced/src/pages/settings/java/fields.rs b/crates/app-iced/src/pages/settings/java/fields.rs index 66d9ad9..145f3e5 100644 --- a/crates/app-iced/src/pages/settings/java/fields.rs +++ b/crates/app-iced/src/pages/settings/java/fields.rs @@ -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, diff --git a/crates/app-iced/src/pages/settings/java/prefs.rs b/crates/app-iced/src/pages/settings/java/prefs.rs new file mode 100644 index 0000000..1c3c68c --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/prefs.rs @@ -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, +} + +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 { + self.priority.iter().map(|id| (*id).to_string()).collect() + } + + /// 草稿的优先级链 → 有效顺序(`vendor::order` 的补集追加语义,与真正下载时同源)。 + #[must_use] + pub fn order(&self, domestic: bool) -> Vec { + let parsed = self + .priority + .iter() + .filter_map(|id| JdkVendor::parse(id)) + .collect::>(); + 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"); + } +} diff --git a/crates/app-iced/src/pages/settings/java/prefs_store.rs b/crates/app-iced/src/pages/settings/java/prefs_store.rs new file mode 100644 index 0000000..f33cf96 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/prefs_store.rs @@ -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 { + 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(LauncherLayout::new); + 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::>(); + 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`、同一套信号。 +/// 读不到缓存目录(配置路径不像 `/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 { + 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() +} + +/// 下载引擎的缓存根(`/cache`):让 JDK 归档与其它下载共用同一份缓存 / 健康度。 +#[must_use] +pub fn cache_root(config: &Path) -> Option { + home_of(config).map(|home| LauncherLayout::new(home).cache()) +} + +/// launcher 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(), + } +} diff --git a/crates/app-iced/src/pages/settings/java/prefs_view.rs b/crates/app-iced/src/pages/settings/java/prefs_view.rs new file mode 100644 index 0000000..108552c --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/prefs_view.rs @@ -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::>() + .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() +} diff --git a/crates/app-iced/src/pages/settings/java/seed.rs b/crates/app-iced/src/pages/settings/java/seed.rs index 4f59203..7d99862 100644 --- a/crates/app-iced/src/pages/settings/java/seed.rs +++ b/crates/app-iced/src/pages/settings/java/seed.rs @@ -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 { 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, + } +} diff --git a/crates/app-iced/src/pages/settings/java/state.rs b/crates/app-iced/src/pages/settings/java/state.rs index 62e1bb9..6c12b7f 100644 --- a/crates/app-iced/src/pages/settings/java/state.rs +++ b/crates/app-iced/src/pages/settings/java/state.rs @@ -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, -} - -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, - /// 当前生效内存来自哪一层 - pub layer: Option, - /// 当前生效内存(三级回落之后的数值) - pub effective_memory_mb: Option, -} - /// 保存 / 操作的回执。 #[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, /// 当前实例的上下文(版本门槛 / 生效内存层级) 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, + /// 已经处理过的那份下载快照(用来判「刚成功」——装完要重扫一次并把新 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) -> Option { + pub fn fit(&self, major: Option) -> Option { 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 { + self.prefs + .saved_priority + .iter() + .filter_map(|id| unlucky_core::game::java::JdkVendor::parse(id)) + .collect() + } + /// 现在能不能点保存。 #[must_use] pub fn can_save(&self) -> bool { diff --git a/crates/app-iced/src/pages/settings/java/store.rs b/crates/app-iced/src/pages/settings/java/store.rs index b5a795d..1102bc0 100644 --- a/crates/app-iced/src/pages/settings/java/store.rs +++ b/crates/app-iced/src/pages/settings/java/store.rs @@ -298,7 +298,7 @@ fn finish(mut store: SettingStore, config: &Path) -> Result Unwritable { +pub(super) fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable { use unlucky_core::setting::store::SkipReason; match reason { SkipReason::NotDirty => Unwritable::NotDirty, diff --git a/crates/app-iced/src/pages/settings/java/tests.rs b/crates/app-iced/src/pages/settings/java/tests.rs index 5df6fe9..37a88f5 100644 --- a/crates/app-iced/src/pages/settings/java/tests.rs +++ b/crates/app-iced/src/pages/settings/java/tests.rs @@ -240,3 +240,6 @@ fn the_memory_bounds_mirror_the_schema() { } #[path = "tests/runtime.rs"] mod runtime; + +#[path = "tests/prefs.rs"] +mod prefs; diff --git a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs index 882bcc2..830d363 100644 --- a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs +++ b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs @@ -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`,第二、三档立刻红(每次心跳都发起扫描); diff --git a/crates/app-iced/src/pages/settings/java/tests/prefs.rs b/crates/app-iced/src/pages/settings/java/tests/prefs.rs new file mode 100644 index 0000000..e825376 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/prefs.rs @@ -0,0 +1,140 @@ +//! 三个设置键的真读写(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); + + // 前后带空格:落盘值会被 trim,回读的草稿必须显示**磁盘上那份**。 + let mut prefs = Prefs { + install_root: " D:/JDKs ".to_string(), + auto_install: false, + priority: vec!["zulu", "temurin"], + ..Prefs::default() + }; + 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(), "被本地校验拒掉的保存不许碰磁盘"); +} diff --git a/crates/app-iced/src/pages/settings/java/view.rs b/crates/app-iced/src/pages/settings/java/view.rs index 944c022..978c3a4 100644 --- a/crates/app-iced/src/pages/settings/java/view.rs +++ b/crates/app-iced/src/pages/settings/java/view.rs @@ -53,6 +53,12 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> stacked = stacked.push(runtime_list(ui, catalog, state)); stacked = stacked.push(divider(ui)); stacked = stacked.push(add_row(ui, catalog, state)); + // M197:真下载一个 JDK(版本选择列表 / 下载中 / 失败三态都在这一块里)。 + stacked = stacked.push(divider(ui)); + stacked = stacked.push(super::download_view::section(ui, catalog, state)); + // M197:三个设置键(安装目录 / 发行版顺序 / 缺 JDK 时自动准备)。 + stacked = stacked.push(divider(ui)); + stacked = stacked.push(super::prefs_view::section(ui, catalog, state)); stacked = stacked.push(divider(ui)); stacked = stacked.push(memory_field(ui, catalog, state)); stacked = stacked.push(jvm_field(ui, catalog, state)); @@ -197,25 +203,27 @@ fn runtime_row<'a>( runtime: &'a RuntimeRow, ) -> Element<'a, Message> { let is_default = super::state::same_path(&runtime.binary, &state.selected); - let satisfied = state.satisfied(runtime.major); - - let (verdict, verdict_color) = match satisfied { - Some(true) => ( + // M197:**带方向**的三态。M175 的布尔判定对「1.12.2 + Java 21」会说「低于要求:需要 Java 8 + // 及以上」——21 当然 ≥ 8,那句话自相矛盾;现在两个方向各说各的。 + let (verdict, verdict_color) = match state.fit(runtime.major) { + Some(super::state::Fit::Ok) => ( catalog.text(Key::SettingsJavaFits).to_string(), ui.color(Role::Primary), ), - Some(false) => { - let required = state - .context - .required - .as_ref() - .map(|required| required.minimum.to_string()) - .unwrap_or_default(); - ( - catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]), - ui.color(Role::Error), - ) - } + Some(super::state::Fit::TooOld { required, .. }) => ( + catalog.render( + Key::SettingsJavaTooOld, + &[("minimum", required.to_string().as_str())], + ), + ui.color(Role::Error), + ), + Some(super::state::Fit::TooNew { maximum, .. }) => ( + catalog.render( + Key::SettingsJavaTooNew, + &[("maximum", maximum.to_string().as_str())], + ), + ui.color(Role::Error), + ), None => (String::new(), ui.color(Role::OnSurfaceVariant)), }; diff --git a/crates/core/src/download/bytes.rs b/crates/core/src/download/bytes.rs index c0a1fc0..f405402 100644 --- a/crates/core/src/download/bytes.rs +++ b/crates/core/src/download/bytes.rs @@ -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, diff --git a/crates/core/src/download/engine.rs b/crates/core/src/download/engine.rs index 0063bb1..e782e78 100644 --- a/crates/core/src/download/engine.rs +++ b/crates/core/src/download/engine.rs @@ -11,9 +11,8 @@ //! → 失败则保留中间文件供下次续传 //! ``` //! -//! 中间文件是本会话命名的(`.ullpart--`),下次(甚至下次启动)由 -//! `PartFile::for_dest` 认领并改名,续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在 -//! `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。 +//! 中间文件是本会话命名的(`.ullpart--`),下次(甚至下次启动)由 `PartFile::for_dest` 认领并改名, +//! 续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。 use std::path::Path; use std::sync::Arc; @@ -268,6 +267,7 @@ impl DownloadEngine { self.emit(ProgressEvent::SourceFailed { id, source: candidate.source.clone(), + kind: err.kind, reason: err.message.clone(), attempt, will_retry: retry, diff --git a/crates/core/src/download/progress.rs b/crates/core/src/download/progress.rs index 4dea5f0..e63b722 100644 --- a/crates/core/src/download/progress.rs +++ b/crates/core/src/download/progress.rs @@ -45,6 +45,11 @@ pub enum ProgressEvent { id: DownloadId, /// 源标识。 source: String, + /// 失败分类(**结构化**:文案层按这个类型选 i18n key,`reason` 只进日志)。 + /// + /// M197 补的:在这之前只有 `reason`(core 的中文句子),GUI 要么放弃双语、 + /// 要么放弃「命中哪个源 / 为什么退避」这一条用户可见信息。 + kind: DownloadErrorKind, /// 失败原因(短描述)。 reason: String, /// 第几次尝试(从 0 起)。 diff --git a/crates/core/src/game/java/catalog.rs b/crates/core/src/game/java/catalog.rs new file mode 100644 index 0000000..bd76bc3 --- /dev/null +++ b/crates/core/src/game/java/catalog.rs @@ -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 { + 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 { + 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 { + 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, "超出目录时退回基准"); + } +} diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs index 1b93f57..046128a 100644 --- a/crates/core/src/game/java/mod.rs +++ b/crates/core/src/game/java/mod.rs @@ -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, }; diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index be443ec..97a74b4 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -37,8 +37,10 @@ pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; /// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。 pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"]; -/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)。主表各留一行登记。 +/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)/ 代理与 gh-proxy(M197)。 +/// 主表各留一行登记。 pub mod font; +pub mod proxy; pub mod update; /// launcher.json 当前 schema 版本。 @@ -55,6 +57,8 @@ pub mod update; /// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移 /// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。 /// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。 +/// `1.8.0` 登记 M197 的 gh-proxy 前缀覆盖键(`download.gh_proxy`):同样纯增量——出厂值是空串, +/// 语义正是「老文件没有这个键」时的行为(跟随内置镜像表的默认前缀)。 /// /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → @@ -64,7 +68,7 @@ pub mod update; /// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 7, 0) + SchemaVersion::new(1, 8, 0) } /// `config/launcher.json` 的 schema。 @@ -130,20 +134,9 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::audited(), "镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)", )) - .with(SettingSpec::enumeration( - "download.proxy_mode", - "none", - PROXY_MODES, - AiPolicy::sensitive(), - "代理模式:none / system / manual", - )) - .with(SettingSpec::text( - "download.proxy_url", - "", - 512, - AiPolicy::sensitive(), - "manual 代理地址(如 http://127.0.0.1:7890)", - )) + // M197:代理与 gh-proxy 三键(`download.proxy_mode` / `proxy_url` / `gh_proxy`)搬到 + // [`proxy`]——主表这一段的注释也一起搬过去,见那个模块的表格。 + .with_all(proxy::specs()) .with(SettingSpec::path( "java.selected_path", AiPolicy::sensitive(), @@ -153,7 +146,10 @@ pub fn launcher_schema() -> SettingSchema { "java.auto_install", true, AiPolicy::audited(), - "缺 Java 时自动下载(D13 基准 JDK17)", + // M197:这句会进 AI 工具的 JSON Schema `description`(`catalog.rs` 直接塞 `spec.doc`), + // 所以必须写**实现真做的事**:缺 Java 时在 Java 页标出推荐版本并可一键安装, + // 不会自己开始下载(是否下载由用户点「安装」决定)。 + "缺 Java 时在 Java 页标出推荐版本并可一键安装;不会自动下载", )) // D23:托管 JDK 的落点三模式。语义在 `game::java::location`: // 未设置(空串)→ 按 `core.data_mode` 派生(Portable 数据根 / Standard 系统目录); diff --git a/crates/core/src/setting/keys/proxy.rs b/crates/core/src/setting/keys/proxy.rs new file mode 100644 index 0000000..9608c73 --- /dev/null +++ b/crates/core/src/setting/keys/proxy.rs @@ -0,0 +1,77 @@ +//! 代理与 gh-proxy 三键(M197 从 `keys.rs` 拆出:那边正好贴着 400 行硬线)。 +//! +//! 三个键是一件事的两层:前两个是 **HTTP 代理**(整条请求走代理),第三个是 **gh-proxy 前缀** +//! (只把 GitHub 上的 URL 改写成 ``,其余流量直连)。放在一起是因为用户问的往往是 +//! 「我下不动 GitHub 怎么办」,答案要么在这三个里面、要么在镜像表里。 +//! +//! | 键 | 语义 | 谁消费 | +//! |---|---|---| +//! | `download.proxy_mode` | `none` / `system` / `manual` | 引擎的 HTTP 客户端(`http::build_client`) | +//! | `download.proxy_url` | manual 模式的代理地址 | 同上 | +//! | `download.gh_proxy` | 空 = 跟随内置默认;`off` = 关;其它 = 覆盖前缀 | `app-iced` 的 JDK 下载作业(M197),其它下载走同一份 `region.json` | +//! +//! `download.gh_proxy` 的语义细节在 `core::download::region`:**空串不等于关**——空串是「没有 +//! 覆盖」,仍按地区判定与内置镜像表走(国内判成镜像优先时用 `gh-proxy.com`)。要关必须写 `off`, +//! 因为「我不想用加速」和「我还没决定」是两件事。 + +use crate::setting::spec::{AiPolicy, SettingSpec}; + +use super::PROXY_MODES; + +/// 三个键的完整登记项(主表一行 `.with_all(proxy::specs())`)。 +#[must_use] +pub fn specs() -> Vec { + vec![ + SettingSpec::enumeration( + "download.proxy_mode", + "none", + PROXY_MODES, + AiPolicy::sensitive(), + "代理模式:none / system / manual", + ), + SettingSpec::text( + "download.proxy_url", + "", + 512, + AiPolicy::sensitive(), + "manual 代理地址(如 http://127.0.0.1:7890)", + ), + // M197:gh-proxy 前缀的用户覆盖(任务书那句「(默认值可改)」的落点)。 + SettingSpec::text( + "download.gh_proxy", + "", + 256, + AiPolicy::sensitive(), + "GitHub 下载的加速前缀(gh-proxy)。留空 = 跟随内置默认(镜像表里的 gh-proxy.com,仅镜像优先/国内判定时启用);填 off 关闭;填一个 URL 前缀则覆盖它", + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 三个键都在、且**空串 ≠ 关**这条语义写在描述里(M197 复审要求它与行为一致)。 + /// + /// 变异证明:把 `gh_proxy` 的描述改成「留空 = 关闭」⇒ 本用例红(实现里空串是「跟随内置默认」, + /// `downloads::engine::gh_proxy_from("")` 返回 `Builtin` 而不是 `Off`)。 + #[test] + fn the_gh_proxy_description_says_empty_is_not_off() { + let specs = specs(); + let ids: Vec<&str> = specs.iter().map(|spec| spec.key).collect(); + assert_eq!( + ids, + vec![ + "download.proxy_mode", + "download.proxy_url", + "download.gh_proxy" + ] + ); + let gh = specs + .iter() + .find(|spec| spec.key == "download.gh_proxy") + .expect("gh_proxy 已登记"); + assert!(gh.doc.contains("留空 = 跟随内置默认"), "{}", gh.doc); + assert!(gh.doc.contains("填 off 关闭"), "{}", gh.doc); + } +} diff --git a/crates/core/tests/download/engine_mock/not_found.rs b/crates/core/tests/download/engine_mock/not_found.rs new file mode 100644 index 0000000..13cc50c --- /dev/null +++ b/crates/core/tests/download/engine_mock/not_found.rs @@ -0,0 +1,67 @@ +//! `SourceFailed.kind` 的另一半(M197 复审 P2-2):**404 是「该源没有这个文件」**,不是「源坏了」。 +//! +//! 两档在引擎里的后果不同:`NotFound` 走 `record_missing`(不冷却源),`ServerError` 走 +//! `record_failure`。院校镜像只留最新补丁,请求旧补丁必然 404——判成「源坏了」会把唯一可用镜像 +//! 冷却掉(M33/M34 实测)。 +//! +//! 变异证明:把 `engine.rs` / `bytes.rs` 的 `kind: err.kind` 改成常量 `Network` ⇒ 本条与 +//! `reports_progress_events_and_errors_when_all_sources_fail` 一起红。 +//! +//! 拆成子模块:`engine_mock_test.rs` 加了这条之后 407 行,越过 400 行硬线(`guards.yml` 的 +//! `file-size`),于是把整条用例搬出来。 + +use std::sync::Arc; + +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::error::DownloadErrorKind; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent}; +use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + +use super::support::{temp_dir, MockResponse, MockServer}; +use super::{direct_region, fast_config}; + +#[tokio::test] +async fn a_404_is_classified_as_not_found() { + let server = MockServer::start(vec![ + MockResponse::status(404), + MockResponse::status(404), + MockResponse::status(404), + MockResponse::status(404), + ]) + .await; + let url = server.url("/gone.jar"); + let dir = temp_dir("engine-404"); + let (emitter, mut events) = ProgressEmitter::channel(); + let engine = DownloadEngine::new( + fast_config(1), + Arc::new(MirrorTable::builtin()), + Arc::new(direct_region()), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ) + .with_progress(emitter); + engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("gone.jar"), + )) + .await + .expect_err("只有 404 的源必须失败"); + + let mut seen: Vec = Vec::new(); + while let Ok(event) = events.try_recv() { + if let ProgressEvent::SourceFailed { kind, .. } = event { + seen.push(kind); + } + } + assert!(!seen.is_empty(), "至少要有一条 SourceFailed"); + assert!( + seen.iter().all(|kind| *kind == DownloadErrorKind::NotFound), + "404 必须分类成 NotFound,实际:{seen:?}" + ); + server.stop(); +} diff --git a/crates/core/tests/download/engine_mock_test.rs b/crates/core/tests/download/engine_mock_test.rs index 528e664..30604c4 100644 --- a/crates/core/tests/download/engine_mock_test.rs +++ b/crates/core/tests/download/engine_mock_test.rs @@ -338,9 +338,19 @@ async fn reports_progress_events_and_errors_when_all_sources_fail() { started = true; assert!(candidates >= 1); } - ProgressEvent::SourceFailed { will_retry, .. } => { + ProgressEvent::SourceFailed { + will_retry, kind, .. + } => { failed_source = true; assert!(!will_retry, "单次尝试用尽后不应再声明重试"); + // M197:`kind` 是诊断字段,必须来自**真实**的失败分类(HTTP 500 → ServerError)。 + // 变异证明:把 `engine.rs` 的 `kind: err.kind` 写成常量 + // `DownloadErrorKind::Network` ⇒ 本断言红。 + assert_eq!( + kind, + DownloadErrorKind::ServerError, + "HTTP 500 必须被分类成 ServerError(诊断字段不许是常量)" + ); } _ => {} } @@ -348,3 +358,7 @@ async fn reports_progress_events_and_errors_when_all_sources_fail() { assert!(started && failed_source, "进度事件缺失"); server.stop(); } + +/// 「404 不是源坏了」的守卫(M197 P2-2 的第二档):拆到子模块守 400 行硬线。 +#[path = "engine_mock/not_found.rs"] +mod not_found; diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index 3eb5910..fc59988 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() { fn schema_registration_shape_is_right() { assert_eq!( launcher_version().minor, - 7, - "1.7.0 登记 M168 的 ui.font_cdn(1.6.0 是 M161 的自更新两键)" + 8, + "1.8.0 登记 M197 的 download.gh_proxy(1.7.0 是 M168 的 ui.font_cdn)" ); let schema = launcher_schema(); assert_eq!( diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index 60d98a9..d628dab 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -128,9 +128,10 @@ fn overrides_key_is_an_enum_whitelist() { #[test] fn schema_versions_register_the_new_keys_without_migration() { - // 1.7.0 登记 M168 的 `ui.font_cdn`(1.6.0 登记 M161 的自更新两键,1.5.0 = M116 窗口圆角, - // 1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,1.2.0 = D23 的 JDK 落点/优先级键)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); + // 1.8.0 登记 M197 的 `download.gh_proxy`(1.7.0 = M168 的 `ui.font_cdn`,1.6.0 登记 M161 的 + // 自更新两键,1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」, + // 1.2.0 = D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index 0168f55..2cc7d4a 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -66,10 +66,10 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() { #[test] fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { - // 1.7.0 = M168 的中文字体下载地址覆盖键(`ui.font_cdn`;此前 1.6.0 是 M161 的自更新两键, - // 1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,不是巧合: - // 老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); + // 1.8.0 = M197 的 gh-proxy 覆盖键(`download.gh_proxy`;此前 1.7.0 是 M168 的 `ui.font_cdn`, + // 1.6.0 是 M161 的自更新两键,1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**, + // 不是巧合:老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0)); assert_eq!( launcher_version().major, 1, @@ -120,3 +120,31 @@ fn an_old_config_file_without_the_new_keys_still_loads() { ); let _ = std::fs::remove_dir_all(&dir); } + +/// `java.auto_install` 的 schema 描述必须与实现一致:**不会**自动下载。 +/// +/// 这句会进 AI 工具的 JSON Schema `description`(`setting/catalog.rs` 把 `spec.doc` 原样塞进去), +/// 所以它不是注释而是**对外文案**:写「缺 Java 时自动下载」等于让启动器自己的助手对用户说 +/// 一件实现刻意不做的事(M197 复审 r1 的 P1)。 +/// +/// 变异证明:把描述改回「缺 Java 时自动下载(D13 基准 JDK17)」⇒ 本用例红(三句断言都会红)。 +#[test] +fn the_auto_install_description_says_it_never_downloads_by_itself() { + let schema = launcher_schema(); + let doc = schema + .spec("java.auto_install") + .expect("java.auto_install 必须登记") + .doc; + assert!( + doc.contains("不会自动下载"), + "描述必须写明「不会自动下载」(AI 会照这句话告诉用户):{doc}" + ); + assert!( + !doc.contains("缺 Java 时自动下载"), + "描述不许再写「缺 Java 时自动下载」:{doc}" + ); + assert!( + !doc.contains("D13"), + "描述里不许出现内部决策编号(用户与助手都不知道 D13 是什么):{doc}" + ); +} diff --git a/crates/i18n/src/catalog/font_install.rs b/crates/i18n/src/catalog/font_install.rs index e4056e1..d389514 100644 --- a/crates/i18n/src/catalog/font_install.rs +++ b/crates/i18n/src/catalog/font_install.rs @@ -62,7 +62,7 @@ macro_rules! font_install_entries { "无法探测系统字体,因此没有下载" ), FontCjkStatusHint => ( - "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with `ui.font_cdn`.", + "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。" ), } } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index ddc258a..3c439c1 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -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 usize { mod tail; mod tail2; +mod tail_ai; diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index d21b601..5a64043 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -1,8 +1,13 @@ -//! 金表的**后半段**(`InstancesLaunchReset` 之后的全部行)。 +//! 金表的**后半段的前半截**(`InstancesLaunchReset` 之后、到 M197 的 Java 域结束)。 //! //! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线; //! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。 //! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。 +//! +//! **M197 起本段只到 Java 域结束**:M197 往 `settings_java_entries` 追加了 68 行,加上 dev 侧 +//! 其它 mission 后加的行会涨到 470+(越过 400 行硬线),于是把 **M174 助手域起的那半截** +//! 纯搬移到 [`super::tail_ai`](链序:`golden` → `tail` → `tail_ai` → `tail2`)。 +//! 搬移只改位置:其余每一行逐字节原样(含其它 mission 后加的行)。 pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("LoaderVanilla", "Vanilla", "原版"), ("LaunchOutcomeNormal", "Finished normally", "正常结束"), @@ -88,9 +93,9 @@ 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)。在那之前启动器不会自己去扫。"), + ("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` 装一个。"), + ("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", "满足当前实例的要求"), @@ -103,7 +108,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)"), @@ -135,264 +140,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", "选择实例"), - ("CrashDialogTitleFailed", "Launch failed", "启动失败"), - ("CrashDialogTitleCrashed", "The game crashed", "游戏崩溃"), - ("CrashDialogExitCode", "Exit code {code}", "退出码 {code}"), - ("CrashDialogStartedThenDied", "The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"), - ("CrashDialogSilentFailure", "The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"), - ("CrashDialogStageReason", "It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"), - ("CrashDialogCrashReportFound", "The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"), - ("CrashDialogNoGameLog", "No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"), - ("CrashDialogNoLogAtAll", "No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"), - ("CrashDialogShowDetail", "Show crash details", "展开崩溃说明"), - ("CrashDialogHideDetail", "Hide crash details", "收起崩溃说明"), - ("CrashDialogLauncherLogs", "Launcher logs", "启动器日志"), - ("CrashDialogSessionLog", "This session", "本次会话"), - ("CrashDialogGameLog", "Game latest.log", "游戏 latest.log"), - ("CrashDialogCrashReports", "Crash reports", "崩溃报告目录"), - ("CrashDialogCapturedTail", "CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"), - ("CrashDialogExport", "Export report bundle", "导出诊断包"), - ("CrashDialogOpenFolder", "Open log folder", "打开日志文件夹"), - ("CrashDialogCopyPaths", "Copy file paths", "复制文件路径"), - ("CrashDialogClose", "Close", "关闭"), - ("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"), - ("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), - ("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"), - ("CrashDialogExportTruncated", "{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"), - ("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"), - ("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", "下载"), - ("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 来源的安装尚未开放"), - ("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"), - ("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"), - ("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"), - ("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"), - ("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"), - ("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"), + ("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", "Recommended baseline", "推荐基准版本"), + ("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 ///.", "留空 = 跟数据目录模式走:portable 放在启动器目录里,standard 放在系统 Java 目录。填了它覆盖前两者,JDK 落在 /<发行方>/<版本>/。"), + ("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", "Recommend 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. Nothing is downloaded on its own — you press Install. Off: nothing is marked and 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", "目录路径太长"), ]; diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 2daa388..a9621ec 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -232,5 +232,5 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"), ("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"), ("FontCjkStatusUnprobeable", "System fonts cannot be probed, so nothing was downloaded", "无法探测系统字体,因此没有下载"), - ("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with `ui.font_cdn`.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), + ("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), ]; diff --git a/crates/i18n/src/catalog/golden/tail_ai.rs b/crates/i18n/src/catalog/golden/tail_ai.rs new file mode 100644 index 0000000..4a120dc --- /dev/null +++ b/crates/i18n/src/catalog/golden/tail_ai.rs @@ -0,0 +1,274 @@ +//! 金表的**第四段**(旧 `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`)。 +//! +//! 正文是 **dev 侧 `tail.rs` 从 M174 助手域起** 的全部行(含其它 mission 后加的行), +//! 逐字节原样搬来;本段只负责把它们放到 `Key::ALL` 的同一位置。 +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", "选择实例"), + ("CrashDialogTitleFailed", "Launch failed", "启动失败"), + ("CrashDialogTitleCrashed", "The game crashed", "游戏崩溃"), + ("CrashDialogExitCode", "Exit code {code}", "退出码 {code}"), + ("CrashDialogStartedThenDied", "The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"), + ("CrashDialogSilentFailure", "The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"), + ("CrashDialogStageReason", "It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"), + ("CrashDialogCrashReportFound", "The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"), + ("CrashDialogNoGameLog", "No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"), + ("CrashDialogNoLogAtAll", "No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"), + ("CrashDialogShowDetail", "Show crash details", "展开崩溃说明"), + ("CrashDialogHideDetail", "Hide crash details", "收起崩溃说明"), + ("CrashDialogLauncherLogs", "Launcher logs", "启动器日志"), + ("CrashDialogSessionLog", "This session", "本次会话"), + ("CrashDialogGameLog", "Game latest.log", "游戏 latest.log"), + ("CrashDialogCrashReports", "Crash reports", "崩溃报告目录"), + ("CrashDialogCapturedTail", "CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"), + ("CrashDialogExport", "Export report bundle", "导出诊断包"), + ("CrashDialogOpenFolder", "Open log folder", "打开日志文件夹"), + ("CrashDialogCopyPaths", "Copy file paths", "复制文件路径"), + ("CrashDialogClose", "Close", "关闭"), + ("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"), + ("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"), + ("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"), + ("CrashDialogExportTruncated", "{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"), + ("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"), + ("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", "下载"), + ("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 来源的安装尚未开放"), + ("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"), + ("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"), + ("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"), + ("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"), + ("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"), + ("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"), +]; diff --git a/crates/i18n/src/catalog/settings/java.rs b/crates/i18n/src/catalog/settings/java.rs index 40e0c53..b38aeae 100644 --- a/crates/i18n/src/catalog/settings/java.rs +++ b/crates/i18n/src/catalog/settings/java.rs @@ -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 => ("Recommended baseline", "推荐基准版本"), + 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 ///.", + "留空 = 跟数据目录模式走:portable 放在启动器目录里,standard 放在系统 Java 目录。填了它覆盖前两者,JDK 落在 /<发行方>/<版本>/。" + ), + 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 => ("Recommend 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. Nothing is downloaded on its own — you press Install. Off: nothing is marked and 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", + "目录路径太长" + ), } } }; } diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index 5c137cc..db2874f 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -201,3 +201,52 @@ fn split_keeps_every_translation() { assert_eq!(got.2, expected.2, "{} 的中文译文变了", expected.0); } } + +/// 用户可见文案里**不许**出现内部决策编号(`(D13)` / `(M175)` 这类)。 +/// +/// 为什么要在目录这一层守:`(D13)` 对用户没有任何意义(他不知道 D13 是什么),而这类串一旦 +/// 混进来就再没人记得删。M197 出图时实测到下载目录的基准版本徽标写着 `基准版本(D13)`。 +/// +/// 变异证明:把 `SettingsJavaDownloadBaseline` 的译文改回 `Baseline (D13)` / `基准版本(D13)` +/// ⇒ 本用例红(中英两侧都扫)。判据是 `(`/`(` + `D`/`M` + 数字,不误伤 `(404)` / `(5xx / 429)` +/// 这类真对用户有意义的括号短语。 +#[test] +fn no_user_visible_string_mentions_an_internal_decision_id() { + for (key, en, zh) in super::golden::rows().copied() { + for (side, text) in [("en", en), ("zh-CN", zh)] { + assert!( + !mentions_decision_id(text), + "{key} 的{side}译文里出现了内部决策编号:{text}" + ); + } + } +} + +/// 用户可见文案里**不许**出现 markdown 反引号。 +/// +/// 渲染层没有内联代码通道(M175 r3 复审已核实 `RichText|markdown|Span::|spans(` 全树只命中 +/// 进度条与注释),反引号会原样上屏。M175 修过三处、M197 又修了三处;M168 的 +/// `FontCjkStatusHint` 是最后一条(M197 复审 r1 指出的),这里一并钉住。 +/// +/// 变异证明:给任意一条译文加一对反引号 ⇒ 本用例红。 +#[test] +fn no_user_visible_string_carries_markdown_backticks() { + for (key, en, zh) in super::golden::rows().copied() { + for (side, text) in [("en", en), ("zh-CN", zh)] { + assert!( + !text.contains('`'), + "{key} 的{side}译文里还有 markdown 反引号:{text}" + ); + } + } +} + +/// 串里有没有「(D13)」「(M175)」这类内部编号(全角半角括号都算)。 +fn mentions_decision_id(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + chars.windows(3).any(|window| { + (window[0] == '(' || window[0] == '(') + && (window[1] == 'D' || window[1] == 'M') + && window[2].is_ascii_digit() + }) +}