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

@@ -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:?} 之后面板要让位");
}
}