Merge branch 'feat/m105-dlflow-install-options-step-and-can'
This commit is contained in:
commit
777d02e7cb
39 files changed
+2771
-522
No files matched your search
@@ -25,6 +25,7 @@ use unlucky_presenter::dto::Backend;
|
||||
use unlucky_presenter::prefs::ThemeMode;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::state::AppState;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
@@ -130,6 +131,15 @@ 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,
|
||||
/// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」,
|
||||
/// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。
|
||||
pub(super) install_cancelled_seen: u64,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -216,6 +226,9 @@ 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(),
|
||||
install_cancelled_seen: 0,
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
@@ -277,12 +290,30 @@ impl App {
|
||||
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
|
||||
self.sync_agent_route();
|
||||
|
||||
// M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。
|
||||
// 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 ——
|
||||
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
|
||||
if self.state.install_cancelled != self.install_cancelled_seen {
|
||||
self.install_cancelled_seen = self.state.install_cancelled;
|
||||
let text = pages::cancelled_toast(super::view::ui_lang());
|
||||
self.state.toasts.push(ToastKind::Info, text);
|
||||
}
|
||||
|
||||
// 分类是**页面自己持有的过滤条件**(真 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;
|
||||
}
|
||||
|
||||
@@ -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,135 @@ 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, "取消后要能立刻再装一次");
|
||||
}
|
||||
|
||||
/// **取消提示只说一次,且说的是页面语言**(复审 P2-2)。
|
||||
///
|
||||
/// 修前:`presenter` 在取消时直接推一条**写死的中文** Toast,而下载页此刻渲染英文 ⇒
|
||||
/// 选项区英文、取消弹窗中文,同屏两语。现在 host 只发信号(`CoreEvent::InstallCancelled`),
|
||||
/// 文案由页面层按 `Key::DownloadCancelled` 现求,语言跟页面走。
|
||||
///
|
||||
/// 抓得住:谁把中文写回 presenter(类型上已经做不到——它没有 i18n 依赖)、
|
||||
/// 或者 app 层每次 `refresh_view` 都重复弹(下面的 `items().len() == 1`)。
|
||||
/// 抓不住:`Lang` 本身接得对不对(`view::ui_lang()` 现恒为英文,那是 M106 的范围)。
|
||||
#[test]
|
||||
fn cancelling_announces_once_in_the_page_language() {
|
||||
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(),
|
||||
);
|
||||
assert!(app.state.toasts.is_empty(), "出厂态不该有 Toast");
|
||||
|
||||
app.state.apply(AppEvent::Core(
|
||||
unlucky_presenter::dto::CoreEvent::InstallCancelled,
|
||||
));
|
||||
app.refresh_view();
|
||||
let items = app.state.toasts.items();
|
||||
assert_eq!(items.len(), 1, "取消要提示一条(且只一条)");
|
||||
let text = items[0].text.clone();
|
||||
assert_eq!(
|
||||
text,
|
||||
pages::cancelled_toast(super::super::view::ui_lang()),
|
||||
"提示语必须来自 i18n 目录(不是 presenter 写死的中文)"
|
||||
);
|
||||
assert!(
|
||||
!text.contains('取') && !text.contains('消'),
|
||||
"页面此刻是英文,取消提示不该混中文:{text}"
|
||||
);
|
||||
|
||||
// 再重建一次视图:不该重复弹(信号是计数,不是「每拍都算一次」)
|
||||
app.refresh_view();
|
||||
assert_eq!(app.state.toasts.items().len(), 1, "同一次取消只提示一次");
|
||||
let _ = app.state.toasts.len();
|
||||
}
|
||||
@@ -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 的公开快照字段,
|
||||
// 不派业务命令、不写持久状态)
|
||||
|
||||
@@ -93,6 +93,13 @@ impl App {
|
||||
|
||||
// --------------------------------------------------------------- 快照 → 视图模型
|
||||
|
||||
/// **产品路径此刻的界面语言**(唯一入口):语言还没接到设置(M106 的范围),所以恒为英文。
|
||||
/// 收成一处是为了别让第二个地方(如取消提示)各硬编码一次;M106 只改这里。
|
||||
/// **M106 合并前中文译文在产品路径上不可达**(页面与提示语都是英文)。
|
||||
pub(super) fn ui_lang() -> Lang {
|
||||
Lang::En
|
||||
}
|
||||
|
||||
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||
///
|
||||
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
||||
@@ -105,6 +112,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);
|
||||
@@ -129,15 +137,19 @@ pub(super) fn model(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。
|
||||
let lang = Lang::En;
|
||||
let lang = ui_lang();
|
||||
|
||||
pages::Model {
|
||||
screen,
|
||||
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 +286,16 @@ mod tests {
|
||||
/// 与「取证密闭」正好相反,而且图看起来完全正常);② 加了变体却映射到别的态这种复制粘贴错。
|
||||
/// 抓不住:`shell::overridden`(标题栏 / 导航轨按钮走的**另一处** match,断言在 `shell/tests.rs`),
|
||||
/// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。
|
||||
/// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。
|
||||
fn at(state: &AppState, forced: Option<crate::cli::ForcedState>) -> pages::Model {
|
||||
let draft = pages::InstallDraft::default();
|
||||
model(state, 1180.0, forced, pages::Category::All, &draft)
|
||||
}
|
||||
|
||||
#[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 +310,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 +321,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 +352,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 +390,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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -65,7 +75,8 @@ mod states;
|
||||
mod tests;
|
||||
|
||||
pub use demo::demo;
|
||||
pub use model::{Category, Download};
|
||||
pub use model::{cancelled_toast, Category, Download};
|
||||
pub use options::{InstallDraft, Loader};
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::{Element, Length};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。
|
||||
|
||||
@@ -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:?} 之后面板要让位");
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,19 @@ use unlucky_presenter::dto::{
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use super::options::{InstallDraft, OptionsView};
|
||||
|
||||
/// 取消安装之后那一句提示(M105)。
|
||||
///
|
||||
/// 为什么文案在页面层而不是 presenter:取消的**信号**由 host 发(`CoreEvent::InstallCancelled`),
|
||||
/// 但 presenter 不依赖 `unlucky-i18n`,写死的中文会和页面语言打架(复审 P2-2 抓的就是这个:
|
||||
/// 选项区是英文、取消弹窗是中文)。所以文本按 `Key` 现求,语言跟着页面走。
|
||||
#[must_use]
|
||||
pub fn cancelled_toast(lang: Lang) -> String {
|
||||
lang.catalog().text(Key::DownloadCancelled).to_owned()
|
||||
}
|
||||
|
||||
/// 分类筛选(页面上的四个 filter chip)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -265,8 +277,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 +304,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 +337,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 +359,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),
|
||||
)
|
||||
}
|
||||
@@ -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, "解析版本");
|
||||
}
|
||||
@@ -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` 只画现有快照)。
|
||||
|
||||
@@ -123,7 +123,7 @@ pub(crate) mod settings;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub use download::{Category, Download};
|
||||
pub use download::{cancelled_toast, 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};
|
||||
|
||||
@@ -17,160 +17,55 @@
|
||||
//! **不整体回滚**:已下好的共享层文件(libraries/assets)留在原地复用,失败信息逐文件结构化
|
||||
//! 返回,由调用方决定是重试、还是只提示"缺哪几个文件"。这与 analyse/03 §6.3 的"宁可给出
|
||||
//! 可修复的骨架"一致,但**版本清单与实例登记只在全量成功后才写**,不会出现半实例。
|
||||
//!
|
||||
//! # 取消(M105:GUI 的「取消安装」要**真停**)
|
||||
//!
|
||||
//! [`execute`] 与 CLI 的契约**一字不变**(不传取消标志 = 今天的行为);
|
||||
//! 取消走 [`execute_with_cancel`],判据三条:
|
||||
//!
|
||||
//! 1. **不再起新文件**:流里**每个 item 的第一句**就看标志,置位直接返回 `None` ⇒
|
||||
//! 那些 item **连 `install_one` 都不进**(不发请求、不算整文件 sha1、不回调 `sink`)。
|
||||
//! 这一句不能挪到 `select` 的右支:`select` 先轮询左支,左支第一次 poll 就把
|
||||
//! `engine.download(…)` 派出去了(复审 P2-1 就是从这条抓出来的)。
|
||||
//! 2. **在飞的文件就地掐断**:`install_one` 与「取消 future」`select` 赛跑,
|
||||
//! 取消赢就 drop 掉下载 future —— reqwest 的响应流随之关闭(socket 断开),
|
||||
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾)。
|
||||
//! 3. **半成品不许变成品**:取消后不再写版本 JSON,返回
|
||||
//! [`InstallErrorKind::Cancelled`](super::InstallErrorKind),调用方(presenter)据此把阶段
|
||||
//! 记成「已取消」而不是「失败」。
|
||||
//!
|
||||
//! 于是取消延迟 = **一个数据块的唤醒**(不是「等一个文件下完」,也不是「等这一段跑完」)。
|
||||
//!
|
||||
//! ## 固有边界(复审要求留档,别把它当 bug 修)
|
||||
//!
|
||||
//! `buffer_unordered(concurrency)` 在取消那一刻**已经有最多 `concurrency`(GUI 传 16)个 future
|
||||
//! 被拉起来了**,它们已经过了第 1 条那道门。所以最坏情况下这 16 个文件里:
|
||||
//!
|
||||
//! - **盘上已就绪的**(第一 poll 就 `Ready`)会照常「跑完」并被回调一次 `sink` ——
|
||||
//! presenter 侧靠**安装代次**(`CoreHost::install_epoch`)把这些回调带来的进度事件丢掉,
|
||||
//! 所以面板不会因此复燃;
|
||||
//! - **真在下载的**会在同一次 poll 的右支被取消掉(future drop、连接关掉、`.ullpart` 清掉)。
|
||||
//!
|
||||
//! 也就是说:**「取消之后一个字节都不会再写」这句话只对「还没被拉起的 item」成立**;
|
||||
//! 在飞的 16 个里已就绪的那些仍会走完自己的回调。要连这一步都收掉,得让
|
||||
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
|
||||
|
||||
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 +86,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 +177,47 @@ 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);
|
||||
// **每个 item 的第一句就查标志**(复审 P2-1,p1):`select` 先轮询左支,
|
||||
// 左支第一次 poll 就会把 `engine.download(...)` 派发出去 —— 只把标志检查写在
|
||||
// `select` 的右支,等于「取消之后每个剩余文件仍各发一次整文件 sha1 + 一次请求」,
|
||||
// 1.20.1 那种四位数文件的 plan 会一直走到列表末尾,而且「盘上已就绪」的文件
|
||||
// 第一 poll 就 `Ready` ⇒ 还会各回调一次 `sink`。
|
||||
// 放在这里,取消后这些 item **连 `install_one` 都不进**。
|
||||
if is_cancelled(cancel) {
|
||||
return None;
|
||||
}
|
||||
// 已经开始的那些(`buffer_unordered` 在飞,最多 `concurrency` 个)与取消 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 +235,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) {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -268,3 +314,84 @@ fn failure_kind_prefers_integrity() {
|
||||
assert_eq!(report.failure_kind(), Some(DownloadErrorKind::Integrity));
|
||||
assert_eq!(report.failures(1).len(), 1);
|
||||
}
|
||||
|
||||
/// **取消之后,剩下的文件连 `install_one` 都不许进**(复审 P2-1 的守门,p1)。
|
||||
///
|
||||
/// 现场(修前):标志检查只写在 `select` 的右支,而 `select` 先轮询左支 —— 左支第一次 poll
|
||||
/// 就把 `engine.download(…)` 派出去了。于是取消后 `buffer_unordered` 立刻让出槽位、流继续被拉,
|
||||
/// **每一个剩余 item 都会各发一次整文件 sha1 + 一次请求**,1.20.1 那种四位数文件的 plan 会一直
|
||||
/// 走到列表末尾;盘上已就绪的文件第一 poll 就 `Ready` ⇒ 还会各回调一次 `sink`。
|
||||
///
|
||||
/// 这条用例把它钉成可数的东西:三个文件全在盘上(`install_one` 直接判 Skipped、零网络),
|
||||
/// 并发 1,`sink` 第一次回调时置取消标志 —— 于是「取消后还剩几个文件被处理」= `sink` 的调用次数。
|
||||
/// 修好后必须是 **1**(只有第一个),修前是 **3**。
|
||||
///
|
||||
/// 抓得住:`async move` 开头那句 `if is_cancelled(cancel) { return None; }` 被删掉/挪到 `select` 右支。
|
||||
/// 抓不住:在飞的那一个文件被掐断的时机(那要真字节流,见 presenter 的真网络装置)。
|
||||
#[tokio::test]
|
||||
async fn cancelling_mid_run_stops_creating_remaining_files() {
|
||||
let root = temp_root("cancel-mid");
|
||||
let layout = GameLayout::new(&root);
|
||||
let jar = b"client jar payload".to_vec();
|
||||
let sha1 = sha1_of(&jar);
|
||||
write_file(&layout.version_jar("1.20.1"), &jar);
|
||||
let resolved = VersionJson::from_json_str(&format!(
|
||||
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
|
||||
"downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
|
||||
jar.len()
|
||||
))
|
||||
.expect("清单应可解析");
|
||||
let mut plan = InstallPlan::build(PlanInput {
|
||||
layout: &layout,
|
||||
version_id: "1.20.1",
|
||||
resolved: &resolved,
|
||||
asset_index: None,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功");
|
||||
|
||||
// 再挂两个"已在盘上"的库文件:内容与声明的 sha1 一致 ⇒ 跳过判定成立、零网络。
|
||||
for name in ["org.example:a:1.0", "org.example:b:1.0"] {
|
||||
let bytes = format!("payload for {name}").into_bytes();
|
||||
let dest = root
|
||||
.join("libraries")
|
||||
.join(format!("{}.jar", name.replace(':', "-")));
|
||||
write_file(&dest, &bytes);
|
||||
plan.files.push(PlanFile {
|
||||
role: FileRole::Library,
|
||||
name: name.to_string(),
|
||||
url: format!("https://example.invalid/{name}.jar"),
|
||||
sha1: Some(sha1_of(&bytes)),
|
||||
size: Some(bytes.len() as u64),
|
||||
dest,
|
||||
resource: ResourceKind::Library,
|
||||
});
|
||||
}
|
||||
assert_eq!(plan.files.len(), 3, "三个文件全在盘上 ⇒ 全程零网络");
|
||||
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
// `sink` 是 `Fn + 'static`,所以标志也必须 `Arc` 进来;第一次回调就置取消。
|
||||
let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let sink_seen = Arc::clone(&seen);
|
||||
let sink_flag = Arc::clone(&flag);
|
||||
let sink: crate::game::install::ProgressSink = Arc::new(move |_outcome: &FileOutcome| {
|
||||
sink_seen.fetch_add(1, Ordering::SeqCst);
|
||||
sink_flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let error = execute_with_cancel(&engine(&root), &plan, 1, Some(sink), Some(flag.as_ref()))
|
||||
.await
|
||||
.expect_err("取消之后不许返回成功 report");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
crate::game::install::InstallErrorKind::Cancelled
|
||||
);
|
||||
assert_eq!(
|
||||
seen.load(Ordering::SeqCst),
|
||||
1,
|
||||
"取消只该让**第一个**文件走完回调;剩下的 item 必须连 install_one 都不进\
|
||||
(修前这里会是 3:每个剩余文件各多算一次 sha1、各多发一次请求)"
|
||||
);
|
||||
assert!(!plan.version_json_path.is_file(), "取消后不许落版本清单");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -58,6 +58,38 @@ 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} 或更新"
|
||||
),
|
||||
DownloadCancelled => (
|
||||
"Install cancelled — finished files are kept, the instance was not created",
|
||||
"安装已取消 —— 已下好的文件保留,实例未登记"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,16 @@ 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} 或更新"),
|
||||
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
|
||||
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
|
||||
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||
("LogsOpenFolder", "Open folder", "打开目录"),
|
||||
|
||||
@@ -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,24 @@ pub enum CoreEvent {
|
||||
JavaChanged(JavaPageView),
|
||||
/// 安装进度(`None` = 清掉进度面板)
|
||||
InstallProgress(Option<InstallProgressView>),
|
||||
/// 用户取消了安装(**不带人读文案**)
|
||||
///
|
||||
/// 为什么只有信号没有文本:`presenter` 不依赖 `unlucky-i18n`,任何写死的中文都会和
|
||||
/// 页面语言打架(下载页目前渲染英文,一条中文 Toast 会**同屏一中一英**,复审 P2-2)。
|
||||
/// 文案由 app 层按 `Key` 现求:见 `pages::download::cancelled_toast`。
|
||||
InstallCancelled,
|
||||
/// 某 (加载器, 原版版本) 可用的加载器版本列表(下载页「安装选项」的版本下拉)。
|
||||
///
|
||||
/// `result` 用 `Result` 而不是空列表:**取不到**与**确实没有**是两件事,
|
||||
/// 前者要给具名错误(不许让下拉显示成空还以为能装),后者是「这个组合官方就没有版本」。
|
||||
LoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本
|
||||
mc_version: String,
|
||||
/// `Ok(最新在前)` / `Err(人读原因)`
|
||||
result: Result<Vec<String>, String>,
|
||||
},
|
||||
/// 设置分组刷新(设置页右栏)
|
||||
SettingsChanged(Vec<SettingGroupView>),
|
||||
/// 横幅通知刷新(隔离切换提示/启动失败等)
|
||||
@@ -110,6 +128,13 @@ pub enum AppCommand {
|
||||
CancelInstall,
|
||||
/// 新建实例(向导提交:版本 + 加载器 + 名字)
|
||||
CreateInstance(CreateRequest),
|
||||
/// 拉取某原版版本可用的加载器版本(下载页「安装选项」的版本下拉)
|
||||
ListLoaderVersions {
|
||||
/// 加载器种类
|
||||
kind: LoaderKind,
|
||||
/// 目标原版版本(`1.20.1`)
|
||||
mc_version: String,
|
||||
},
|
||||
/// 修改实例隔离档位(写实例设置文件)
|
||||
SetIsolation {
|
||||
/// 实例 id
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 取消安装的**实测装置**(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,**并且**绝对值必须是 0 | 抓得住「还在写」与「留了半截垃圾」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) |
|
||||
//! | 半成品变成品 | `<game>/versions/<id>/<id>.json`(版本清单)与 `<launcher_home>/config/profiles.json`(**实例登记的唯一真相源**,`LauncherLayout::profiles_config()`) | 抓得住「取消后仍然落了成品 / 登记了实例」;抓不住「用户看不到的临时目录」 |
|
||||
//!
|
||||
//! 线程与 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();
|
||||
// 实例登记的真相源是 `<launcher_home>/config/profiles.json`(`LauncherLayout::profiles_config()`),
|
||||
// 不是 `<game>/instances` —— 后者根本不存在,探它等于什么都没探(复审 P2-3 第 2 点)。
|
||||
let launcher = env.layout.clone();
|
||||
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 判定);
|
||||
// 实例登记在 `run()` 的最后一步,取消后不该登记(读 profiles.json 这份唯一真相源)。
|
||||
let version_json = game
|
||||
.join("versions")
|
||||
.join(&version)
|
||||
.join(format!("{version}.json"));
|
||||
let profiles = unlucky_core::game::install::instance::read_profiles(&launcher)
|
||||
.expect("profiles.json 应可读(不存在 = 空清单)");
|
||||
eprintln!(
|
||||
"[cancel-test] 版本清单{} / profiles.json 里 {} 条实例",
|
||||
if version_json.is_file() {
|
||||
"已落盘"
|
||||
} else {
|
||||
"未落盘"
|
||||
},
|
||||
profiles.profiles.len()
|
||||
);
|
||||
|
||||
// ---- 判据(修好之后必须成立的三条) ----
|
||||
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_eq!(
|
||||
parts_after, 0,
|
||||
"取消之后还留着 {parts_after} 字节的 `.ullpart`:半截垃圾没清掉(D21 要的就是「清掉或标清楚」)"
|
||||
);
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"取消之后仍然登记了实例(profiles.json 有 {} 条):半成品被记成成品",
|
||||
profiles.profiles.len()
|
||||
);
|
||||
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();
|
||||
}
|
||||
@@ -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,35 @@ 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));
|
||||
// 只发信号、不发文案(复审 P2-2):presenter 没有 i18n 依赖,
|
||||
// 写死的中文会和页面语言打架。提示语由 app 层按 `Key` 现求。
|
||||
self.send(CoreEvent::InstallCancelled);
|
||||
}
|
||||
AppCommand::SetIsolation { id, mode } => {
|
||||
match game::set_isolation(&self.env, &id, &mode) {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// 安装错误 → 用户可读文案。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,21 @@ pub struct AppState {
|
||||
pub java: JavaPageView,
|
||||
/// 安装进度(None = 没有安装任务)
|
||||
pub install: Option<InstallProgressView>,
|
||||
/// 用户取消安装的**次数**(M105):每次 `CoreEvent::InstallCancelled` +1。
|
||||
///
|
||||
/// 只是信号,不带文案:app 层比对前后值决定「要不要弹一条 i18n Toast」,
|
||||
/// 于是取消提示和页面用同一种语言(复审 P2-2)。
|
||||
pub install_cancelled: u64,
|
||||
/// 加载器版本清单(下载页「安装选项」的下拉;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 +136,8 @@ impl AppState {
|
||||
device_login: DeviceLoginView::new(),
|
||||
java: JavaPageView::new(),
|
||||
install: None,
|
||||
install_cancelled: 0,
|
||||
loader_versions: std::collections::HashMap::new(),
|
||||
settings_groups: Vec::new(),
|
||||
settings_tab: "download".to_string(),
|
||||
notices: Vec::new(),
|
||||
@@ -126,6 +146,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()
|
||||
@@ -218,7 +251,20 @@ impl AppState {
|
||||
}
|
||||
None => self.install = None,
|
||||
},
|
||||
CoreEvent::InstallCancelled => {
|
||||
self.install_cancelled = self.install_cancelled.saturating_add(1);
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user