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());