Files
Unlucky-Launcher/crates/app-iced/src/pages/settings/java/download_view.rs
T
AskaEth ce463cc9b5 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}` 各自成模块。
2026-11-08 20:49:56 +08:00

385 lines
12 KiB
Rust

//! 「下载一个 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()
}