feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)

用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」

- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
  数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
  要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
  下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
  `java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
  各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
  `SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。

顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
This commit is contained in:
AskaEth committed 2026-11-08 20:49:56 +08:00
1 parent 329c529bea
commit ce463cc9b5
33 files changed
+3839 -322

No files matched your search

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