feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停

用户实跑反馈两条,本轮各自定位到根因并修掉:

1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
   `AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
   安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
   Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
   `AppCommand::CreateInstance`。
   - 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
     `AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
     缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
   - 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
     1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
   - 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
     模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。

2. 「点取消没反应」= 真 bug,两跳各断一半:
   - 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
     刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
     修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
     `InstallDone` 带代次回来,代次不符一律丢。
   - 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
     新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
     CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
     drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
     返回新的 `InstallErrorKind::Cancelled`。
   - 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
     「哪些留着、哪些一定没发生」说清(D21)。

实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。

文案全部进 i18n(中英,9 条新 key + 金表同步)。
This commit is contained in:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent d690d09b84
commit 30b1ff6f48
39 files changed
+2550 -518

No files matched your search

+18 -1
View File
@@ -130,6 +130,12 @@ pub struct App {
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
pub(super) agent: crate::shell::agent::AgentUi,
/// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。
///
/// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建,
/// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`)
/// 是同一个理由、同一个做法。
pub(super) download_draft: pages::InstallDraft,
}
impl App {
@@ -216,6 +222,8 @@ impl App {
settings: pages::settings::Groups::default(),
// 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
@@ -280,9 +288,18 @@ impl App {
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
let draft = self.download_draft.clone();
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
self.view = super::view::model(&self.state, width, self.options.force_state, category);
self.view = super::view::model(
&self.state,
width,
self.options.force_state,
category,
&draft,
);
self.view_dirty = false;
self.refreshes += 1;
}
+105 -8
View File
@@ -1,16 +1,18 @@
//! 下载页在 app 层的那几支(M94)。
//! 下载页在 app 层的那几支(M94;M105 补上安装选项)。
//!
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
//! 四个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! 几个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! mission scope)。
//!
//! ## 四支各自的纪律
//! ## 各支各自的纪律(M105 后的口径)
//!
//! | 消息 | 落点 | 为什么 |
//! |---|---|---|
//! | [`pages::Message::SelectVersion`] | **只改视图模型** | 选中是页面内状态,不是业务命令 |
//! | [`pages::Message::InstallVersion`] | `AppCommand::InstallVersion` | 真指令,回事件后才画进度 |
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 同上(host 侧对没在跑的任务是 no-op) |
//! | [`pages::Message::SelectVersion`] | 只改**草稿** | 选中是页面内状态,不是业务命令;顺手清掉加载器版本(换了原版版本,旧版本号几乎必然不适用) |
//! | [`pages::Message::SetLoader`] | 改草稿 + 必要时派 `ListLoaderVersions` | 加载器版本列表来自官方元数据,页面不能自己编 |
//! | [`pages::Message::SetLoaderVersion`] / `SetInstanceName` | 只改草稿 | 纯输入 |
//! | [`pages::Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)` | **确认才开工**(带加载器 / 加载器版本 / 实例名) |
//! | [`pages::Message::CancelInstall`] | `AppCommand::CancelInstall` | 见 `host/dispatch.rs`:真停 + 作废旧代次 |
//! | [`pages::Message::SetCategory`] | 写分类 → 重建 → `AppCommand::ListVersions` | 见下 |
//!
//! [「刷新下载源」](pages::Action::RefreshSources) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
@@ -22,6 +24,14 @@
//! 本页做**:写进视图模型 → 立刻 `refresh_view()`(它会把 category 带回去、按同一谓词过滤行)→
//! 再派指令。这不是「乐观回显」:写的是**过滤条件本身**,chip 与列表用同一个
//! [`Category::matches`](crate::pages::Category),于是不可能出现「chip 说正式版、列表是全量」的两份真相。
//!
//! ## 草稿为什么住在这里而不是页面模型里
//!
//! `refresh_view()` 每次事件都把 `self.view` 整棵重建(连安装进度都算),页面存不住「用户正在填、
//! 还没确认」的东西。于是草稿放 `App::download_draft`,与分类筛选同一个做法:
//! 改草稿 → `view_dirty = true` → `refresh_view()`。
use unlucky_presenter::dto::CreateRequest;
use super::*;
@@ -30,10 +40,39 @@ impl App {
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
match message {
pages::Message::SelectVersion(id) => {
self.view.download.selected = Some(id.clone());
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
// 换了原版版本:加载器版本是**按 (加载器, 原版版本)** 取的(见
// `CoreEvent::LoaderVersions` 的缓存键),旧的版本号留着只会误导。
self.download_draft.loader_version.clear();
}
self.download_draft.selected = Some(id.clone());
self.mark_download_dirty();
// 选了加载器版本之后换原版版本,列表要跟着换——已经查过的直接从快照读,不重复派。
self.ensure_loader_versions();
}
pages::Message::SetLoader(loader) => {
self.download_draft.loader = *loader;
self.download_draft.loader_version.clear();
self.mark_download_dirty();
self.ensure_loader_versions();
}
pages::Message::SetLoaderVersion(version) => {
self.download_draft.loader_version = version.clone();
self.mark_download_dirty();
}
pages::Message::SetInstanceName(name) => {
self.download_draft.instance_name = name.clone();
self.mark_download_dirty();
}
pages::Message::InstallVersion(id) => {
self.dispatch(AppCommand::InstallVersion(id.clone()));
// 防守:按钮带的是**页面上那一行**的 id,草稿里的选中项理论上与它一致;
// 万一不一致(例如列表刚换过、用户手快),不能让 A 版本的加载器版本落到 B 版本上。
if self.download_draft.selected.as_deref() != Some(id.as_str()) {
self.download_draft.selected = Some(id.clone());
self.download_draft.loader_version.clear();
}
let request = self.install_request(id);
self.dispatch(AppCommand::CreateInstance(request));
}
pages::Message::CancelInstall => self.dispatch(AppCommand::CancelInstall),
pages::Message::SetCategory(category) => {
@@ -54,4 +93,62 @@ impl App {
}
true
}
/// 改草稿之后统一走这一条:置脏 + 立刻重建(不等下一拍,点了就有反应)。
fn mark_download_dirty(&mut self) {
self.view_dirty = true;
self.refresh_view();
}
/// 确认安装时的 `CreateRequest`(草稿 → 指令载荷)。
///
/// 实例名留空就让 core 用版本号(`CreateRequest::instance_name` 的既有口径),
/// 所以这里**不**自己拼一个默认名——两处各拼一份迟早会漂。
fn install_request(&self, id: &str) -> CreateRequest {
let draft = &self.download_draft;
CreateRequest {
name: draft.instance_name.trim().to_string(),
version: id.to_string(),
loader: draft.loader.kind(),
loader_version: draft.loader_version.trim().to_string(),
}
}
/// 当前草稿需要加载器版本列表、而快照里还没有时,派一次查询。
///
/// 判据是 `state.loader_versions_for(...)` 的 `None`(= 没查过):查过的(成功或失败)都不再重复派,
/// 失败要重试走 `RefreshSources` 那条路,页面上的红色原因行不会因为一次点击就消失。
fn ensure_loader_versions(&mut self) {
let loader = self.download_draft.loader;
if loader == pages::Loader::Vanilla {
return;
}
let Some(version) = self.download_draft.selected.clone() else {
return;
};
// 静态就不支持的组合不联网(否则只会换来一条「取不到」的假原因;真正的原因是
// 「这个加载器没有这个版本的构建」,页面已经用静态规则说清了)。
let kind = self
.state
.versions
.iter()
.find(|summary| summary.id == version)
.map(|summary| summary.kind);
if let Some(kind) = kind {
if loader.supports(&version, kind).is_err() {
return;
}
}
if self
.state
.loader_versions_for(loader.kind(), &version)
.is_some()
{
return;
}
self.dispatch(AppCommand::ListLoaderVersions {
kind: loader.kind(),
mc_version: version,
});
}
}
@@ -47,8 +47,15 @@ fn download_intents_dispatch_real_commands() {
let _ = app.on_page(pages::Message::CancelInstall);
let _ = app.on_page(pages::Message::SetCategory(pages::Category::Release));
let _ = app.on_page(pages::Message::SelectVersion("1.21.8".to_string()));
// M105:「安装」不再是「立刻装原版」,而是把**草稿里的选项**一起交出去
// (`CreateInstance` 带加载器 / 加载器版本 / 实例名)——这正是用户报的「没有加载器可选」的修复点。
let expected = vec![
AppCommand::InstallVersion("1.21.9".to_string()),
AppCommand::CreateInstance(unlucky_presenter::dto::CreateRequest {
version: "1.21.9".to_string(),
loader: unlucky_presenter::dto::LoaderKind::Vanilla,
loader_version: String::new(),
name: String::new(),
}),
AppCommand::CancelInstall,
AppCommand::ListVersions(VersionCategory::Release),
];
@@ -57,7 +64,14 @@ fn download_intents_dispatch_real_commands() {
expected,
"三条各派一条;`SelectVersion` 不派"
);
assert_eq!(app.view.download.selected.as_deref(), Some("1.21.8"));
// M105:选中项住在**草稿**里(`refresh_view` 每次事件都会整棵重建页面模型,
// 页面自己存不住它);recorder 不回 `VersionListChanged` ⇒ 列表是空的 ⇒
// 视图模型里那一行不存在,于是 `selected` 被过滤成 `None`(防「切分类后还留着一个点不动的安装」)。
assert_eq!(app.download_draft.selected.as_deref(), Some("1.21.8"));
assert_eq!(
app.view.download.selected, None,
"选中的行不在列表里时,视图模型不该拿着它"
);
assert_eq!(app.view.download.category, pages::Category::Release);
assert_eq!(
app.view.download.rows.len(),
@@ -74,3 +88,86 @@ fn download_intents_dispatch_real_commands() {
"重建视图模型之后分类必须还在"
);
}
/// **取消之后面板要回到「未安装态」且能立刻再装**(M105 的验收项,CI 可跑的那一半)。
///
/// 现场(用户原话):「取消下载按钮点了没反应」。真根因是取消生效了又被旧任务的事件盖回去
/// (presenter 侧的代次守门在 `host/cancel_tests.rs`);这一条钉的是**页面这一侧**:
/// 收到 `InstallProgress(None)` 之后,面板必须回到安装选项区、确认键可用、选中的版本还在
/// ——不需要用户重新点一遍版本。
///
/// 抓得住:取消后仍画着进度面板 / 选项区丢了选中 / 确认键还是禁用。
/// 抓不住:真网络下的取消延迟(那条是 `#[ignore]` 的真网络用例)。
#[test]
fn cancelling_gives_the_panel_back_to_the_install_options() {
let log: Rc<RefCell<Vec<AppCommand>>> = Rc::new(RefCell::new(Vec::new()));
let families = crate::theme::Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
let mut app = App::with_presenter(
crate::cli::Options::default(),
families,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&log))),
SettingsBridge::memory_only(),
);
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::VersionListChanged(vec![
unlucky_presenter::dto::VersionSummary {
id: "1.21.9".to_string(),
kind: unlucky_presenter::dto::VersionKind::Release,
released: "2026-09-20".to_string(),
installed: false,
},
]),
));
app.refresh_view();
// 选中 → 面板给出选项区
let _ = app.on_page(pages::Message::SelectVersion("1.21.9".to_string()));
let options = app
.view
.download
.options
.as_ref()
.expect("选中版本之后要出现安装选项区");
assert!(options.can_confirm, "原版 + 列表里的版本 = 可以装");
assert_eq!(options.version, "1.21.9");
// 开装:host 回一条「下载中」→ 面板换成进度
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstallProgress(Some(
unlucky_presenter::dto::InstallProgressView {
version: "1.21.9".to_string(),
stage: unlucky_presenter::dto::InstallStage::Downloading,
files_done: 9,
files_total: 512,
..unlucky_presenter::dto::InstallProgressView::new()
},
)),
));
app.refresh_view();
assert!(app.view.download.install.is_some(), "装的时候面板讲进度");
assert!(app.view.download.options.is_none(), "进度与选项互斥");
// 取消:host 回 `InstallProgress(None)`(`CancelInstall` 的既有语义)
let _ = app.on_page(pages::Message::CancelInstall);
app.state.apply(AppEvent::Core(
unlucky_presenter::dto::CoreEvent::InstallProgress(None),
));
app.refresh_view();
assert!(app.view.download.install.is_none(), "取消后面板必须收回");
let options = app
.view
.download
.options
.as_ref()
.expect("取消后要回到安装选项区(未安装态),而不是只剩一句提示");
assert_eq!(
options.version, "1.21.9",
"选中项要还在,用户不必重新点一遍"
);
assert!(options.can_confirm, "取消后要能立刻再装一次");
}
@@ -47,6 +47,9 @@ impl App {
pages::Message::SelectVersion(_)
| pages::Message::InstallVersion(_)
| pages::Message::CancelInstall
| pages::Message::SetLoader(_)
| pages::Message::SetLoaderVersion(_)
| pages::Message::SetInstanceName(_)
| pages::Message::SetCategory(_) => debug_assert!(false, "应由 on_download 消费"),
// M95:清空日志是**呈现层**操作(`log_lines` 是 presenter 的公开快照字段,
// 不派业务命令、不写持久状态)
+23 -9
View File
@@ -105,6 +105,7 @@ pub(super) fn model(
width: f32,
forced: Option<crate::cli::ForcedState>,
category: pages::Category,
draft: &pages::InstallDraft,
) -> pages::Model {
let route = state.route().clone();
let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances);
@@ -137,7 +138,12 @@ pub(super) fn model(
window_width: width,
active: route.nav(),
instances: pages::Instances { rows, detail },
download: pages::Download::from_snapshot(state, category, unlucky_i18n::Lang::En.catalog()),
download: pages::Download::from_snapshot(
state,
category,
draft,
unlucky_i18n::Lang::En.catalog(),
),
logs: logs_model(state).with_status(lang),
lang,
forced: forced.map(|state| match state {
@@ -274,10 +280,21 @@ mod tests {
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
model(
state,
1180.0,
forced,
pages::Category::All,
&pages::InstallDraft::default(),
)
}
#[test]
fn the_forced_state_maps_to_the_expected_interaction() {
let state = AppState::new();
let forced = |forced| model(&state, 1180.0, Some(forced), pages::Category::All).forced;
let forced = |forced| at(&state, Some(forced)).forced;
assert_eq!(
forced(crate::cli::ForcedState::Rest),
@@ -292,10 +309,7 @@ mod tests {
forced(crate::cli::ForcedState::Pressed),
Some(Interaction::Pressed)
);
assert_eq!(
model(&state, 1180.0, None, pages::Category::All).forced,
None
);
assert_eq!(at(&state, None).forced, None);
}
#[test]
@@ -306,7 +320,7 @@ mod tests {
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
let model = at(&state, None);
assert_eq!(model.screen, pages::Screen::Instances);
assert_eq!(model.active, Some(pages::Screen::Instances.nav()));
assert_eq!(model.window_width, 1180.0);
@@ -337,7 +351,7 @@ mod tests {
));
state.navigate(Route::Detail("survival".to_string()));
let model = model(&state, 1180.0, None, pages::Category::All);
let model = at(&state, None);
assert_eq!(
model.instances.rows[0].status.as_deref(),
Some("Downloading · 50%")
@@ -375,7 +389,7 @@ mod tests {
fn the_agent_route_has_no_page() {
let mut state = AppState::new();
state.navigate(Route::Agent);
let model = model(&state, 1180.0, None, pages::Category::All);
let model = at(&state, None);
// Agent 没有页面:模型退回实例页的空壳,view 会走外壳回退(`None` 分支)
assert_eq!(pages::Screen::of(state.route()), None);
assert!(model.instances.detail.is_none());
+12 -1
View File
@@ -41,10 +41,19 @@
//! |---|---|---|
//! | 点分类 tab | [`Message::SetCategory`] | `AppCommand::ListVersions(category)`(`All` 不派:画现有快照) |
//! | 点一行 | [`Message::SelectVersion`] | **只改视图模型**(选中不是业务命令) |
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)`(M105:带加载器与实例名) |
//! | 点「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 点「刷新」/「打开日志」 | `Message::Action(RefreshSources)` / `Message::Navigate(Logs)` | `ListVersions` / `AppState::navigate` |
//!
//! ## M105:安装选项(加载器维度)
//!
//! 选中版本**不再直接开工**。面板下半块变成「安装选项」区:五家加载器(原版 / Forge /
//! NeoForge / Fabric / Quilt)的 chip、该加载器的官方版本列表、实例名,点「安装」才派
//! `CreateInstance`。选项的决策与画法在 [`options`](子模块),本文件只负责把模型交给它。
//! 加载器版本列表来自 core 的 `LoaderSource::loader_versions`(经
//! `AppCommand::ListLoaderVersions` 回流到快照);取不到时页面给具名原因并**禁掉确认**——
//! 不许「选完才失败」。
//!
//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。
//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。
//!
@@ -58,6 +67,7 @@
mod demo;
mod install;
mod model;
mod options;
mod rows;
mod states;
@@ -66,6 +76,7 @@ mod tests;
pub use demo::demo;
pub use model::{Category, Download};
pub use options::{InstallDraft, Loader};
use iced::widget::column;
use iced::{Element, Length};
+42 -3
View File
@@ -19,7 +19,8 @@
//! ```
//!
//! 取值:`ready`(缺省)/ `loading` / `empty` / `selected` / `category-release` / `installing` /
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next`。
//! `installing-late` / `installing-empty` / `failed` / `done` / `done-next` /
//! **`options` / `options-fabric` / `options-incompat`**(M105 的安装选项区三态)。
//!
//! `category-release` / `done` / `done-next` 都**不手搓结果**,而是拿一份 `AppState` 跑
//! [`Download::from_snapshot`]:图证明的是产品路径的行为(分类本地过滤、装完后面板让回列表),
@@ -40,6 +41,7 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::Catalog;
use super::model::{Category, Download, InstallView, VersionRow};
use super::options::{InstallDraft, Loader};
/// 取证开关的名字([`demo`] 读它)。
pub const DEMO_STATE_ENV: &str = "ULL_DL_DEMO";
@@ -63,6 +65,17 @@ pub fn demo(catalog: Catalog) -> Download {
selected: Some("1.21.9".to_string()),
..ready(catalog)
},
// M105:安装选项区三态(未选加载器 / 已选 Fabric / 不兼容组合)。
// 三条都走**产品那条真转换**(`from_snapshot` + 草稿),图里看到的是页面真会画的东西。
Some("options") => options(catalog, "1.21.9", Loader::Vanilla, None),
Some("options-fabric") => options(
catalog,
"1.21.9",
Loader::Fabric,
Some(vec!["0.16.5", "0.16.4", "0.15.11"]),
),
// NeoForge 最早只到 1.20.1,远古版(列表里的 `rd-132211`)根本没有构建 ⇒ 选中即报原因
Some("options-incompat") => options(catalog, "rd-132211", Loader::NeoForge, None),
Some("selected") => Download {
selected: Some("1.21.9".to_string()),
..ready(catalog)
@@ -103,11 +116,36 @@ fn ready(catalog: Catalog) -> Download {
category: Category::of(&versions),
rows,
selected: None,
options: None,
install: None,
loaded: true,
}
}
/// 「用户选了版本、正在填安装选项」的演示(M105)。
///
/// 走真转换:`AppState`(版本列表 + 可选的加载器版本缓存)+ 草稿 → [`Download::from_snapshot`]。
/// 于是图上「默认取最新加载器版本」「不兼容给具名原因」「确认键禁用」都是产品行为,不是演示特权。
fn options(catalog: Catalog, version: &str, loader: Loader, cached: Option<Vec<&str>>) -> Download {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
demo_versions(),
)));
if let Some(items) = cached {
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
kind: loader.kind(),
mc_version: version.to_string(),
result: Ok(items.into_iter().map(str::to_string).collect()),
}));
}
let draft = InstallDraft {
selected: Some(version.to_string()),
loader,
..InstallDraft::default()
};
Download::from_snapshot(&state, Category::All, &draft, catalog)
}
/// 演示用的版本表(`ready` 与 `finished` 共用;与 presenter 的 `mock_versions` 同形)。
fn demo_versions() -> Vec<VersionSummary> {
fn summary(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
@@ -161,7 +199,8 @@ fn finished(catalog: Catalog, select_next: Option<&str>) -> Download {
},
))));
let mut model = Download::from_snapshot(&state, Category::All, catalog);
let mut model =
Download::from_snapshot(&state, Category::All, &InstallDraft::default(), catalog);
model.selected = select_next.map(str::to_string);
model
}
@@ -172,7 +211,7 @@ fn category(catalog: Catalog, wanted: Category) -> Download {
state.apply(AppEvent::Core(CoreEvent::VersionListChanged(
demo_versions(),
)));
Download::from_snapshot(&state, wanted, catalog)
Download::from_snapshot(&state, wanted, &InstallDraft::default(), catalog)
}
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
+14 -49
View File
@@ -1,4 +1,8 @@
//! 安装面板(页面底部):选中版本 → 「安装」/ 进度 → 「取消」/ 失败 → 「打开日志」。
//! 安装面板(页面底部):选中版本 → **安装选项** → 「安装」/ 进度 + 「取消」/ 失败 → 「打开日志」。
//!
//! M105 之前这里是「选中版本 → 直接『安装』」:点下去就开工,加载器维度整个不存在
//! (用户实跑的原话:「没是否有选择有前置加载器(版本选择)选项」)。选项区在 `options/`,
//! 本文件只管**面板形态的决策**(纯函数 [`panel_state`])与「安装中 / 失败」两种形态。
//!
//! ## 这一块为什么钉在底部而不是跟着列表滚
//!
@@ -9,7 +13,7 @@
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::InstallVersion(id)` |
//! | 「安装」 | [`Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)`(带加载器与实例名) |
//! | 「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 「打开日志」 | [`Message::Navigate`](`Route::Logs`) | `AppState::navigate`(不派指令) |
//!
@@ -47,6 +51,7 @@ use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Download, InstallView};
use super::options::OptionsView;
/// 面板要画哪一种形态(**决策与画法分开**:决策是纯函数,单测直接钉它)。
///
@@ -56,13 +61,8 @@ use super::model::{Download, InstallView};
pub(super) enum PanelState<'a> {
/// 没有安装、也没选中:一句「选一个版本再安装」
Hint,
/// 选中了一个版本:给出「安装」(已装的给禁用态)
Ready {
/// 选中的版本 id(= `AppCommand::InstallVersion` 的载荷)
id: &'a str,
/// 本地已经装过(按钮禁用,列表行上也有「已安装」+ 勾)
installed: bool,
},
/// 选中了一个版本:给出**安装选项区**(M105;加载器 / 加载器版本 / 实例名 + 「安装」)
Options(&'a OptionsView),
/// 有安装(进行中 / 失败):进度或错误面板
Install(&'a InstallView),
}
@@ -71,15 +71,12 @@ pub(super) enum PanelState<'a> {
///
/// 顺序是**安装优先**:有安装时面板讲安装,讲不了列表;安装结束后 `model.install` 由
/// [`Download::from_snapshot`](super::model::Download::from_snapshot) 清掉,于是这里自然回到
/// [`PanelState::Ready`] / [`PanelState::Hint`],用户能接着选下一个版本。
/// [`PanelState::Options`] / [`PanelState::Hint`],用户能接着选下一个版本。
pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
match &model.install {
Some(install) => PanelState::Install(install),
None => match model.selected_row() {
Some(row) => PanelState::Ready {
id: row.id.as_str(),
installed: row.installed,
},
None => match &model.options {
Some(options) => PanelState::Options(options),
None => PanelState::Hint,
},
}
@@ -89,7 +86,8 @@ pub(super) fn panel_state(model: &Download) -> PanelState<'_> {
pub(super) fn panel<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Download) -> Element<'a, Message> {
let content = match panel_state(model) {
PanelState::Install(install) => installing(ui, catalog, install),
PanelState::Ready { id, installed } => ready(ui, catalog, id, installed),
// 选中版本之后**先给选项,再点「安装」**(M105:以前是选中就直接开工,等于没有加载器维度)
PanelState::Options(options) => super::options::section(ui, catalog, options),
PanelState::Hint => hint(ui, catalog),
};
@@ -110,39 +108,6 @@ fn hint<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
)
}
/// 选好了:版本 id + 「安装」(全屏唯一实心 accent,rubric K2)。
///
/// 已装的版本给**禁用**的 Outlined 按钮(`Button::disabled` 只清消息、保留几何):
/// 「已装」是列表行上的事实(overline + 勾),按钮再点一次不会更清楚。
fn ready<'a>(ui: &'a Ui, catalog: Catalog, id: &'a str, installed: bool) -> Element<'a, Message> {
let label = container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
id,
ui.color(Role::OnSurface),
))
.width(Length::Fill);
let action = if installed {
buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)))
} else {
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadInstall),
Message::InstallVersion(id.to_string()),
)
.leading(IconKind::Download),
)
};
row![label, action]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
/// 安装中 / 已收尾(失败另走 [`failed`])。
fn installing<'a>(ui: &'a Ui, catalog: Catalog, install: &'a InstallView) -> Element<'a, Message> {
if install.failed() {
@@ -15,6 +15,7 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::Lang;
use super::super::model::{Category, Download};
use super::super::options::InstallDraft;
use super::{panel_state, PanelState};
fn version(id: &str) -> VersionSummary {
@@ -62,6 +63,7 @@ fn a_finished_install_gives_the_page_back_to_the_list() {
let running = Download::from_snapshot(
&snapshot(InstallStage::Downloading),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(running.install.is_some(), "跑着的时候面板要在");
@@ -74,7 +76,12 @@ fn a_finished_install_gives_the_page_back_to_the_list() {
"现实如此:快照里仍留着终态(presenter 不清),页面必须自己让位"
);
let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
let model = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(model.install.is_none(), "Done 之后不许再占着安装面板");
assert!(model.selected.is_none(), "装完不替用户选中任何行");
assert_eq!(model.rows.len(), 2, "列表要回来");
@@ -84,17 +91,22 @@ fn a_finished_install_gives_the_page_back_to_the_list() {
"回到「选一个版本再安装」"
);
// 再装一次:选中下一个版本 → 面板重新给出「安装」(= `Message::InstallVersion(id)`)
let mut next = model.clone();
next.selected = Some("1.21.9".to_string());
assert_eq!(
panel_state(&next),
PanelState::Ready {
id: "1.21.9",
installed: false
// 再装一次:选中下一个版本 → 面板重新给出**安装选项区**(M105 起「安装」在选项区里)
let next = Download::from_snapshot(
&state,
Category::All,
&InstallDraft {
selected: Some("1.21.9".to_string()),
..InstallDraft::default()
},
"装完之后必须能接着装下一个版本"
Lang::En.catalog(),
);
let options = match panel_state(&next) {
PanelState::Options(options) => options,
other => panic!("装完之后必须能接着装下一个版本,实际 {other:?}"),
};
assert_eq!(options.version, "1.21.9");
assert!(options.can_confirm, "原版 + 列表里的版本 = 可以装");
}
/// 取消与失败**不许**一起被清:失败态是 P2-4 的错误出口,取消是「已经没事了」。
@@ -106,13 +118,19 @@ fn only_done_and_cancelled_release_the_panel() {
let failed = Download::from_snapshot(
&snapshot(InstallStage::Failed),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(failed.install.is_some(), "失败态要留在屏幕上");
assert!(matches!(panel_state(&failed), PanelState::Install(_)));
for stage in [InstallStage::Done, InstallStage::Cancelled] {
let model = Download::from_snapshot(&snapshot(stage), Category::All, Lang::En.catalog());
let model = Download::from_snapshot(
&snapshot(stage),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(model.install.is_none(), "{stage:?} 之后面板要让位");
}
}
+32 -4
View File
@@ -31,6 +31,8 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::{Catalog, Key};
use super::options::{InstallDraft, OptionsView};
/// 分类筛选(页面上的四个 filter chip)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Category {
@@ -265,8 +267,10 @@ pub struct Download {
pub rows: Vec<VersionRow>,
/// 当前分类(从快照反推)
pub category: Category,
/// 选中的版本 id(`None` = 没选中 → 安装按钮禁用)
/// 选中的版本 id(`None` = 没选中 → 面板只给一句提示)
pub selected: Option<String>,
/// 安装选项区(M105):选中了版本、又没在装的时候才有
pub options: Option<OptionsView>,
/// 正在跑(或刚跑完)的安装
pub install: Option<InstallView>,
/// 快照里有没有版本列表(`false` = 还没拉到 → 加载态)
@@ -290,7 +294,17 @@ impl Download {
/// 永远是同一份真相,事件回来重建也不会跳回「全部」。
///
/// 过滤后为空就是「空态」(`loaded` 仍为 `true`)。
pub fn from_snapshot(state: &AppState, category: Category, catalog: Catalog) -> Self {
///
/// `draft` 是**用户还没确认的安装输入**(选中版本 + 加载器 + 加载器版本 + 实例名)。
/// 它住在 `App`(照分类筛选的既有做法),因为 `refresh_view` 每次事件都会把整棵模型重建,
/// 页面自己存不住任何东西。选中项还要**在当前列表里**才算数:切分类后那一行不在了,
/// 就不该还留着一个点不动的「安装」。
pub fn from_snapshot(
state: &AppState,
category: Category,
draft: &InstallDraft,
catalog: Catalog,
) -> Self {
let query = state.download_query.trim().to_ascii_lowercase();
let rows = state
.versions
@@ -313,8 +327,21 @@ impl Download {
// 为什么不连 `Failed` 一起清:失败态带着用户要的东西(停在哪个文件 + 去日志页),
// 那正是 P2-4 要它一眼可辨的理由。
.filter(|view| !view.finished());
// 快照没有「选中」字段:正在安装的那个版本就是「选中」(见模块文档的缺口表)。
let selected = install.as_ref().map(|view| view.version.clone());
// 选中项:安装中就是正在装的那个;否则是用户点的那个(草稿),且必须还在当前列表里。
let selected = install
.as_ref()
.map(|view| view.version.clone())
.or_else(|| draft.selected.clone())
.filter(|id| rows.iter().any(|row| &row.id == id));
// 选项区与安装面板**互斥**:在装的时候面板讲进度,讲不了选项。
let options = match &install {
Some(_) => None,
None => selected.as_ref().and_then(|id| {
rows.iter()
.find(|row| &row.id == id)
.map(|row| OptionsView::of(row, state, draft, catalog))
}),
};
Self {
rows,
@@ -322,6 +349,7 @@ impl Download {
// [`Category::of`] 只留给「不经过用户选择」的入口(演示数据的首帧)。
category,
selected,
options,
install,
loaded: !state.versions.is_empty(),
}
@@ -5,6 +5,7 @@ use unlucky_presenter::state::AppState;
use unlucky_i18n::Lang;
use super::super::options::InstallDraft;
use super::{Category, Download};
fn version(id: &str, kind: VersionKind) -> VersionSummary {
@@ -35,7 +36,12 @@ fn the_category_filters_the_list_locally() {
version("rd-132211", VersionKind::Ancient(AncientKind::Classic)),
]);
let release = Download::from_snapshot(&state, Category::Release, Lang::En.catalog());
let release = Download::from_snapshot(
&state,
Category::Release,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(
release.category,
Category::Release,
@@ -44,7 +50,12 @@ fn the_category_filters_the_list_locally() {
assert_eq!(release.rows.len(), 1, "只剩正式版");
assert_eq!(release.rows[0].id, "1.21.9");
let all = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
let all = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(all.rows.len(), 3, "「全部」不过滤");
}
@@ -55,7 +66,12 @@ fn the_category_filters_the_list_locally() {
#[test]
fn a_category_that_hides_everything_is_the_empty_state() {
let state = state_with(vec![version("1.21.9", VersionKind::Release)]);
let ancient = Download::from_snapshot(&state, Category::Ancient, Lang::En.catalog());
let ancient = Download::from_snapshot(
&state,
Category::Ancient,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(ancient.rows.is_empty());
assert!(!ancient.is_loading(), "快照里有版本 ⇒ 不是加载中");
assert!(ancient.is_empty(), "筛掉所有行 ⇒ 空态");
@@ -0,0 +1,36 @@
//! 安装选项区(M105):**选中版本之后先给选项,点「安装」才开工**。
//!
//! ## 为什么要有这一步
//!
//! 用户实跑反馈的原话是「点击下载之后它就直接下载了,没有选择前置加载器(版本选择)选项」。
//! 这不是画得不好,是**功能没做**:`Message::InstallVersion` 直接派 `InstallVersion`,
//! 而 core 早就会装四家加载器(M32/M35/M45 已合)。于是「能装什么」与「UI 让你装什么」
//! 之间差着一整个加载器维度。
//!
//! ## 文件分工
//!
//! | 文件 | 内容 |
//! |---|---|
//! | [`model`] | **决策**(无 iced):加载器词表、兼容性静态规则、选项视图、确认键判据 |
//! | [`view`] | 控件树:加载器 chip ×2 行 + 加载器版本列表 + 实例名 + 不兼容提示 |
//! | `tests`(`options/tests.rs`) | 兼容性真值表 + 确认键门(纯函数,不开图形环境) |
//!
//! ## 一条纪律:页面只发意图
//!
//! | 控件 | 消息 | app 层落点 |
//! |---|---|---|
//! | 加载器 chip | [`Message::SetLoader`](super::Message::SetLoader) | 改草稿 + 按需派 `ListLoaderVersions` |
//! | 加载器版本一行 | [`Message::SetLoaderVersion`](super::Message::SetLoaderVersion) | 改草稿 |
//! | 实例名输入 | [`Message::SetInstanceName`](super::Message::SetInstanceName) | 改草稿 |
//! | 「安装」 | [`Message::InstallVersion`](super::Message::InstallVersion) | `AppCommand::CreateInstance(草稿)` |
//!
//! 草稿(选中版本 + 三个选项)住在 `App`,由 `refresh_view` 带进页面模型——与分类筛选同一个做法,
//! 因为 `refresh_view` 每次事件都会把整棵模型重建,页面自己存不住任何东西。
mod model;
#[cfg(test)]
mod tests;
mod view;
pub use model::{InstallDraft, Loader, OptionsView};
pub(crate) use view::section;
@@ -0,0 +1,286 @@
//! 安装选项的**决策**(无 iced):选哪个加载器、它配不配这个原版版本、确认键能不能按。
//!
//! ## 为什么选项要有自己的模型层
//!
//! M105 之前「点安装」直接开工,于是用户装不了带加载器的版本(core 早就会装四家加载器,
//! 只是 UI 没接)。补上这一步之后,页面要回答的问题从一个变成四个:
//!
//! | 问题 | 判据 |
//! |---|---|
//! | 这个加载器配这个原版版本吗 | [`Loader::supports`](静态规则,**不联网**) |
//! | 加载器版本有哪些 | presenter 的 `LoaderVersions` 快照(`state.loader_versions_for`) |
//! | 用哪个加载器版本 | 用户选的;没选就取列表里最新的一条(core 的 `resolve(kind, mc, None)` 也是这个口径) |
//! | 现在能不能点「安装」 | [`OptionsView::can_confirm`](不兼容 / 列表没到位 / 已装 都按不下去) |
//!
//! ## 兼容性为什么是静态规则而不是问网络
//!
//! 「选完才失败」是用户最讨厌的一种交互。凡是**官方根本没有**的组合(NeoForge × 1.12.2、
//! 远古版 × 任何加载器),必须在选中那一刻就说清,不能等点了安装再回一个 404。
//! 反过来说:**能**装但列表拉不到(网络问题)属于「取不到」,由 [`LoaderVersionsView::Failed`]
//! 显示具名原因并禁掉确认——两条都不会让用户「选完才失败」。
//!
//! 规则来源(不猜:都是 core 安装管线自己的判据):
//! - Fabric/Quilt 的 loader 列表从 `1.14` 起才有(core 走 fabric meta,1.13 及以前没有);
//! - NeoForge 的第一个版本是 `1.20.1`(`maven::NEOFORGE_FORGE_LAYOUT_MC`);
//! - Forge 从 `1.1` 起有正式构建;远古版(`rd-*` / `a*` / `b*`)与预览版在 maven 表里没有条目。
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{LoaderKind, VersionKind};
use unlucky_presenter::state::AppState;
use super::super::model::VersionRow;
/// 加载器版本下拉此刻的内容。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoaderVersionsView {
/// 还没查过 / 正在查(页面显示「正在获取…」,确认键按下不去)
Loading,
/// 查到了(**最新在前**,与 core 的 `loader_versions` 同序)
Ready(Vec<String>),
/// 查不到:人读原因(页面显示它并禁掉确认,不静默显示成空列表)
Failed(String),
}
/// 页面侧的加载器词表:只列 GUI 真能装的五家(与 `dto::LoaderKind::wizard_choices` 同集)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Loader {
/// 原版(不装加载器)
#[default]
Vanilla,
/// Forge
Forge,
/// NeoForge
NeoForge,
/// Fabric
Fabric,
/// Quilt
Quilt,
}
impl Loader {
/// 展示顺序(任务书的口径:原版 / Forge / NeoForge / Fabric / Quilt)。
pub const ALL: [Loader; 5] = [
Loader::Vanilla,
Loader::Forge,
Loader::NeoForge,
Loader::Fabric,
Loader::Quilt,
];
/// 对应的 presenter 词表(派 `AppCommand` 时用)。
pub const fn kind(self) -> LoaderKind {
match self {
Loader::Vanilla => LoaderKind::Vanilla,
Loader::Forge => LoaderKind::Forge,
Loader::NeoForge => LoaderKind::NeoForge,
Loader::Fabric => LoaderKind::Fabric,
Loader::Quilt => LoaderKind::Quilt,
}
}
/// 从 presenter 词表回来(`OptiFine` / `LiteLoader` 不是本页选项)。
pub const fn of(kind: LoaderKind) -> Option<Self> {
match kind {
LoaderKind::Vanilla => Some(Loader::Vanilla),
LoaderKind::Forge => Some(Loader::Forge),
LoaderKind::NeoForge => Some(Loader::NeoForge),
LoaderKind::Fabric => Some(Loader::Fabric),
LoaderKind::Quilt => Some(Loader::Quilt),
LoaderKind::OptiFine | LoaderKind::LiteLoader => None,
}
}
/// chip 文案。
///
/// 四家加载器的名字是**专有名词**(Fabric/Forge/NeoForge/Quilt 官方各语言都写原样),
/// 所以只有「原版」走 i18n;硬给品牌名建一条 en==zh 的 key 会被
/// `catalog::tests::chinese_catalog_is_actually_chinese` 判为「未翻译」。
pub fn label(self, catalog: Catalog) -> &'static str {
match self {
Loader::Vanilla => catalog.text(Key::LoaderVanilla),
Loader::Forge => "Forge",
Loader::NeoForge => "NeoForge",
Loader::Fabric => "Fabric",
Loader::Quilt => "Quilt",
}
}
/// 这个加载器配不配这个原版版本(静态规则,见模块文档的判据表)。
///
/// 参数是 `(版本 id, 版本类型)` 而不是一整行:app 层在**派查询之前**也要问同一个问题
/// (静态就不支持的组合不该去联网问,否则会白白换来一条「取不到」的 Toast)。
pub fn supports(self, id: &str, kind: VersionKind) -> Result<(), Incompat> {
if self == Loader::Vanilla {
return Ok(());
}
// 远古版:四家都没有构建(maven 表 / fabric meta 都没有这些版本)。
if kind.is_ancient() {
return Err(Incompat::Unsupported);
}
let rank = release_rank(id);
match self {
Loader::Vanilla => Ok(()),
// 预览版:Fabric 系的确会为快照发 loader,交给列表回答(拉不到就显示原因);
// maven 系(Forge/NeoForge)没有快照条目,直接拒。
Loader::Fabric | Loader::Quilt => match rank {
Some(version) if version >= (1, 14, 0) => Ok(()),
Some(_) => Err(Incompat::NeedsAtLeast("1.14")),
None if kind == VersionKind::Snapshot => Ok(()),
None => Err(Incompat::Unsupported),
},
Loader::Forge => match rank {
Some(_) => Ok(()),
None => Err(Incompat::Unsupported),
},
Loader::NeoForge => match rank {
Some(version) if version >= (1, 20, 1) => Ok(()),
Some(_) => Err(Incompat::NeedsAtLeast("1.20.1")),
None => Err(Incompat::Unsupported),
},
}
}
}
/// 不支持的原因(两种,页面各一句文案)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Incompat {
/// 官方没有这个组合的构建
Unsupported,
/// 需要某个最低原版版本
NeedsAtLeast(&'static str),
}
/// `1.20.1` → `(1, 20, 1)`;预览版 / 远古版 / 乱七八糟的串 → `None`。
///
/// 只要有一段落不是纯数字就给 `None`:宁可保守地拒绝一个自定义版本号,也不把
/// `1.20.1-pre3` 当成 `1.20.1` 放过去(那会装出加载器找不到父版本)。
pub fn release_rank(id: &str) -> Option<(u64, u64, u64)> {
let mut pieces = id.trim().split('.');
let major: u64 = pieces.next()?.parse().ok()?;
let minor: u64 = pieces.next()?.parse().ok()?;
let patch: u64 = match pieces.next() {
Some(patch) => patch.parse().ok()?,
None => 0,
};
if pieces.next().is_some() {
return None;
}
Some((major, minor, patch))
}
/// 用户还没确认的安装输入(**app 持有**,不是 presenter 快照的一部分)。
///
/// 为什么住在 app 而不是 presenter:这是「用户正在填、还没提交」的草稿,
/// 和下载页的分类筛选(`App::view.download.category` 的来源)是同一层的东西;
/// 塞进 presenter 快照会让后端视图模型混进 UI 草稿。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct InstallDraft {
/// 选中的版本 id(点行得到;点别的版本会清掉加载器版本,见 `app/update/download.rs`)
pub selected: Option<String>,
/// 选中的加载器
pub loader: Loader,
/// 用户指定的加载器版本(空 = 用列表里最新的那条)
pub loader_version: String,
/// 实例名(空 = 用版本 id)
pub instance_name: String,
}
/// 选项区要画的东西(文案都已按 `Catalog` 求好值)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionsView {
/// 目标版本 id
pub version: String,
/// 当前选中的加载器
pub loader: Loader,
/// 加载器版本下拉的内容(原版恒为 [`LoaderVersionsView::Ready`] 空表)
pub versions: LoaderVersionsView,
/// **实际会用**的加载器版本(空 = 原版)
pub loader_version: String,
/// 实例名输入框的现值
pub instance_name: String,
/// 不兼容 / 取不到的原因(`Some` ⇒ 确认键按下不去)
pub problem: Option<String>,
/// 本地已装(确认键给「已安装」禁用态)
pub installed: bool,
/// 现在能不能点「安装」
pub can_confirm: bool,
}
impl OptionsView {
/// 选中行 + 快照 + 草稿 → 选项区的样子。
pub fn of(row: &VersionRow, state: &AppState, draft: &InstallDraft, catalog: Catalog) -> Self {
let loader = draft.loader;
let incompat = loader.supports(&row.id, row.kind);
// 静态就不支持的组合**不去查列表**(查了也只会换来一条「取不到」的假原因):
// 空表 + 上面那条「没有适配构建」就是说清这件事的那一句话。
let versions = if loader == Loader::Vanilla || incompat.is_err() {
LoaderVersionsView::Ready(Vec::new())
} else {
match state.loader_versions_for(loader.kind(), &row.id) {
None => LoaderVersionsView::Loading,
Some(Ok(items)) => LoaderVersionsView::Ready(items.clone()),
Some(Err(reason)) => LoaderVersionsView::Failed(reason.clone()),
}
};
// 用户选过就用它;没选过取列表里最新的一条(core 的默认口径也是「最新」)。
let loader_version = if loader == Loader::Vanilla {
String::new()
} else if !draft.loader_version.trim().is_empty() {
draft.loader_version.clone()
} else {
match &versions {
LoaderVersionsView::Ready(items) => items.first().cloned().unwrap_or_default(),
_ => String::new(),
}
};
let problem = match (incompat, &versions) {
(Err(Incompat::Unsupported), _) => Some(catalog.render(
Key::DownloadOptionsIncompatible,
&[
("loader", loader.label(catalog)),
("version", row.id.as_str()),
],
)),
(Err(Incompat::NeedsAtLeast(min)), _) => Some(catalog.render(
Key::DownloadOptionsNeedsAtLeast,
&[("loader", loader.label(catalog)), ("min", min)],
)),
(Ok(()), LoaderVersionsView::Failed(reason)) => Some(catalog.render(
Key::DownloadOptionsLoaderVersionsFailed,
&[("reason", reason.as_str())],
)),
(Ok(()), LoaderVersionsView::Ready(items))
if loader != Loader::Vanilla && items.is_empty() =>
{
Some(catalog.render(
Key::DownloadOptionsLoaderVersionsEmpty,
&[("version", row.id.as_str())],
))
}
(Ok(()), _) => None,
};
// 四条同时成立才按得下去:不兼容没有、列表真的就位(原版不需要列表)、没装过。
// 「列表没到位」既包括 `Loading` 也包括 `Failed` 与空表——它们都已经在 `problem` 里说了原因。
let ready = loader == Loader::Vanilla
|| matches!(&versions, LoaderVersionsView::Ready(items) if !items.is_empty());
let can_confirm = problem.is_none() && ready && !row.installed;
Self {
version: row.id.clone(),
loader,
versions,
loader_version,
instance_name: draft.instance_name.clone(),
problem,
installed: row.installed,
can_confirm,
}
}
/// 确认安装要提交的加载器版本(`None` = 让 core 用官方默认,只在原版路径出现)。
pub fn loader_version_arg(&self) -> Option<&str> {
(!self.loader_version.is_empty()).then_some(self.loader_version.as_str())
}
}
@@ -0,0 +1,254 @@
//! 安装选项的决策单测(M105):兼容性真值表 + 确认键门 + 默认加载器版本。
//!
//! 全部是**纯函数**(`OptionsView::of` 吃 `AppState` 与 `VersionRow`,不碰 iced),
//! 所以不开图形环境也能钉住「什么组合能装、什么组合按下不去」。
//!
//! **为什么用 `AppState` 而不是 mock 后端**:加载器版本列表是快照字段
//! (`state.loader_versions`),灌一条 `CoreEvent::LoaderVersions` 就能造出三种状态
//! (没查过 / 查到了 / 取不到),比 mock 更直接,也不依赖 `MockBackend` 的脚本。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AncientKind, AppEvent, CoreEvent, LoaderKind, VersionKind, VersionSummary,
};
use unlucky_presenter::state::AppState;
use super::model::{release_rank, Incompat, InstallDraft, Loader, LoaderVersionsView, OptionsView};
use crate::pages::download::model::VersionRow;
fn row(id: &str, kind: VersionKind) -> VersionRow {
VersionRow::of(
&VersionSummary {
id: id.to_string(),
kind,
released: "2026-09-20".to_string(),
installed: false,
},
Lang::En.catalog(),
)
}
fn state_with(kind: LoaderKind, mc: &str, items: Result<Vec<&str>, &str>) -> AppState {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
kind,
mc_version: mc.to_string(),
result: items
.map(|list| {
list.into_iter()
.map(str::to_string)
.collect::<Vec<String>>()
})
.map_err(str::to_string),
}));
state
}
fn draft(loader: Loader) -> InstallDraft {
InstallDraft {
selected: Some("1.20.1".to_string()),
loader,
loader_version: String::new(),
instance_name: String::new(),
}
}
/// 版本号解析:只有纯数字三段(或缺末段)才算正式版。
#[test]
fn release_rank_only_accepts_plain_release_ids() {
assert_eq!(release_rank("1.20.1"), Some((1, 20, 1)));
assert_eq!(release_rank("1.20"), Some((1, 20, 0)), "缺末段按 0 算");
assert_eq!(release_rank("24w14a"), None, "快照不是正式版号");
assert_eq!(release_rank("rd-132211"), None);
assert_eq!(
release_rank("1.20.1-pre3"),
None,
"预发布不许当正式版放过去"
);
assert_eq!(release_rank("1.20.1.2"), None, "多一段不认");
}
/// 兼容性真值表(任务书点名的「老版本 × NeoForge」在这条里)。
#[test]
fn loader_support_matrix_is_explicit() {
// 原版永远可以
for id in ["1.21.9", "1.12.2", "rd-132211"] {
let kind = if id.starts_with("rd") {
VersionKind::Ancient(AncientKind::Classic)
} else {
VersionKind::Release
};
assert_eq!(Loader::Vanilla.supports(id, kind), Ok(()));
}
// NeoForge 要 1.20.1+
assert_eq!(
Loader::NeoForge.supports("1.20.1", VersionKind::Release),
Ok(())
);
assert_eq!(
Loader::NeoForge.supports("1.12.2", VersionKind::Release),
Err(Incompat::NeedsAtLeast("1.20.1")),
"老版本 × NeoForge 必须在选中那一刻就说清,而不是点安装才失败"
);
// Fabric/Quilt 要 1.14+
assert_eq!(
Loader::Fabric.supports("1.20.1", VersionKind::Release),
Ok(())
);
assert_eq!(
Loader::Fabric.supports("1.12.2", VersionKind::Release),
Err(Incompat::NeedsAtLeast("1.14"))
);
// Forge 任何正式版都行,但预览版/远古版没有 maven 条目
assert_eq!(
Loader::Forge.supports("1.12.2", VersionKind::Release),
Ok(())
);
assert_eq!(
Loader::Forge.supports("24w14a", VersionKind::Snapshot),
Err(Incompat::Unsupported)
);
// 远古版:四家全都不支持
let ancient = ("a1.2.6", VersionKind::Ancient(AncientKind::OldAlpha));
for loader in [
Loader::Fabric,
Loader::Quilt,
Loader::Forge,
Loader::NeoForge,
] {
assert_eq!(
loader.supports(ancient.0, ancient.1),
Err(Incompat::Unsupported)
);
}
}
/// 不兼容的组合:给具名提示,并且**确认键按下不去**。
#[test]
fn incompatible_combination_disables_confirm_with_a_named_reason() {
let state = AppState::new();
let options = OptionsView::of(
&row("1.12.2", VersionKind::Release),
&state,
&draft(Loader::NeoForge),
Lang::En.catalog(),
);
assert!(!options.can_confirm, "不兼容就不许按下确认");
let problem = options.problem.expect("要给出具名原因");
assert!(problem.contains("NeoForge"), "{problem}");
assert!(problem.contains("1.20.1"), "{problem}");
}
/// 默认加载器版本 = 列表里最新的一条(core 的默认口径);用户选过就用用户的。
#[test]
fn loader_version_defaults_to_the_newest_and_honours_the_choice() {
let state = state_with(LoaderKind::Fabric, "1.20.1", Ok(vec!["0.16.5", "0.16.4"]));
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&state,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert!(options.can_confirm);
assert_eq!(options.loader_version, "0.16.5", "没选过就用最新");
assert_eq!(options.loader_version_arg(), Some("0.16.5"));
let mut chosen = draft(Loader::Fabric);
chosen.loader_version = "0.16.4".to_string();
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&state,
&chosen,
Lang::En.catalog(),
);
assert_eq!(options.loader_version, "0.16.4", "用户选的就是用户选的");
}
/// 三条「列表还没到位」的状态都不许按下确认,且各自说清是哪种。
#[test]
fn versions_pending_or_failed_blocks_confirm() {
let bare = AppState::new();
let loading = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&bare,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert_eq!(loading.versions, LoaderVersionsView::Loading);
assert!(!loading.can_confirm, "还没查过就不许装");
let failed = state_with(LoaderKind::Fabric, "1.20.1", Err("network down"));
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&failed,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert!(!options.can_confirm);
assert!(
options
.problem
.as_deref()
.is_some_and(|p| p.contains("network down")),
"取不到要给具名原因(不是空下拉)"
);
let empty = state_with(LoaderKind::Fabric, "1.20.1", Ok(vec![]));
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&empty,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert!(!options.can_confirm, "官方没有版本就不许装");
assert!(options.problem.is_some());
}
/// 缓存键带原版版本:A 版本的列表不会挂到 B 版本下面。
#[test]
fn cached_versions_are_keyed_by_minecraft_version() {
let state = state_with(LoaderKind::Fabric, "1.20.1", Ok(vec!["0.16.5"]));
let other = OptionsView::of(
&row("1.21.9", VersionKind::Release),
&state,
&draft(Loader::Fabric),
Lang::En.catalog(),
);
assert_eq!(
other.versions,
LoaderVersionsView::Loading,
"换原版版本就得重新查,不许复用上一版的结果"
);
}
/// 原版:不查列表、不需要加载器版本,确认键直接可用。
#[test]
fn vanilla_needs_no_loader_version_at_all() {
let state = AppState::new();
let options = OptionsView::of(
&row("1.20.1", VersionKind::Release),
&state,
&draft(Loader::Vanilla),
Lang::En.catalog(),
);
assert!(options.can_confirm);
assert_eq!(options.versions, LoaderVersionsView::Ready(Vec::new()));
assert_eq!(options.loader_version, "");
assert_eq!(options.loader_version_arg(), None);
}
/// 已装的版本:确认键给「已安装」禁用态(列表行上也有勾,按钮再点一次不会更清楚)。
#[test]
fn installed_versions_cannot_be_confirmed_again() {
let state = AppState::new();
let mut installed = row("1.20.1", VersionKind::Release);
installed.installed = true;
let options = OptionsView::of(
&installed,
&state,
&draft(Loader::Vanilla),
Lang::En.catalog(),
);
assert!(options.installed);
assert!(!options.can_confirm);
}
@@ -0,0 +1,213 @@
//! 选项区的控件树(M105)。
//!
//! ## 版面(424 正文列里的四段)
//!
//! ```text
//! 1.21.9 [安装]
//! 安装选项
//! [原版][Forge][NeoForge]
//! [Fabric][Quilt]
//! 加载器版本 ← 只在选了加载器时出现
//! ┌ 0.16.5 ┐ ← 官方那份列表(menu + 滚动,最多看两行)
//! │ 0.16.4 │
//! └───────────────────┘
//! [实例名 ]
//! NeoForge 需要 Minecraft 1.20.1 或更新 ← 不兼容时(Error 色)
//! ```
//!
//! ## 为什么加载器分成两行
//!
//! 五家一起排在 424 宽里最宽的那组(`原版 + Forge + NeoForge + Fabric + Quilt`)实测会溢出,
//! 而 chip 的几何是组件自己的真源(`ChipGeometry`),页面不许压它 ⇒ 折成两行。
//! 顺序仍是任务书的口径(原版 / Forge / NeoForge / Fabric / Quilt),只是换行。
//!
//! ## 为什么「不兼容」不禁用 chip、而是禁用确认键
//!
//! 禁用的 chip 点不动,用户就**看不到原因**(那正是「选完才失败」的另一种形态)。
//! 现在的做法:五家永远都能点,选中不兼容的那家时下面出现一行具名原因,同时「安装」变成禁用态。
//! 于是「为什么不能装」和「怎么才能装」永远同屏。
use iced::widget::{column, container, row, scrollable};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
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::{menu, Menu, MenuItem};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, Chip};
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::model::{Loader, LoaderVersionsView, OptionsView};
/// 五家加载器(一行放得下:实测五枚 chip 合计约 311 dp,正文列 424)。
const LOADERS: [Loader; 5] = [
Loader::Vanilla,
Loader::Forge,
Loader::NeoForge,
Loader::Fabric,
Loader::Quilt,
];
/// 菜单项高度 = 48(`MenuGeometry` 的项高)×2 行 + 上下留白 8×2 = 112。
/// 这么算是为了避免「列表被截掉半行」——滚动区高度必须覆盖整行。
const VERSION_ROWS: f32 = 7.0;
/// 选项区(面板的下半块)。
pub(crate) fn section<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
) -> Element<'a, Message> {
let head = row![
container(frame::text_role(
ui,
TypeRole::BodyMedium,
false,
options.version.as_str(),
ui.color(Role::OnSurface),
))
.width(Length::Fill),
confirm(ui, catalog, options),
]
.width(Length::Fill)
.align_y(Alignment::Center);
let mut body = column![
head,
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsTitle),
ui.color(Role::OnSurfaceVariant),
),
loader_row(ui, catalog, options),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 「加载器版本」这一段只在**真该有列表**时出现:原版没有、静态不兼容没有(原因在下面那行)、
// 取不到也没有(同样在原因行)。留一个空标题只会让人以为界面缺了一块。
if show_versions(options) {
body = body.push(frame::text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::DownloadOptionsLoaderVersion),
ui.color(Role::OnSurfaceVariant),
));
body = body.push(version_list(ui, catalog, options));
}
body = body.push(text_field(
ui,
Field::new(options.instance_name.as_str())
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::DownloadOptionsInstanceName))
.placeholder(catalog.text(Key::DownloadOptionsInstanceNameHint)),
Message::SetInstanceName,
));
if let Some(problem) = &options.problem {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
problem.as_str(),
ui.color(Role::Error),
));
}
body.into()
}
/// 加载器版本这一段要不要画(见 `section` 的注释)。
fn show_versions(options: &OptionsView) -> bool {
if options.loader == Loader::Vanilla || options.problem.is_some() {
return false;
}
match &options.versions {
LoaderVersionsView::Loading => true,
LoaderVersionsView::Ready(items) => !items.is_empty(),
LoaderVersionsView::Failed(_) => false,
}
}
/// 一行加载器 chip(Filter 档:选中 = `SecondaryContainer`)。
fn loader_row<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Element<'a, Message> {
let mut line = row![]
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center);
for loader in LOADERS.iter() {
line = line.push(chip(
ui,
Chip::filter(
loader.label(catalog),
options.loader == *loader,
Message::SetLoader(*loader),
),
));
}
line.into()
}
/// 官方那份加载器版本列表(`menu` + 滚动:Fabric 有上百条,不做下拉也不铺满屏)。
fn version_list<'a>(
ui: &'a Ui,
catalog: Catalog,
options: &'a OptionsView,
) -> Element<'a, Message> {
match &options.versions {
LoaderVersionsView::Loading => frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::DownloadOptionsLoaderVersionsLoading),
ui.color(Role::OnSurfaceVariant),
),
// 取不到 / 一条都没有:原因已经在上面那行 `problem` 里说清,这里不再重复。
LoaderVersionsView::Failed(_) => frame::gap(0.0),
LoaderVersionsView::Ready(items) if items.is_empty() => frame::gap(0.0),
LoaderVersionsView::Ready(items) => {
let mut list = Menu::new().width(Length::Fill);
for item in items {
let mut entry =
MenuItem::new(item.as_str(), Message::SetLoaderVersion(item.clone()));
if *item == options.loader_version {
entry = entry.selected();
}
list = list.item(entry);
}
scrollable(menu(ui, list))
.height(Length::Fixed(ui.space(Space::X4) * VERSION_ROWS))
.style(frame::scroll_style(ui))
.into()
}
}
}
/// 「安装」:不兼容 / 列表没到位 / 已装 都按下不去(禁用态保留几何,只清消息)。
fn confirm<'a>(ui: &'a Ui, catalog: Catalog, options: &'a OptionsView) -> Element<'a, Message> {
if options.installed {
return buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstalled)));
}
if !options.can_confirm {
return buttons::outlined(ui, Button::disabled(catalog.text(Key::DownloadInstall)));
}
buttons::filled(
ui,
Button::new(
catalog.text(Key::DownloadInstall),
Message::InstallVersion(options.version.clone()),
)
.leading(IconKind::Download),
)
}
+35 -81
View File
@@ -5,7 +5,7 @@
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
use unlucky_i18n::{Key, Lang};
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
AncientKind, AppEvent, CoreEvent, InstallProgressView, InstallStage, VersionCategory,
VersionKind, VersionSummary,
@@ -17,6 +17,9 @@ use crate::theme::{Families, Ui};
use super::super::layout::{content_column_width, frame_metrics, measure_cap_px, PaneKit};
use super::super::Message;
use super::model::{Category, Download};
use super::options::InstallDraft;
mod i18n;
const FAMILIES: Families = Families {
text: "Segoe UI",
@@ -101,7 +104,12 @@ fn the_snapshot_becomes_the_page_model() {
},
))));
let model = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
let model = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert_eq!(model.rows.len(), 2);
assert_eq!(model.rows[0].id, "1.21.9");
@@ -142,14 +150,24 @@ fn presenter_filters_still_apply() {
let installed_only = {
state.installed_only = true;
Download::from_snapshot(&state, Category::All, Lang::En.catalog())
Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
)
};
assert_eq!(installed_only.rows.len(), 2, "仅已装");
let queried = {
state.installed_only = false;
state.download_query = " 1.21 ".to_string();
Download::from_snapshot(&state, Category::All, Lang::En.catalog())
Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
)
};
assert_eq!(queried.rows.len(), 2, "版本号片段匹配两个");
assert!(queried.rows.iter().all(|row| row.id.starts_with("1.21")));
@@ -159,12 +177,22 @@ fn presenter_filters_still_apply() {
/// 抓不住:真机上卡在加载态多久(没有超时信号,见模块文档的缺口表)。
#[test]
fn loading_and_empty_are_different_states() {
let loading = Download::from_snapshot(&AppState::new(), Category::All, Lang::En.catalog());
let loading = Download::from_snapshot(
&AppState::new(),
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(loading.is_loading() && !loading.is_empty());
let mut state = state_with(vec![version("1.21.9", VersionKind::Release, false)]);
state.download_query = "no-such-version".to_string();
let empty = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
let empty = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
assert!(!empty.is_loading(), "列表拉到了,不是加载中");
assert!(empty.is_empty(), "过滤后没有行 = 空态");
}
@@ -213,81 +241,6 @@ fn category_commands_are_real() {
assert!(!Category::Release.matches(VersionKind::Snapshot));
}
// --------------------------------------------------------------- 文案(i18n)
/// 每个 `Download*` key 都**真的被页面引用**(死 key 守门,M94 r1 / P2-6)。
///
/// 复审抓到的 `DownloadSelected` 就是这条要拦的东西:目录里定义了、页面没人用。所以这里把页面
/// 源码用 `include_str!` 编进来扫一遍。**抓不住什么也说清**:反向(引用了不存在的 key)是编译错误,
/// 不需要测试;译文对不对、两套语言都填了没由 `i18n` 自己的 `built_in_catalogs_have_every_key` 守;
/// 它只看字面出现,用宏拼 key 名能骗过它。
#[test]
fn every_download_key_is_referenced_by_the_page() {
const SOURCES: [&str; 6] = [
include_str!("../download.rs"),
include_str!("model.rs"),
include_str!("rows.rs"),
include_str!("install.rs"),
include_str!("states.rs"),
include_str!("demo.rs"),
];
let mut checked = 0;
for key in Key::ALL
.iter()
.filter(|key| key.name().starts_with("Download"))
{
let needle = format!("Key::{}", key.name());
assert!(
SOURCES.iter().any(|source| source.contains(&needle)),
"{needle} 在目录里定义了,但下载页没有任何地方用它(死 key,删掉或接上)"
);
checked += 1;
}
assert!(
checked > 20,
"下载页的 key 不该被成批删掉(只查到 {checked} 条)"
);
}
/// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。
///
/// 抓得住:页面里又出现硬编码英文/中文字面量、或把 `InstallStage::label()` / `progress.message`
/// (core 的中文串)画到界面上。
/// 抓不住:目录里那条译文本身写错了。
#[test]
fn the_model_speaks_the_catalog_language() {
let versions = vec![version("1.21.9", VersionKind::Release, false)];
let state = state_with(versions);
let en = Download::from_snapshot(&state, Category::All, Lang::En.catalog());
let zh = Download::from_snapshot(&state, Category::All, Lang::ZhCn.catalog());
assert_eq!(en.rows[0].supporting, "Release · released 2026-09-20");
assert_eq!(zh.rows[0].supporting, "正式版 · 发布于 2026-09-20");
let progress = InstallProgressView {
version: "1.21.9".to_string(),
stage: InstallStage::Planning,
message: "正在解析版本清单…".to_string(),
..InstallProgressView::new()
};
let install_en = super::model::InstallView::of(&progress, Lang::En.catalog());
assert_eq!(install_en.stage_label, "Planning");
assert_eq!(install_en.detail, "Waiting for the first file…");
assert!(
!install_en.stage_label.contains('解'),
"core 的中文阶段串不许直接上屏"
);
assert!(
!install_en.detail.contains('正'),
"`InstallProgressView::message` 是中文业务串,页面不许画它"
);
let install_zh = super::model::InstallView::of(&progress, Lang::ZhCn.catalog());
assert_eq!(install_zh.stage_label, "解析版本");
}
// --------------------------------------------------------------- 控件树
/// 五个状态都能构建出控件树(分支走到、不 panic、消息类型对得上)。
@@ -309,6 +262,7 @@ fn every_state_builds_a_tree() {
};
let ready = Download {
options: None,
rows: vec![super::model::VersionRow::of(
&version("1.21.9", VersionKind::Release, false),
catalog,
@@ -0,0 +1,97 @@
//! 下载页的**文案守门**(M105 从 `download/tests.rs` 搬出:那边加了安装选项的用例之后顶破 400 行)。
//!
//! 两条各守一件事:死 key(目录里有、页面没用)与「模型说的是目录的语言」。
use unlucky_i18n::{Key, Lang};
use unlucky_presenter::dto::VersionKind;
use super::*;
// --------------------------------------------------------------- 文案(i18n)
/// 每个 `Download*` key 都**真的被页面引用**(死 key 守门,M94 r1 / P2-6)。
///
/// 复审抓到的 `DownloadSelected` 就是这条要拦的东西:目录里定义了、页面没人用。所以这里把页面
/// 源码用 `include_str!` 编进来扫一遍。**抓不住什么也说清**:反向(引用了不存在的 key)是编译错误,
/// 不需要测试;译文对不对、两套语言都填了没由 `i18n` 自己的 `built_in_catalogs_have_every_key` 守;
/// 它只看字面出现,用宏拼 key 名能骗过它。
#[test]
fn every_download_key_is_referenced_by_the_page() {
const SOURCES: [&str; 8] = [
include_str!("../../download.rs"),
include_str!("../model.rs"),
include_str!("../rows.rs"),
include_str!("../install.rs"),
include_str!("../states.rs"),
include_str!("../demo.rs"),
// M105:安装选项区的两条填页线(漏扫的话新 key 会被判成死 key)
include_str!("../options/model.rs"),
include_str!("../options/view.rs"),
];
let mut checked = 0;
for key in Key::ALL
.iter()
.filter(|key| key.name().starts_with("Download"))
{
let needle = format!("Key::{}", key.name());
assert!(
SOURCES.iter().any(|source| source.contains(&needle)),
"{needle} 在目录里定义了,但下载页没有任何地方用它(死 key,删掉或接上)"
);
checked += 1;
}
assert!(
checked > 20,
"下载页的 key 不该被成批删掉(只查到 {checked} 条)"
);
}
/// 页面级的文案全部按 `Catalog` 走:中文目录出中文,英文目录出英文,**且不含 core 的中文业务串**。
///
/// 抓得住:页面里又出现硬编码英文/中文字面量、或把 `InstallStage::label()` / `progress.message`
/// (core 的中文串)画到界面上。
/// 抓不住:目录里那条译文本身写错了。
#[test]
fn the_model_speaks_the_catalog_language() {
let versions = vec![version("1.21.9", VersionKind::Release, false)];
let state = state_with(versions);
let en = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::En.catalog(),
);
let zh = Download::from_snapshot(
&state,
Category::All,
&InstallDraft::default(),
Lang::ZhCn.catalog(),
);
assert_eq!(en.rows[0].supporting, "Release · released 2026-09-20");
assert_eq!(zh.rows[0].supporting, "正式版 · 发布于 2026-09-20");
let progress = InstallProgressView {
version: "1.21.9".to_string(),
stage: InstallStage::Planning,
message: "正在解析版本清单…".to_string(),
..InstallProgressView::new()
};
let install_en = crate::pages::download::model::InstallView::of(&progress, Lang::En.catalog());
assert_eq!(install_en.stage_label, "Planning");
assert_eq!(install_en.detail, "Waiting for the first file…");
assert!(
!install_en.stage_label.contains('解'),
"core 的中文阶段串不许直接上屏"
);
assert!(
!install_en.detail.contains('正'),
"`InstallProgressView::message` 是中文业务串,页面不许画它"
);
let install_zh =
crate::pages::download::model::InstallView::of(&progress, Lang::ZhCn.catalog());
assert_eq!(install_zh.stage_label, "解析版本");
}
+12 -2
View File
@@ -10,7 +10,7 @@
use unlucky_presenter::router::Route;
use super::appearance;
use super::download::Category;
use super::download::{Category, Loader};
use super::settings;
/// 页面消息。
@@ -34,8 +34,18 @@ pub enum Message {
Appearance(appearance::Change),
/// 选中下载页的一个版本(M94):**页面内选中**,app 层只改视图模型,不 dispatch。
SelectVersion(String),
/// 安装指定版本(M94;app 层翻成 `AppCommand::InstallVersion`)。
/// **确认安装**选中的版本(用当前安装选项)。
///
/// M105 起语义变了:以前是「立刻装原版」(`AppCommand::InstallVersion`),现在是
/// 「用选项区里选好的加载器 / 加载器版本 / 实例名开工」(`AppCommand::CreateInstance`)。
/// 载荷仍是版本 id,app 层拿它去草稿里取其余三项。
InstallVersion(String),
/// 改安装选项的加载器(M105;app 层改草稿,必要时派 `AppCommand::ListLoaderVersions`)。
SetLoader(Loader),
/// 改加载器版本(M105;空串 = 用官方最新)。
SetLoaderVersion(String),
/// 改实例名(M105;空串 = 用版本号)。
SetInstanceName(String),
/// 取消正在跑的安装(M94;`AppCommand::CancelInstall`)。
CancelInstall,
/// 切换下载页分类(M94;app 层翻成 `AppCommand::ListVersions`,`All` 只画现有快照)。
+1 -1
View File
@@ -123,7 +123,7 @@ pub(crate) mod settings;
#[cfg(test)]
mod tests;
pub use download::{Category, Download};
pub use download::{Category, Download, InstallDraft, Loader};
pub use layout::{FrameMetrics, PageLayout, PaneKit, WidthClass};
pub use logs::{Confidence, Finding, Level, Line, Logs, Outcome};
pub use message::{Action, Message};
+101 -149
View File
@@ -17,160 +17,38 @@
//! **不整体回滚**:已下好的共享层文件(libraries/assets)留在原地复用,失败信息逐文件结构化
//! 返回,由调用方决定是重试、还是只提示"缺哪几个文件"。这与 analyse/03 §6.3 的"宁可给出
//! 可修复的骨架"一致,但**版本清单与实例登记只在全量成功后才写**,不会出现半实例。
//!
//! # 取消(M105:GUI 的「取消安装」要**真停**)
//!
//! [`execute`] 与 CLI 的契约**一字不变**(不传取消标志 = 今天的行为);
//! 取消走 [`execute_with_cancel`],判据三条:
//!
//! 1. **不再起新文件**:每个文件开工前看一眼标志,置位就不进 `install_one`;
//! 2. **在飞的文件就地掐断**:`install_one` 与「取消 future」`select` 赛跑,
//! 取消赢就 drop 掉下载 future —— reqwest 的响应流随之关闭(socket 断开),
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾);
//! 3. **半成品不许变成品**:取消后不再写版本 JSON,返回
//! [`InstallErrorKind::Cancelled`](super::InstallErrorKind),调用方(presenter)据此把阶段
//! 记成「已取消」而不是「失败」。
//!
//! 于是取消延迟 = **一个数据块的唤醒**(不是「等一个文件下完」,也不是「等这一段跑完」)。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::future::poll_fn;
use std::path::Path;
use std::pin::pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::Poll;
use futures_util::future::{select, Either};
use futures_util::stream::{self, StreamExt};
use super::plan::{FileRole, PlanFile};
use super::InstallPlan;
use super::plan::PlanFile;
use super::{InstallError, InstallErrorKind, InstallPlan};
use crate::download::verify;
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
use crate::platform::atomic::write_atomic;
/// 单个文件的结局。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
/// 本次真的下载(或从缓存直取)并落盘。
Downloaded,
/// 已存在且校验通过,跳过。
Skipped,
/// 失败(原因在 [`FileOutcome::failure`])。
Failed,
}
impl FileStatus {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Downloaded => "downloaded",
Self::Skipped => "skipped",
Self::Failed => "failed",
}
}
}
/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileFailure {
/// 下载错误分类。
pub kind: DownloadErrorKind,
/// 人读说明。
pub message: String,
}
/// 一个文件的执行结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileOutcome {
/// 角色。
pub role: FileRole,
/// 人读标识。
pub name: String,
/// 落盘路径。
pub dest: std::path::PathBuf,
/// 结局。
pub status: FileStatus,
/// 实际字节数(跳过时为盘上大小)。
pub bytes: u64,
/// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
pub source: Option<String>,
/// 失败详情。
pub failure: Option<FileFailure>,
}
impl FileOutcome {
/// 是否失败。
#[must_use]
pub fn failed(&self) -> bool {
self.status == FileStatus::Failed
}
}
/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
///
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
/// 一次安装的执行结果。
#[derive(Debug, Clone)]
pub struct InstallReport {
/// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
pub files: Vec<FileOutcome>,
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 [`execute`] 的说明)。
pub version_json: Option<PathBuf>,
/// 计划阶段带来的告警(缺件降级说明)。
pub warnings: Vec<String>,
}
impl InstallReport {
/// 真正下载的文件数。
#[must_use]
pub fn downloaded(&self) -> usize {
self.files
.iter()
.filter(|file| file.status == FileStatus::Downloaded)
.count()
}
/// 跳过(已存在且校验通过)的文件数。
#[must_use]
pub fn skipped(&self) -> usize {
self.files
.iter()
.filter(|file| file.status == FileStatus::Skipped)
.count()
}
/// 失败的文件数。
#[must_use]
pub fn failed(&self) -> usize {
self.files.iter().filter(|file| file.failed()).count()
}
/// 本次写入的字节数(只算真正下载的)。
#[must_use]
pub fn bytes(&self) -> u64 {
self.files
.iter()
.filter(|file| file.status == FileStatus::Downloaded)
.map(|file| file.bytes)
.sum()
}
/// 是否全部就绪(无失败)。
#[must_use]
pub fn is_complete(&self) -> bool {
self.failed() == 0
}
/// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
#[must_use]
pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
self.files
.iter()
.filter(|file| file.failed())
.take(limit)
.collect()
}
/// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
#[must_use]
pub fn failure_kind(&self) -> Option<DownloadErrorKind> {
let mut fallback = None;
for file in self.files.iter().filter(|file| file.failed()) {
let Some(failure) = &file.failure else {
continue;
};
if failure.kind == DownloadErrorKind::Integrity {
return Some(DownloadErrorKind::Integrity);
}
fallback.get_or_insert(failure.kind);
}
fallback
}
}
pub use super::report::{FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
/// 执行安装计划。
///
@@ -191,6 +69,28 @@ pub async fn execute(
concurrency: usize,
sink: Option<ProgressSink>,
) -> Result<InstallReport, super::InstallError> {
execute_with_cancel(engine, plan, concurrency, sink, None).await
}
/// 同 [`execute`],但可以带一个**取消标志**(M105)。
///
/// `cancel = None` 与 [`execute`] 逐字等价(CLI 走这条);`Some(flag)` 时:
/// 置位就不再起新文件、并就地掐断在飞的文件,最后返回
/// [`InstallErrorKind::Cancelled`](**不写版本 JSON**)。
///
/// # Errors
///
/// 同 [`execute`];另外取消时返回 `Cancelled` 大类。
pub async fn execute_with_cancel(
engine: &DownloadEngine,
plan: &InstallPlan,
concurrency: usize,
sink: Option<ProgressSink>,
cancel: Option<&AtomicBool>,
) -> Result<InstallReport, super::InstallError> {
if is_cancelled(cancel) {
return Err(cancelled_error());
}
let mut outcomes: Vec<FileOutcome> = Vec::with_capacity(plan.files.len());
let mut prefetched: Vec<&PlanFile> = Vec::new();
@@ -260,18 +160,38 @@ pub async fn execute(
.map(|file| {
let sink = sink.clone();
async move {
let outcome = install_one(engine, file).await;
if let Some(sink) = &sink {
sink(&outcome);
// 取消后不再起新文件;在飞的那个与取消 future 赛跑,取消赢就 drop 掉下载 future
// (响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉),于是取消延迟 = 一个数据块。
let outcome = match cancel {
Some(flag) => {
let download = install_one(engine, file);
let abort = cancelled(flag);
let mut download = pin!(download);
let mut abort = pin!(abort);
match select(download.as_mut(), abort.as_mut()).await {
Either::Left((outcome, _)) => Some(outcome),
Either::Right(((), _)) => None,
}
}
None => Some(install_one(engine, file).await),
};
if let (Some(sink), Some(outcome)) = (&sink, &outcome) {
sink(outcome);
}
outcome
}
})
.buffer_unordered(concurrency.max(1))
.filter_map(|outcome| async move { outcome })
.collect()
.await;
outcomes.extend(downloaded);
// 取消了就到此为止:**不写版本清单**(否则半成品会被 `is_version_installed()` 当成已安装)。
if is_cancelled(cancel) {
return Err(cancelled_error());
}
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
let complete = outcomes.iter().all(|outcome| !outcome.failed());
@@ -289,6 +209,38 @@ pub async fn execute(
})
}
/// 标志是否已置位(`None` = 没有取消通道,永远 false)。
///
/// 用 `SeqCst` 与 presenter 侧 `store` 的强度一致:这里不是性能热点(每个文件一次),
/// 换更强的顺序只买一个「不给自己留疑问」。
fn is_cancelled(cancel: Option<&AtomicBool>) -> bool {
cancel.is_some_and(|flag| flag.load(Ordering::SeqCst))
}
/// 取消 future:标志一置位就 `Ready`,否则永远 `Pending`(不注册 waker)。
///
/// 为什么敢用「不注册 waker 的 Pending」:它只跟下载 future 一起被 `select` 轮询,
/// 下载每读到一个数据块就会唤醒 executor、连带重新 poll 这个 future,所以取消延迟
/// 天然是「一个数据块」;真正卡死的连接另有读超时兜底(`chunked` 的 30 s)。
async fn cancelled(flag: &AtomicBool) {
poll_fn(|_| {
if flag.load(Ordering::SeqCst) {
Poll::Ready(())
} else {
Poll::Pending
}
})
.await;
}
/// 取消的稳定错误(大类 `Cancelled`,不是失败)。
fn cancelled_error() -> InstallError {
InstallError::new(
InstallErrorKind::Cancelled,
"已取消:剩下的文件没有开始下载,已下好的文件保留(重装续用)",
)
}
/// 单文件:先判"已就绪",否则走引擎下载。
async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
if let Some(bytes) = current_bytes(file) {
+8 -1
View File
@@ -32,14 +32,16 @@ pub mod instance;
pub mod libraries;
pub mod plan;
pub mod plan_view;
mod report;
pub mod source;
use std::fmt;
pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
pub use execute::{execute, FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
pub use execute::{execute, execute_with_cancel};
pub use instance::{register, InstanceConfig, Registration};
pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
pub use report::{FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
use crate::download::{DownloadError, DownloadErrorKind};
@@ -58,6 +60,10 @@ pub enum InstallErrorKind {
Io,
/// 元数据解析失败(清单/版本 JSON/资源索引坏了)。
Metadata,
/// 用户取消(`execute_with_cancel` 看到取消标志;不是失败,也不是网络问题)。
///
/// CLI 永远走 [`execute`](不传取消标志),所以这个大类只可能从 GUI 的取消路径出来。
Cancelled,
}
impl InstallErrorKind {
@@ -70,6 +76,7 @@ impl InstallErrorKind {
Self::Integrity => "install_integrity",
Self::Io => "install_io",
Self::Metadata => "install_metadata",
Self::Cancelled => "install_cancelled",
}
}
}
+154
View File
@@ -0,0 +1,154 @@
//! 安装报告与进度回调的形状(M105 从 `execute.rs` 搬出,守 400 行硬线)。
//!
//! 纯数据 + 纯方法:`execute.rs`(执行)与 CLI(汇总打印)都读它,谁也不改它。
//! 搬移是**逐字**的,只有 `use` 断了回来(原来靠 `execute.rs` 的 import 活着)。
use std::path::PathBuf;
use std::sync::Arc;
use crate::download::DownloadErrorKind;
use super::plan::FileRole;
/// 单个文件的结局。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
/// 本次真的下载(或从缓存直取)并落盘。
Downloaded,
/// 已存在且校验通过,跳过。
Skipped,
/// 失败(原因在 [`FileOutcome::failure`])。
Failed,
}
impl FileStatus {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Downloaded => "downloaded",
Self::Skipped => "skipped",
Self::Failed => "failed",
}
}
}
/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileFailure {
/// 下载错误分类。
pub kind: DownloadErrorKind,
/// 人读说明。
pub message: String,
}
/// 一个文件的执行结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileOutcome {
/// 角色。
pub role: FileRole,
/// 人读标识。
pub name: String,
/// 落盘路径。
pub dest: std::path::PathBuf,
/// 结局。
pub status: FileStatus,
/// 实际字节数(跳过时为盘上大小)。
pub bytes: u64,
/// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
pub source: Option<String>,
/// 失败详情。
pub failure: Option<FileFailure>,
}
impl FileOutcome {
/// 是否失败。
#[must_use]
pub fn failed(&self) -> bool {
self.status == FileStatus::Failed
}
}
/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
///
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
/// 一次安装的执行结果。
#[derive(Debug, Clone)]
pub struct InstallReport {
/// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
pub files: Vec<FileOutcome>,
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。
pub version_json: Option<PathBuf>,
/// 计划阶段带来的告警(缺件降级说明)。
pub warnings: Vec<String>,
}
impl InstallReport {
/// 真正下载的文件数。
#[must_use]
pub fn downloaded(&self) -> usize {
self.files
.iter()
.filter(|file| file.status == FileStatus::Downloaded)
.count()
}
/// 跳过(已存在且校验通过)的文件数。
#[must_use]
pub fn skipped(&self) -> usize {
self.files
.iter()
.filter(|file| file.status == FileStatus::Skipped)
.count()
}
/// 失败的文件数。
#[must_use]
pub fn failed(&self) -> usize {
self.files.iter().filter(|file| file.failed()).count()
}
/// 本次写入的字节数(只算真正下载的)。
#[must_use]
pub fn bytes(&self) -> u64 {
self.files
.iter()
.filter(|file| file.status == FileStatus::Downloaded)
.map(|file| file.bytes)
.sum()
}
/// 是否全部就绪(无失败)。
#[must_use]
pub fn is_complete(&self) -> bool {
self.failed() == 0
}
/// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
#[must_use]
pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
self.files
.iter()
.filter(|file| file.failed())
.take(limit)
.collect()
}
/// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
#[must_use]
pub fn failure_kind(&self) -> Option<DownloadErrorKind> {
let mut fallback = None;
for file in self.files.iter().filter(|file| file.failed()) {
let Some(failure) = &file.failure else {
continue;
};
if failure.kind == DownloadErrorKind::Integrity {
return Some(DownloadErrorKind::Integrity);
}
fallback.get_or_insert(failure.kind);
}
fallback
}
}
+47 -1
View File
@@ -14,7 +14,9 @@ use crate::download::{
};
use crate::game::arguments::Environment;
use crate::game::install::assets::{AssetIndex, AssetObject};
use crate::game::install::execute::{current_bytes, execute, FileOutcome, FileStatus};
use crate::game::install::execute::{
current_bytes, execute, execute_with_cancel, FileOutcome, FileStatus,
};
use crate::game::install::plan::{FileRole, InstallPlan, PlanFile, PlanInput};
use crate::game::layout::GameLayout;
use crate::game::manifest::VersionJson;
@@ -168,6 +170,50 @@ async fn skips_existing_files_and_writes_version_json() {
std::fs::remove_dir_all(&root).ok();
}
/// **取消:一个文件都不下、版本清单不落盘**(M105 的「半成品不许变成品」)。
///
/// 标志**开工前**就置位 ⇒ 走「一个文件都不起」那条路,全程零网络(所以这条能进 CI,
/// 真网络的那条在 `crates/presenter/src/host/cancel_tests.rs`,`#[ignore]` 守着)。
///
/// 抓得住:取消返回 `Cancelled` 大类、`version_json` 没写、盘上没多出 jar。
/// 抓不住:在飞的文件被掐断的那一刻(那要真网络 + 真字节流,见 presenter 的实测装置)。
#[tokio::test]
async fn cancelling_before_the_first_file_downloads_nothing_and_writes_no_manifest() {
let root = temp_root("cancel");
let layout = GameLayout::new(&root);
// jar **不在**盘上:真跑就会去下(本用例不许它跑起来)
let sha1 = "3f".repeat(20);
let resolved = VersionJson::from_json_str(&format!(
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
"downloads": {{"client": {{"sha1": "{sha1}", "size": 32, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#
))
.expect("清单应可解析");
let plan = InstallPlan::build(PlanInput {
layout: &layout,
version_id: "1.20.1",
resolved: &resolved,
asset_index: None,
environment: &Environment::host(),
})
.expect("组计划应成功");
let cancel = std::sync::atomic::AtomicBool::new(true);
let error = execute_with_cancel(&engine(&root), &plan, 4, None, Some(&cancel))
.await
.expect_err("取消了就不要返回 report");
assert_eq!(
error.kind(),
crate::game::install::InstallErrorKind::Cancelled,
"取消是独立大类,不是网络/IO 失败"
);
assert!(
!plan.version_json_path.is_file(),
"取消后不许落版本清单(否则半成品会被当成已安装)"
);
assert!(!layout.version_jar("1.20.1").is_file(), "一个文件都不该下");
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn reinstalling_twice_is_idempotent() {
let root = temp_root("idempotent");
+28
View File
@@ -58,6 +58,34 @@ macro_rules! download_entries {
),
DownloadOpenLogs => ("Open logs", "打开日志"),
DownloadDismiss => ("Dismiss", "关闭"),
// --- 安装选项(M105)---
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} 或更新"
),
} }
};
}
+9
View File
@@ -77,6 +77,15 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("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} 或更新"),
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
("LogsOpenFolder", "Open folder", "打开目录"),
+21 -2
View File
@@ -10,8 +10,8 @@ use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::CreateRequest;
use crate::dto::{
AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogOutcome, LogSession, NoticeView,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession,
NoticeView, SessionId, SettingGroupView, VersionCategory, VersionSummary,
};
use crate::toast::Toast;
@@ -71,6 +71,18 @@ pub enum CoreEvent {
JavaChanged(JavaPageView),
/// 安装进度(`None` = 清掉进度面板)
InstallProgress(Option<InstallProgressView>),
/// 某 (加载器, 原版版本) 可用的加载器版本列表(下载页「安装选项」的版本下拉)。
///
/// `result` 用 `Result` 而不是空列表:**取不到**与**确实没有**是两件事,
/// 前者要给具名错误(不许让下拉显示成空还以为能装),后者是「这个组合官方就没有版本」。
LoaderVersions {
/// 加载器种类
kind: LoaderKind,
/// 目标原版版本
mc_version: String,
/// `Ok(最新在前)` / `Err(人读原因)`
result: Result<Vec<String>, String>,
},
/// 设置分组刷新(设置页右栏)
SettingsChanged(Vec<SettingGroupView>),
/// 横幅通知刷新(隔离切换提示/启动失败等)
@@ -110,6 +122,13 @@ pub enum AppCommand {
CancelInstall,
/// 新建实例(向导提交:版本 + 加载器 + 名字)
CreateInstance(CreateRequest),
/// 拉取某原版版本可用的加载器版本(下载页「安装选项」的版本下拉)
ListLoaderVersions {
/// 加载器种类
kind: LoaderKind,
/// 目标原版版本(`1.20.1`)
mc_version: String,
},
/// 修改实例隔离档位(写实例设置文件)
SetIsolation {
/// 实例 id
+330
View File
@@ -0,0 +1,330 @@
//! 取消安装的**实测装置**(M105)。
//!
//! ## 为什么这条测试要真起一次安装
//!
//! 「点取消没反应」有三种可能的断点,读码分不出来:
//! ① 按钮/消息链断了(不派 `AppCommand::CancelInstall`);
//! ② host 收下取消后,**还在跑的任务**继续发进度事件,把面板又点亮(面板闪一下又回来);
//! ③ 取消根本停不下传输(M105 之前 core 的 `execute` 没有取消入参,下载线程会跑到段尾)。
//!
//! ①② 能靠单测的假事件流抓;③ 只有真起一次安装、真取消、真量「取消之后还在发生什么」才抓得到。
//! 修完之后 ①②③ 都有守门:① 是 app 层的消息测试,② 是下面那条 CI 可跑的
//! [`progress_from_a_cancelled_job_is_dropped`],③ 就是本用例(真网络)。
//!
//! 所以这一条是**真网络**用例:`#[ignore]` 守着(CI 不跑),人工跑:
//!
//! ```text
//! cargo test -p unlucky-presenter --test '' -- real_install --ignored --nocapture
//! # 或(等价,本用例在 lib 测试里)
//! cargo test -p unlucky-presenter real_install_cancel -- --ignored --nocapture
//! ```
//!
//! ## 它量什么(三样里能抓住的两样)
//!
//! | 现象 | 怎么量 | 抓住什么 / 抓不住什么 |
//! |---|---|---|
//! | 进度还在推进 | 取消后收到的 `InstallProgress(Some)` 条数与 `files_done` 增量 | 抓得住「UI 侧还在被事件喂」;抓不住事件之外的后台动作 |
//! | 断点文件还在长 | `<game>/**/*.ullpart*` 的总字节,取消时 vs 取消后 20 s | 抓得住「网络/磁盘确实还在写」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) |
//! | 实例被登记 | `<game>/versions/<id>/<id>.json` 与 `.minecraft/instances` 有没有多出来 | 抓得住「取消后仍然落了成品」;抓不住「用户看不到的临时目录」 |
//!
//! 线程与 socket 两样**抓不住**(没有可移植的查询口,见 `probe` 一节),所以这里不用它当判据。
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstallProgressView, InstallStage};
use super::job::HostTask;
use super::{env::HostEnv, CoreHost};
/// 实测用的数据根(放在固定位置而不是系统 temp:便于人工复跑时留着看)。
fn root() -> PathBuf {
std::env::temp_dir().join("ull-m105-cancel")
}
/// 递归数 `.ullpart` 家族(中间文件 + 续传元数据)的总字节。
///
/// 目录很小(只有本次安装写的那几层),所以裸递归够用;读不动的一律当 0
/// (量的是趋势,不是精确值)。
fn part_bytes(dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0;
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_dir() {
total += part_bytes(&path);
} else if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
// 中间文件 `<name>.ullpart[-<pid>-<seq>]` 与它的元数据 `.ullmeta.json`
if name.contains(".ullpart") {
total += meta.len();
}
}
}
total
}
/// 一次 tick 后抽干事件,返回本轮的全部事件。
fn pump(host: &mut CoreHost, wait: Duration) -> Vec<AppEvent> {
host.tick(wait);
host.drain_events()
}
/// 事件里的安装进度(别的变体一概忽略)。
fn progress(event: &AppEvent) -> Option<&crate::dto::InstallProgressView> {
match event {
AppEvent::Core(CoreEvent::InstallProgress(Some(progress))) => Some(progress),
_ => None,
}
}
/// **实测主用例**:真起一次安装 → 取消 → 量取消之后还在发生什么。
///
/// 版本用环境变量 `ULL_CANCEL_VERSION` 覆盖(默认 `1.20.1`:文件多、总量够大,
/// 取消点之后还有一大截没下完,于是「取消后还在跑」一眼可见)。
#[test]
#[ignore = "真网络:真起一次安装再取消(需要 ULL_CANCEL_VERSION 指定的版本可下载)"]
fn real_install_cancel_stops_progress_and_leaves_no_half_product() {
let version = std::env::var("ULL_CANCEL_VERSION").unwrap_or_else(|_| "1.20.1".to_string());
let root = root();
std::fs::remove_dir_all(&root).ok();
let env = HostEnv::at_root(&root);
let game = env.game.root().to_path_buf();
let mut host = CoreHost::headless(env);
// ---- 起点:取消之前 ----
host.dispatch(AppCommand::InstallVersion(version.clone()));
let started = Instant::now();
let mut before_cancel_progress = 0_usize;
let mut done_at_cancel = 0_u64;
// 等到「真在下载」(files_done ≥ 2)再取消:取消得太早会落在 Planning/解析阶段,
// 那种取消本来就只是阶段边界停,证明不了下载中取消的行为。
while started.elapsed() < Duration::from_secs(180) {
for event in pump(&mut host, Duration::from_millis(100)) {
if let Some(progress) = progress(&event) {
before_cancel_progress += 1;
done_at_cancel = done_at_cancel.max(progress.files_done);
}
}
if done_at_cancel >= 2 {
break;
}
}
assert!(
done_at_cancel >= 2,
"180 s 内没进到下载阶段(files_done={done_at_cancel}):网络或版本号有问题,先让这条能跑起来"
);
let parts_at_cancel = part_bytes(&game);
// ---- 取消 ----
host.dispatch(AppCommand::CancelInstall);
let cancelled = Instant::now();
let mut after: Vec<(f32, u64, InstallStage, u64)> = Vec::new();
for event in host.drain_events() {
if let Some(progress) = progress(&event) {
after.push((
cancelled.elapsed().as_secs_f32(),
progress.files_done,
progress.stage,
part_bytes(&game),
));
}
}
// ---- 取消之后 20 s:还会不会被喂进度、断点文件还会不会长 ----
//
// 每秒采一次 `.ullpart` 总字节:判据不是「取消那一刻的瞬时值」,而是**它在 5 s 之后
// 不再变高**——在飞的那个文件本来就要多活一个数据块(取消延迟的定义),
// 5 s 是给它留的余量(远超一个数据块)。
let settle = Duration::from_secs(5);
let mut latest = None;
let mut samples: Vec<(f32, u64)> = Vec::new();
let mut next_sample = Duration::from_secs(1);
while cancelled.elapsed() < Duration::from_secs(20) {
for event in pump(&mut host, Duration::from_millis(100)) {
if let Some(progress) = progress(&event) {
latest = Some(progress.clone());
after.push((
cancelled.elapsed().as_secs_f32(),
progress.files_done,
progress.stage,
part_bytes(&game),
));
}
}
let elapsed = cancelled.elapsed();
if elapsed >= next_sample {
samples.push((elapsed.as_secs_f32(), part_bytes(&game)));
next_sample += Duration::from_secs(1);
}
std::thread::sleep(Duration::from_millis(20));
}
let parts_after = part_bytes(&game);
let done_after = latest
.as_ref()
.map_or(done_at_cancel, |view| view.files_done);
// ---- 取证:日志行 ----
eprintln!("[cancel-test] root={}", root.display());
eprintln!(
"[cancel-test] 取消前:进度事件 {before_cancel_progress} 条,files_done={done_at_cancel},.ullpart 合计 {parts_at_cancel} 字节"
);
eprintln!(
"[cancel-test] 取消后 20 s:进度事件 {} 条,files_done={done_after}(+{} 个文件),.ullpart 合计 {parts_after} 字节({:+})",
after.len(),
done_after.saturating_sub(done_at_cancel),
i64::try_from(parts_after).unwrap_or(i64::MAX) - i64::try_from(parts_at_cancel).unwrap_or(i64::MAX)
);
for (at, parts) in &samples {
eprintln!("[cancel-test] t={at:>5.1}s ullpart={parts}");
}
for (index, (at, done, stage, parts)) in after.iter().enumerate().take(12) {
eprintln!(
"[cancel-test] #{index} +{at:.2}s files_done={done} stage={stage:?} ullpart={parts}"
);
}
if after.len() > 12 {
eprintln!("[cancel-test] …(另有 {} 条)", after.len() - 12);
}
// ---- 断点文件 / 临时目录 / 成品三样 ----
// 版本 JSON 只在「全量就绪」才落盘(`execute` 的 complete 判定),取消后不该有成品;
// 实例登记在最后一步之后,取消后不该多出实例。
let version_json = game
.join("versions")
.join(&version)
.join(format!("{version}.json"));
eprintln!(
"[cancel-test] 版本清单 {} / 实例目录 {}",
if version_json.is_file() {
"已落盘"
} else {
"未落盘"
},
game.join("instances").is_dir()
);
// ---- 判据(修好之后必须成立的三条) ----
assert!(
after.is_empty(),
"取消之后还在被喂进度事件({} 条):面板会被重新点亮 = 用户看到的「点了没反应」",
after.len()
);
let settled = samples
.iter()
.find(|(at, _)| Duration::from_secs_f32(*at) >= settle)
.map(|(_, parts)| *parts)
.unwrap_or(parts_at_cancel);
let grew = samples
.iter()
.filter(|(at, _)| Duration::from_secs_f32(*at) >= settle)
.map(|(_, parts)| *parts)
.max()
.map_or(0, |peak| peak.saturating_sub(settled));
assert_eq!(
grew, 0,
"取消 {settle:?} 之后断点文件还在长(+{grew} 字节):下载没有真的停"
);
assert!(
!version_json.is_file(),
"取消之后仍然落了版本清单:半成品被记成「已安装」"
);
std::fs::remove_dir_all(&root).ok();
}
// --------------------------------------------------------------- CI 可跑的守门
/// 一条安装进度快照(够画面板就行)。
fn progress_snapshot(files_done: u64) -> InstallProgressView {
InstallProgressView {
version: "1.20.1".to_string(),
stage: InstallStage::Downloading,
files_done,
files_total: 512,
..InstallProgressView::new()
}
}
/// **「点了取消没反应」的真根因**:取消之后旧任务的进度事件还在往回灌。
///
/// 现场:用户点「取消安装」→ host 收下取消、清 `install`、发 `InstallProgress(None)`(面板回弹),
/// 但**后台线程还在跑**(core 的下载段不是瞬停的),它每下完一个文件就发一条
/// `HostTask::Install`。没有代次的话这条事件照样被翻译成 `InstallProgress(Some(..))`,
/// 面板立刻又被点亮 —— 用户看到的就是「取消按钮点了没反应」。
///
/// 抓得住:代次不符的 `Install` / `InstallDone` 被丢掉(面板不复燃、也不清掉新一轮的取消句柄)。
/// 抓不住:真网络的取消延迟(那条在 `real_install_cancel_*`,`#[ignore]` 守着要手动跑)。
#[test]
fn progress_from_a_cancelled_job_is_dropped() {
let dir = std::env::temp_dir().join(format!("ull-cancel-epoch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut host = CoreHost::headless(HostEnv::at_root(&dir));
// 开工:拿当代次
let epoch = host.next_install_epoch();
host.handle(HostTask::Install {
progress: Box::new(progress_snapshot(3)),
epoch,
});
let live: Vec<AppEvent> = host.drain_events();
assert!(
live.iter()
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))),
"当代次的事件必须照常点亮面板"
);
// 取消
host.dispatch(AppCommand::CancelInstall);
let cancelled: Vec<AppEvent> = host.drain_events();
assert!(
cancelled
.iter()
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(None)))),
"取消要立刻把面板收回(不等后台)"
);
assert!(
!cancelled
.iter()
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))),
"取消本身不该重新点亮面板"
);
// 旧任务继续发进度(现场就是这样)——必须被丢掉
host.handle(HostTask::Install {
progress: Box::new(progress_snapshot(4)),
epoch,
});
host.handle(HostTask::InstallDone {
progress: Box::new(progress_snapshot(512)),
result: Err("已取消".to_string()),
epoch,
});
let after: Vec<AppEvent> = host.drain_events();
assert!(
after.is_empty(),
"取消之后旧任务的事件必须一条都不出(实际 {} 条):出了就会把面板重新点亮",
after.len()
);
// 新一轮安装的代次是新的:它的进度照常生效
let fresh = host.next_install_epoch();
assert_ne!(fresh, epoch, "新一轮开工必须换代次");
host.handle(HostTask::Install {
progress: Box::new(progress_snapshot(1)),
epoch: fresh,
});
assert!(
host.drain_events()
.iter()
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))),
"新一轮的进度要能点亮面板(不能把代次守卫写成「一律丢弃」)"
);
std::fs::remove_dir_all(&dir).ok();
}
+20 -4
View File
@@ -6,7 +6,7 @@ use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind};
use crate::toast::ToastKind;
use super::accounts::{self, LoginJob};
use super::{game, install, java, launch, logs, settings, versions, CoreHost};
use super::{game, install, java, launch, loader_versions, logs, settings, versions, CoreHost};
impl CoreHost {
/// 下发一条用户意图(`Backend::dispatch` 的实现体)。
@@ -28,9 +28,13 @@ impl CoreHost {
None => self.toast(ToastKind::Warn, "这个实例当前没有在跑"),
},
AppCommand::ListVersions(_) => versions::spawn_list(self.env.clone(), self.tx.clone()),
AppCommand::ListLoaderVersions { kind, mc_version } => {
loader_versions::spawn_list(self.env.clone(), self.tx.clone(), kind, mc_version);
}
AppCommand::InstallVersion(version) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
let epoch = self.next_install_epoch();
install::spawn_install(
self.env.clone(),
self.tx.clone(),
@@ -41,21 +45,33 @@ impl CoreHost {
loader_version: String::new(),
},
cancel,
epoch,
);
}
AppCommand::CreateInstance(request) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel);
let epoch = self.next_install_epoch();
install::spawn_install(self.env.clone(), self.tx.clone(), request, cancel, epoch);
}
AppCommand::CancelInstall => {
// 取消只在阶段边界生效(core 的下载循环没有取消钩子):这里先把面板收回,
// 后台线程会在下一阶段开始前看到标记并收尾。
// 取消的三级语义(M105 实测后定稿,见 `install::CANCELLED`):
// ① **面板立刻收回**(不等后台,用户点了就有反应);
// ② **作废旧任务的代次**:后台线程是异步停的(取消延迟 = 一个数据块),
// 它带回来的进度事件一律因代次不符被 `handle` 丢掉——面板不会被重新点亮。
// 这是「点了没反应」的真根因:以前生效了又被旧事件盖回去;
// ③ 后台线程经 `install::run` 的取消检查停在段边界、**绝不登记实例**,
// core 的 `execute_with_cancel` 还会就地掐断在飞的那个文件。
//
// 为什么不在这里 join 后台线程:UI 线程绝不能为了「等它停」而阻塞
// (一个数据块的延迟在人眼下就是「立刻」,但足以让 UI 卡住一帧以上)。
if let Some(cancel) = &self.install_cancel {
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
}
self.install_epoch = self.install_epoch.wrapping_add(1);
self.install = None;
self.send(CoreEvent::InstallProgress(None));
self.toast(ToastKind::Info, install::CANCELLED);
}
AppCommand::SetIsolation { id, mode } => {
match game::set_isolation(&self.env, &id, &mode) {
+36 -2
View File
@@ -15,11 +15,45 @@ impl CoreHost {
HostTask::Versions(Err(reason)) => {
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
}
HostTask::Install(progress) => {
HostTask::LoaderVersions {
kind,
mc_version,
result,
} => {
// 失败既要落进快照(页面禁用确认 + 显示具名原因),也要一条 Toast:
// 用户可能正盯着上面的版本列表,下拉那一行的红字容易被忽略。
if let Err(reason) = &result {
self.toast(
ToastKind::Warn,
format!("取不到 {} 的加载器版本:{reason}", kind.label()),
);
}
self.send(CoreEvent::LoaderVersions {
kind,
mc_version,
result,
});
}
HostTask::Install { progress, epoch } => {
// 旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉:这是「点了取消没反应」的根因。
// 取消时 `InstallProgress(None)` 刚把面板收回,旧任务的下一条「下载中」事件
// 立刻又把它点亮,用户看到的就永远是那块还在涨的进度条。
if epoch != self.install_epoch {
return;
}
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
}
HostTask::InstallDone { progress, result } => {
HostTask::InstallDone {
progress,
result,
epoch,
} => {
// 收尾同样看代次:取消之后那次「已取消」的收尾不该再动面板(面板早就回弹了),
// 也不该清掉**新一轮**安装的取消句柄。
if epoch != self.install_epoch {
return;
}
self.install_cancel = None;
self.install = Some(*progress.clone());
self.send(CoreEvent::InstallProgress(Some(*progress)));
+57 -167
View File
@@ -14,31 +14,32 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::loader::{
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{
execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use unlucky_core::game::install::{InstallError, InstallReport};
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
use crate::toast::{Toast, ToastKind};
use super::env::HostEnv;
use super::install_journal::Journal;
use super::install_processors::{self, StageInput};
use super::install_processors;
use super::job::{spawn, HostTask, JobSender};
use super::meter::{sink_for, Meter};
use super::meter::Meter;
mod routes;
use routes::{install_loader, install_vanilla};
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
pub const CONCURRENCY: usize = 16;
/// 用户取消的稳定文案(阶段边界生效)。
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
/// 用户取消的稳定文案。
///
/// 三件事一句话说清(D21 的口径:半成品不许冒充成品,断点材料不留在半截状态):
/// ① 怎么停的(`execute_with_cancel` 不再起新文件,在飞的那个就地掐断);
/// ② 什么被留着(**已经下完**的文件,重装续用);
/// ③ 什么一定没发生(实例未登记、版本清单没落盘;在飞文件的 `.ullpart` 由 `PartFile::Drop` 清掉)。
pub const CANCELLED: &str =
"已取消:剩下的文件没有开始下载,已下好的文件保留(重装续用),实例未登记";
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
struct Job<'a> {
@@ -93,9 +94,18 @@ pub fn unsupported_reason(kind: LoaderKind) -> String {
}
/// 后台安装(向导提交 / 下载页行内安装共用)。
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
///
/// `epoch` 是本次任务的代次([`super::CoreHost`] 的 `install_epoch`):它随每条进度回流,
/// 让 host 丢掉「已被取消 / 被下一轮顶掉」的旧任务的后续事件。
pub fn spawn_install(
env: HostEnv,
tx: JobSender,
request: CreateRequest,
cancel: Arc<AtomicBool>,
epoch: u64,
) {
spawn("install", move || {
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request, epoch)));
let journal = Journal::open(&env);
journal.line(&format!(
"—— 开始安装 {}({} {})——",
@@ -114,7 +124,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
let mut progress = meter
.lock()
.map(|guard| guard.progress.clone())
.unwrap_or_else(|_| Meter::new(&request).progress);
.unwrap_or_else(|_| Meter::new(&request, epoch).progress);
match result {
Ok(name) => {
journal.line(&format!("完成:实例 {name} 已登记"));
@@ -124,6 +134,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Ok(name),
epoch,
});
let _ = tx.send(HostTask::Toast(Box::new(Toast {
id: 0,
@@ -142,6 +153,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
let text = if cancelled {
journal.line(CANCELLED);
reason
} else {
journal.line(&format!("安装未完成:{reason}"));
@@ -151,6 +163,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
let _ = tx.send(HostTask::InstallDone {
progress: Box::new(progress),
result: Err(text),
epoch,
});
}
}
@@ -171,11 +184,14 @@ fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Planning, "正在解析版本清单…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
let _ = job.tx.send(HostTask::Install {
progress: Box::new(guard.progress.clone()),
epoch: guard.epoch,
});
}
// 段边界检查(M105):`execute_with_cancel` 自己会掐断在飞的文件,这里管的是**段与段之间**
// ——原版段与加载器段、文件段与登记之间,取消都必须在下一段开工前生效。
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
@@ -185,12 +201,22 @@ fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
install_loader(job, &engine, request)?
};
// **取消之后绝不登记实例**(M105):文件段已经在跑了,下完那一刻用户可能早已点了取消;
// 这里不拦的话,取消会留下一个「用户以为没装、其实已经装好并登记」的实例——
// 那正是「半成品污染」里最难解释的一种(列表里凭空多一条)。
if job.cancel.load(Ordering::SeqCst) {
job.journal
.line("已取消:文件段跑完但用户已取消,不登记实例(版本清单是否落盘见上)");
return Err(CANCELLED.to_string());
}
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(InstallStage::Registering, "正在登记实例…");
let _ = job
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
let _ = job.tx.send(HostTask::Install {
progress: Box::new(guard.progress.clone()),
epoch: guard.epoch,
});
}
let created_at =
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
@@ -204,152 +230,16 @@ fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
Ok(name)
}
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
fn install_vanilla(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
block_on(async {
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
let resolved = source.resolve(version).await.map_err(install_error)?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
Some((index, env.game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &env.game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})
.map_err(|error| error.to_string())?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if !plan.is_installable() {
return Err(format!(
"版本 {version} 装不全:{}",
plan.warnings.join(";")
));
}
let (bytes_total, _) = plan.total_bytes();
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
guard.progress.bytes_total = bytes_total;
guard.stage(
InstallStage::Downloading,
&format!("开始下载 {} 个文件…", plan.file_count()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
.await
.map_err(install_error)?;
ensure_complete(&report)?;
Ok::<String, String>(version.to_string())
})
}
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
fn install_loader(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let kind = CoreLoaderKind::parse(request.loader.as_str())
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
let meta_cache = env.layout.meta_cache();
let loader_version = request.loader_version.trim().to_string();
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
let release = block_on(async {
LoaderSource::new(engine, &meta_cache)
.resolve(kind, version, explicit)
.await
})
.map_err(|error| error.to_string())?;
job.journal.line(&format!(
"加载器解析:{} {} → {}",
release.kind.label(),
release.loader_version,
release.version_id
));
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &env.game,
release: &release,
environment: &Environment::host(),
vanilla_installed: env.game.is_version_installed(version),
});
if plan.vanilla_install_needed {
let mut vanilla = request.clone();
vanilla.loader = LoaderKind::Vanilla;
install_vanilla(job, engine, &vanilla)?;
}
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
let (bytes, _) = plan.install.total_bytes();
guard.expect(plan.files().len(), bytes);
}
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
if release.requires_installer() {
let input = StageInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile: release.install_profile.as_ref(),
installer: release.installer.as_deref(),
installed: env.game.is_version_installed(&release.version_id),
};
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
// 进度与日志都由处理器段自己写全,这里只消费返回值。
let _ = install_processors::stage(
env,
engine,
&input,
&install_processors::CORE,
&job.reporter(),
job.cancel,
)?;
}
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(
InstallStage::Downloading,
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
);
}
/// 安装错误 → 用户可读文案(**取消不算失败**:给那句稳定文案,不给「失败 + 日志路径」)。
///
/// 判据用 job 自己的取消标志而不是错误大类:core 的 `execute_with_cancel` 只在阶段边界与
/// 每个文件开工前看标志,取消与「最后一个文件刚好失败」可能同时成立——那种情况下用户点了取消,
/// 文案就该说取消(否则他会以为取消导致了失败)。
fn job_error(job: &Job<'_>, error: InstallError) -> String {
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
return CANCELLED.to_string();
}
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
let report = block_on(async {
execute(engine, &plan.install, CONCURRENCY, Some(sink))
.await
.map_err(install_error)
})?;
ensure_complete(&report)?;
Ok(release.version_id)
install_error(error)
}
/// 安装错误 → 用户可读文案。
+183
View File
@@ -0,0 +1,183 @@
//! 两条安装路线(原版 / 加载器)——M105 从 `install.rs` 搬出,守 400 行硬线。
//!
//! 搬移是**纯搬移**:函数体一行没改,只多了本文件自己的 `use`。
//! 父模块(`install`)的私有项对子模块可见,所以 `Job` / `CANCELLED` / `job_error` /
//! `ensure_complete` / `sink_for` 一律不重新导出,直接用。
use std::sync::atomic::Ordering;
use std::sync::Arc;
use unlucky_core::addon::loader::{
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::{execute_with_cancel, InstallPlan, PlanInput, RemoteSource};
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
use super::super::install_processors::{self, StageInput};
use super::super::meter::sink_for;
use super::{ensure_complete, install_error, job_error, Job, CANCELLED, CONCURRENCY};
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
pub(super) fn install_vanilla(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let meta_cache = env.layout.meta_cache();
let now = now_ms();
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
block_on(async {
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
let resolved = source.resolve(version).await.map_err(install_error)?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
Some((index, env.game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &env.game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})
.map_err(|error| error.to_string())?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if !plan.is_installable() {
return Err(format!(
"版本 {version} 装不全:{}",
plan.warnings.join(";")
));
}
let (bytes_total, _) = plan.total_bytes();
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
guard.progress.bytes_total = bytes_total;
guard.stage(
InstallStage::Downloading,
&format!("开始下载 {} 个文件…", plan.file_count()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let report = execute_with_cancel(
engine,
&plan,
CONCURRENCY,
Some(sink),
Some(job.cancel.as_ref()),
)
.await
.map_err(|error| job_error(job, error))?;
ensure_complete(&report)?;
Ok::<String, String>(version.to_string())
})
}
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
pub(super) fn install_loader(
job: &Job<'_>,
engine: &DownloadEngine,
request: &CreateRequest,
) -> Result<String, String> {
let env = job.env;
let version = request.version.trim();
let kind = CoreLoaderKind::parse(request.loader.as_str())
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
let meta_cache = env.layout.meta_cache();
let loader_version = request.loader_version.trim().to_string();
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
let release = block_on(async {
LoaderSource::new(engine, &meta_cache)
.resolve(kind, version, explicit)
.await
})
.map_err(|error| error.to_string())?;
job.journal.line(&format!(
"加载器解析:{} {} → {}",
release.kind.label(),
release.loader_version,
release.version_id
));
let plan = LoaderInstallPlan::build(LoaderPlanInput {
layout: &env.game,
release: &release,
environment: &Environment::host(),
vanilla_installed: env.game.is_version_installed(version),
});
if plan.vanilla_install_needed {
let mut vanilla = request.clone();
vanilla.loader = LoaderKind::Vanilla;
install_vanilla(job, engine, &vanilla)?;
}
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
let (bytes, _) = plan.install.total_bytes();
guard.expect(plan.files().len(), bytes);
}
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
if release.requires_installer() {
let input = StageInput {
kind: release.kind,
mc_version: &release.mc_version,
version_id: &release.version_id,
profile: release.install_profile.as_ref(),
installer: release.installer.as_deref(),
installed: env.game.is_version_installed(&release.version_id),
};
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
// 进度与日志都由处理器段自己写全,这里只消费返回值。
let _ = install_processors::stage(
env,
engine,
&input,
&install_processors::CORE,
&job.reporter(),
job.cancel,
)?;
}
{
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
guard.stage(
InstallStage::Downloading,
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
);
}
if job.cancel.load(Ordering::SeqCst) {
return Err(CANCELLED.to_string());
}
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
let report = block_on(async {
execute_with_cancel(
engine,
&plan.install,
CONCURRENCY,
Some(sink),
Some(job.cancel.as_ref()),
)
.await
})
.map_err(|error| job_error(job, error))?;
ensure_complete(&report)?;
Ok(release.version_id)
}
@@ -136,9 +136,10 @@ impl Reporter<'_> {
return;
};
guard.stage(stage, message);
let _ = self
.tx
.send(HostTask::Install(Box::new(guard.progress.clone())));
let _ = self.tx.send(HostTask::Install {
progress: Box::new(guard.progress.clone()),
epoch: guard.epoch,
});
}
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
@@ -282,7 +283,10 @@ fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: u
return;
};
guard.stage(InstallStage::Installing, &line);
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
let _ = tx.send(HostTask::Install {
progress: Box::new(guard.progress.clone()),
epoch: guard.epoch,
});
})
}
@@ -60,7 +60,7 @@ impl Harness {
let (tx, rx) = channel();
Self {
journal: Journal::open(&env),
meter: Arc::new(Mutex::new(Meter::new(&request))),
meter: Arc::new(Mutex::new(Meter::new(&request, 1))),
cancel: Arc::new(AtomicBool::new(false)),
dir,
env,
@@ -83,7 +83,7 @@ impl Harness {
self.rx
.try_iter()
.filter_map(|task| match task {
HostTask::Install(progress) => Some(progress.message.clone()),
HostTask::Install { progress, .. } => Some(progress.message.clone()),
_ => None,
})
.collect()
+3 -3
View File
@@ -37,7 +37,7 @@ impl Fixture {
dir,
env,
tx: channel().0,
meter: Arc::new(Mutex::new(Meter::new(request))),
meter: Arc::new(Mutex::new(Meter::new(request, 1))),
cancel: Arc::new(AtomicBool::new(false)),
journal,
}
@@ -87,7 +87,7 @@ fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
let request = request(LoaderKind::Forge, "1.20.1");
let mut meter = Meter::new(&request);
let mut meter = Meter::new(&request, 1);
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
meter.progress.files_total = 30;
@@ -175,7 +175,7 @@ fn meter_counts_files_and_skips_skipped_bytes() {
loader_version: "0.16.5".to_string(),
name: "生存".to_string(),
};
let mut meter = Meter::new(&request);
let mut meter = Meter::new(&request, 1);
assert_eq!(meter.progress.loader, "Fabric 0.16.5");
assert_eq!(meter.progress.instance, "生存");
meter.progress.files_total = 2;
+23 -3
View File
@@ -8,8 +8,8 @@ use std::sync::mpsc::{Receiver, Sender};
use crate::dto::{
AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LogLine, LogOutcome, LogSession,
NoticeView, SessionId, VersionSummary,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LoaderKind, LogLine, LogOutcome,
LogSession, NoticeView, SessionId, VersionSummary,
};
use crate::toast::Toast;
@@ -18,14 +18,34 @@ use crate::toast::Toast;
pub enum HostTask {
/// 版本清单(下载页)
Versions(Result<Vec<VersionSummary>, String>),
/// 加载器版本清单(下载页「安装选项」的下拉)
LoaderVersions {
/// 加载器种类
kind: LoaderKind,
/// 目标原版版本(缓存键的一部分,回传原样)
mc_version: String,
/// `Ok(最新在前)` / `Err(人读原因)`
result: Result<Vec<String>, String>,
},
/// 安装进度(高频,host 只做聚合与转发)
Install(Box<InstallProgressView>),
Install {
/// 进度快照
progress: Box<InstallProgressView>,
/// 任务代次([`super::CoreHost`] 的 `install_epoch`)
///
/// 取消/重新开工都会把代次 +1,于是**旧任务还在飞的事件**带着旧代次回来,
/// host 一眼就能认出来并丢掉。没有这一层的话:取消后 `InstallProgress(None)` 刚把面板
/// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。
epoch: u64,
},
/// 安装收尾(成功带实例名,失败带文案)
InstallDone {
/// 最终进度快照
progress: Box<InstallProgressView>,
/// 成功时为 `Ok(实例名)`
result: Result<String, String>,
/// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`)
epoch: u64,
},
/// 账户列表
Accounts(Vec<AccountView>),
@@ -0,0 +1,120 @@
//! 加载器版本清单(下载页「安装选项」的版本下拉,M105)。
//!
//! ## 为什么要有这一支
//!
//! core 早就有现成能力:[`unlucky_core::addon::loader::LoaderSource::loader_versions`] 按
//! `(加载器, 原版版本)` 给出**官方那份**可用版本表(Fabric/Quilt 走 fabric meta 的 loader 列表,
//! Forge/NeoForge 走 maven 的 `maven-metadata.xml` 再按 `matches_mc` 过滤)。
//! 缺的从来不是能力,是 presenter 到 UI 的那一跳——于是用户在下载页只能装原版,
//! 「自己有的能力不接出来」是 M105 要收的口子。
//!
//! ## 一次查询只回答一个问题
//!
//! 事件里带 `(kind, mc_version)` 原样回传,`state.rs` 拿它当缓存键:**切原版版本 = 换键**,
//! 于是 A 版本的 Fabric 列表不可能挂到 B 版本下面(这条是 M105 复审明确要求的)。
//! 取不到(网络/该组合官方没有版本)走 `Err(人读原因)`,页面据此**禁掉确认**并给具名错误,
//! 绝不静默显示成空列表。
use unlucky_core::addon::loader::{LoaderKind as CoreLoaderKind, LoaderSource};
use unlucky_core::download::blocking::block_on;
use crate::dto::LoaderKind;
use super::env::HostEnv;
use super::job::{spawn, HostTask, JobSender};
/// 拉一份加载器版本列表(网络在后台线程里,UI 不卡)。
pub fn spawn_list(env: HostEnv, tx: JobSender, kind: LoaderKind, mc_version: String) {
spawn("loader-versions", move || {
let result = fetch(&env, kind, &mc_version);
let _ = tx.send(HostTask::LoaderVersions {
kind,
mc_version,
result,
});
});
}
/// 同步拉取(后台线程里跑):DTO 的加载器词表 → core 的加载器词表 → 官方清单。
///
/// 原版(`Vanilla`)没有「加载器版本」这回事,直接给空列表而不是报错:调用方(页面)
/// 在选了原版时根本不画这个下拉,这里是防守。
pub fn fetch(env: &HostEnv, kind: LoaderKind, mc_version: &str) -> Result<Vec<String>, String> {
if kind == LoaderKind::Vanilla {
return Ok(Vec::new());
}
let Some(kind) = CoreLoaderKind::parse(kind.as_str()) else {
return Err(format!("未知加载器 `{}`", kind.as_str()));
};
if mc_version.trim().is_empty() {
return Err("没有选择原版版本".to_string());
}
let engine = super::engine::engine(env);
let meta_cache = env.layout.meta_cache();
block_on(async {
LoaderSource::new(&engine, &meta_cache)
.loader_versions(kind, mc_version.trim())
.await
})
.map_err(|error| error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::{AppEvent, CoreEvent};
use crate::state::AppState;
/// **缓存键必须是 `(加载器, 原版版本)`**(M105 的硬要求,复审点名的那条)。
///
/// 现场:在 1.20.1 上查过 Fabric 的列表,接着点 1.21.9;键里若没有原版版本,1.21.9 会直接
/// 显示 1.20.1 那份列表 —— 用户选了个「1.21.9 上不存在」的加载器版本,直到点安装才失败。
#[test]
fn cache_is_keyed_per_loader_and_minecraft_version() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
kind: LoaderKind::Fabric,
mc_version: "1.20.1".to_string(),
result: Ok(vec!["0.16.5".to_string()]),
}));
assert!(state
.loader_versions_for(LoaderKind::Fabric, "1.20.1")
.is_some());
assert!(state
.loader_versions_for(LoaderKind::Fabric, "1.21.9")
.is_none());
assert!(state
.loader_versions_for(LoaderKind::Quilt, "1.20.1")
.is_none());
// 失败也是「查过了」:页面据此显示具名原因并禁掉确认,不再无限重试
state.apply(AppEvent::Core(CoreEvent::LoaderVersions {
kind: LoaderKind::Quilt,
mc_version: "1.20.1".to_string(),
result: Err("network down".to_string()),
}));
assert!(state
.loader_versions_for(LoaderKind::Quilt, "1.20.1")
.is_some());
}
/// 原版没有加载器版本:给空表而不是报错(页面不画这个下拉,防守而已)。
#[test]
fn vanilla_has_no_loader_versions() {
let dir = std::env::temp_dir().join(format!("ull-loader-versions-{}", std::process::id()));
let env = HostEnv::at_root(&dir);
assert_eq!(fetch(&env, LoaderKind::Vanilla, "1.20.1"), Ok(Vec::new()));
std::fs::remove_dir_all(&dir).ok();
}
/// 空版本号在联网前就被拒(不做无意义的请求,也给出人读原因)。
#[test]
fn empty_mc_version_is_rejected_before_network() {
let dir = std::env::temp_dir().join(format!("ull-loader-empty-{}", std::process::id()));
let env = HostEnv::at_root(&dir);
let err = fetch(&env, LoaderKind::Fabric, " ").unwrap_err();
assert!(err.contains("没有选择原版版本"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
}
+11 -2
View File
@@ -15,17 +15,23 @@ use super::job::{HostTask, JobSender};
/// 一次安装的上下文(进度回调共享)。
pub(super) struct Meter {
pub(super) progress: InstallProgressView,
/// 任务代次:随每条进度一起回流,让 host 能丢掉「已被取消/被顶掉的旧任务」的事件。
///
/// 记在记账器上而不是每条 `HostTask` 现传:所有发送点都已经持有这把锁并读 `progress`,
/// 于是代次与它描述的那份进度**永远同源**(不会出现「新代次的进度配旧代次的标签」)。
pub(super) epoch: u64,
previous: Option<(u64, Instant)>,
}
impl Meter {
pub(super) fn new(request: &CreateRequest) -> Self {
pub(super) fn new(request: &CreateRequest, epoch: u64) -> Self {
Self {
progress: InstallProgressView::start(
&request.version,
&request.instance_name(),
&loader_label(request.loader, &request.loader_version),
),
epoch,
previous: None,
}
}
@@ -75,6 +81,9 @@ pub(super) fn sink_for(tx: JobSender, meter: Arc<std::sync::Mutex<Meter>>) -> Pr
return;
};
meter.note(outcome);
let _ = tx.send(HostTask::Install(Box::new(meter.progress.clone())));
let _ = tx.send(HostTask::Install {
progress: Box::new(meter.progress.clone()),
epoch: meter.epoch,
});
})
}
+20 -1
View File
@@ -25,6 +25,7 @@ mod install_processors;
mod java;
mod job;
mod launch;
mod loader_versions;
mod logs;
mod meter;
mod opener;
@@ -85,8 +86,14 @@ pub struct CoreHost {
tail: logs::Tail,
/// AI 对话的临时后端(agent GUI host 落地前的占位)
chat: MockBackend,
/// 当前安装任务的取消标记(阶段边界检查)
/// 当前安装任务的取消标记(`execute_with_cancel` 与段边界检查共同消费)
install_cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// 当前安装任务的代次。
///
/// 开工(`InstallVersion` / `CreateInstance`)与取消都 +1:还在飞的后台线程把事件带回来时
/// 一眼能看出「这是旧任务的」,于是既不会被取消掉的安装重新点亮面板,也不会串到下一轮安装
/// (两轮安装同时存在时,旧任务的事件曾经把新任务的进度条顶掉)。
install_epoch: u64,
}
impl CoreHost {
@@ -129,6 +136,7 @@ impl CoreHost {
tail: logs::Tail::default(),
chat: MockBackend::default(),
install_cancel: None,
install_epoch: 0,
};
if refresh {
host.refresh_all();
@@ -145,6 +153,14 @@ impl CoreHost {
self.events.push_back(AppEvent::Core(event));
}
/// 开一轮安装:代次 +1 并把新代次交出去(后台线程随每条进度带回来)。
///
/// 返回**新**代次而不是旧代次:调用方拿它去 `spawn_install`,于是「谁是新任务」只有一处答案。
fn next_install_epoch(&mut self) -> u64 {
self.install_epoch = self.install_epoch.wrapping_add(1);
self.install_epoch
}
fn toast(&mut self, kind: ToastKind, text: impl Into<String>) {
self.events.push_back(AppEvent::Toast(Toast {
id: 0,
@@ -339,6 +355,9 @@ impl Backend for CoreHost {
}
}
#[cfg(test)]
#[path = "cancel_tests.rs"]
mod cancel_tests;
#[cfg(test)]
#[path = "launch_state_tests.rs"]
mod launch_state_tests;
+39 -2
View File
@@ -7,8 +7,8 @@ use crate::agent::AgentRail;
use crate::dialogs::DialogQueue;
use crate::dto::{
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogSession,
NoticeView, Progress, SettingGroupView, VersionSummary,
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
};
use crate::prefs::UiPrefs;
use crate::router::{AiPanelMode, Route, Router};
@@ -39,6 +39,9 @@ pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
/// 一份加载器版本清单查询的结果(`Ok` = 最新在前;`Err` = 人读原因)。
pub type LoaderVersions = Result<Vec<String>, String>;
/// 应用全局状态。
#[derive(Debug)]
pub struct AppState {
@@ -78,6 +81,16 @@ pub struct AppState {
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
pub install: Option<InstallProgressView>,
/// 加载器版本清单(下载页「安装选项」的下拉;M105)
///
/// 两段键:外层是加载器的**稳定 id**(`LoaderKind::as_str`,`LoaderKind` 没派生 `Hash`,
/// 而 `as_str` 本来就是它在 CLI/GUI 两边的身份),内层是原版版本。
/// **原版版本进键**,所以切到另一个 MC 版本时拿到的是另一条缓存,
/// 不会把 A 版本的 Fabric 列表挂到 B 版本下面(M105 复审的硬要求)。
/// 值是 `Result`:「取不到」与「官方没有版本」是两件事,前者要在页面上说清原因
/// (并禁掉确认),不许显示成空列表。
pub loader_versions:
std::collections::HashMap<&'static str, std::collections::HashMap<String, LoaderVersions>>,
/// 设置分组(设置页右栏)
pub settings_groups: Vec<SettingGroupView>,
/// 当前选中的设置分组(download / java / data / ai / mcp)
@@ -118,6 +131,7 @@ impl AppState {
device_login: DeviceLoginView::new(),
java: JavaPageView::new(),
install: None,
loader_versions: std::collections::HashMap::new(),
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
notices: Vec::new(),
@@ -126,6 +140,19 @@ impl AppState {
}
}
/// 某 (加载器, 原版版本) 的加载器版本清单。
///
/// `None` = 还没查过(页面据此派一次 `AppCommand::ListLoaderVersions` 并显示「正在获取…」);
/// `Some(Err(..))` = 查过但拿不到(页面显示具名原因并**禁掉确认**)。
#[must_use]
pub fn loader_versions_for(
&self,
kind: LoaderKind,
mc_version: &str,
) -> Option<&LoaderVersions> {
self.loader_versions.get(kind.as_str())?.get(mc_version)
}
/// 当前页面。
pub fn route(&self) -> &Route {
self.router.route()
@@ -219,6 +246,16 @@ impl AppState {
None => self.install = None,
},
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
CoreEvent::LoaderVersions {
kind,
mc_version,
result,
} => {
self.loader_versions
.entry(kind.as_str())
.or_default()
.insert(mc_version, result);
}
CoreEvent::NoticesChanged(notices) => self.notices = notices,
}
}