Files
Unlucky-Launcher/crates/app-iced/src/app/update/download.rs
T
AskaEth 30b1ff6f48 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 + 金表同步)。
2026-11-04 22:12:22 +08:00

155 lines
7.7 KiB
Rust

//! 下载页在 app 层的那几支(M94;M105 补上安装选项)。
//!
//! 为什么单独一个文件:M93(外观三件套接线)合入后 `update.rs` 已经贴到 400 行,下载页再往里塞
//! 几个 arm 就顶破硬线;拆出来是既不动 M93 的代码、又能守住 ≤400 的办法(`app/update/**` 属本
//! mission scope)。
//!
//! ## 各支各自的纪律(M105 后的口径)
//!
//! | 消息 | 落点 | 为什么 |
//! |---|---|---|
//! | [`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) 也在这里:它不是占位动作,具体分类 → 重拉该分类、
//! 「全部」→ 让 host 重扫一遍(`RefreshAll`)。
//!
//! ## 分类为什么既写视图模型、又派指令
//!
//! 真 host 的 `AppCommand::ListVersions(_)` 会丢掉分类参数、回全量清单(复审 P2-1),所以**过滤在
//! 本页做**:写进视图模型 → 立刻 `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::*;
impl App {
/// 下载页的消息。**处理了返回 `true`**(`on_page` 里就不用再写一遍这些 arm)。
pub(super) fn on_download(&mut self, message: &pages::Message) -> bool {
match message {
pages::Message::SelectVersion(id) => {
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) => {
// 防守:按钮带的是**页面上那一行**的 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) => {
self.view.download.category = *category;
self.view_dirty = true;
self.refresh_view();
if let Some(kind) = category.command() {
self.dispatch(AppCommand::ListVersions(kind));
}
}
pages::Message::Action(pages::Action::RefreshSources) => {
match self.view.download.category.command() {
Some(kind) => self.dispatch(AppCommand::ListVersions(kind)),
None => self.dispatch(AppCommand::RefreshAll),
}
}
_ => return false,
}
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,
});
}
}