diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index d56ba33..af60e6e 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -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>`)。 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; } diff --git a/crates/app-iced/src/app/update/download.rs b/crates/app-iced/src/app/update/download.rs index 85ea7d7..24ba6ec 100644 --- a/crates/app-iced/src/app/update/download.rs +++ b/crates/app-iced/src/app/update/download.rs @@ -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, + }); + } } diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs index fbe6525..30b262f 100644 --- a/crates/app-iced/src/app/update/download_wiring.rs +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -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>> = 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>> = 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(); +} diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index 034d1a8..ef409d9 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -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 的公开快照字段, // 不派业务命令、不写持久状态) diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b8bc50d..774e1b6 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -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, 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) -> 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()); diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index eef7cae..f5a245f 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -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}; diff --git a/crates/app-iced/src/pages/download/demo.rs b/crates/app-iced/src/pages/download/demo.rs index cf71d33..0d17600 100644 --- a/crates/app-iced/src/pages/download/demo.rs +++ b/crates/app-iced/src/pages/download/demo.rs @@ -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>) -> 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 { 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) } /// 一次安装的演示快照(文件计数 / 字节 / 速度 / 剩余时间都给真值,进度条画得出来)。 diff --git a/crates/app-iced/src/pages/download/install.rs b/crates/app-iced/src/pages/download/install.rs index b78ac36..93d34c2 100644 --- a/crates/app-iced/src/pages/download/install.rs +++ b/crates/app-iced/src/pages/download/install.rs @@ -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() { diff --git a/crates/app-iced/src/pages/download/install/tests.rs b/crates/app-iced/src/pages/download/install/tests.rs index 02d159e..e960bb5 100644 --- a/crates/app-iced/src/pages/download/install/tests.rs +++ b/crates/app-iced/src/pages/download/install/tests.rs @@ -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:?} 之后面板要让位"); } } diff --git a/crates/app-iced/src/pages/download/model.rs b/crates/app-iced/src/pages/download/model.rs index 632087f..186f505 100644 --- a/crates/app-iced/src/pages/download/model.rs +++ b/crates/app-iced/src/pages/download/model.rs @@ -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, /// 当前分类(从快照反推) pub category: Category, - /// 选中的版本 id(`None` = 没选中 → 安装按钮禁用) + /// 选中的版本 id(`None` = 没选中 → 面板只给一句提示) pub selected: Option, + /// 安装选项区(M105):选中了版本、又没在装的时候才有 + pub options: Option, /// 正在跑(或刚跑完)的安装 pub install: Option, /// 快照里有没有版本列表(`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(), } diff --git a/crates/app-iced/src/pages/download/model/tests.rs b/crates/app-iced/src/pages/download/model/tests.rs index a5a7e4d..678adc4 100644 --- a/crates/app-iced/src/pages/download/model/tests.rs +++ b/crates/app-iced/src/pages/download/model/tests.rs @@ -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(), "筛掉所有行 ⇒ 空态"); diff --git a/crates/app-iced/src/pages/download/options.rs b/crates/app-iced/src/pages/download/options.rs new file mode 100644 index 0000000..27a8ed1 --- /dev/null +++ b/crates/app-iced/src/pages/download/options.rs @@ -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; diff --git a/crates/app-iced/src/pages/download/options/model.rs b/crates/app-iced/src/pages/download/options/model.rs new file mode 100644 index 0000000..3db89f3 --- /dev/null +++ b/crates/app-iced/src/pages/download/options/model.rs @@ -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), + /// 查不到:人读原因(页面显示它并禁掉确认,不静默显示成空列表) + 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 { + 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, + /// 选中的加载器 + 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, + /// 本地已装(确认键给「已安装」禁用态) + 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()) + } +} diff --git a/crates/app-iced/src/pages/download/options/tests.rs b/crates/app-iced/src/pages/download/options/tests.rs new file mode 100644 index 0000000..aa81251 --- /dev/null +++ b/crates/app-iced/src/pages/download/options/tests.rs @@ -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, &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::>() + }) + .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); +} diff --git a/crates/app-iced/src/pages/download/options/view.rs b/crates/app-iced/src/pages/download/options/view.rs new file mode 100644 index 0000000..54fb428 --- /dev/null +++ b/crates/app-iced/src/pages/download/options/view.rs @@ -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), + ) +} diff --git a/crates/app-iced/src/pages/download/tests.rs b/crates/app-iced/src/pages/download/tests.rs index 4b25220..978cac3 100644 --- a/crates/app-iced/src/pages/download/tests.rs +++ b/crates/app-iced/src/pages/download/tests.rs @@ -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, diff --git a/crates/app-iced/src/pages/download/tests/i18n.rs b/crates/app-iced/src/pages/download/tests/i18n.rs new file mode 100644 index 0000000..4aee0c0 --- /dev/null +++ b/crates/app-iced/src/pages/download/tests/i18n.rs @@ -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, "解析版本"); +} diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index 8616eac..4a57559 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -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` 只画现有快照)。 diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index e77e1b5..f782377 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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}; diff --git a/crates/core/src/game/install/execute.rs b/crates/core/src/game/install/execute.rs index ad8586c..f5c380a 100644 --- a/crates/core/src/game/install/execute.rs +++ b/crates/core/src/game/install/execute.rs @@ -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, - /// 失败详情。 - pub failure: Option, -} - -impl FileOutcome { - /// 是否失败。 - #[must_use] - pub fn failed(&self) -> bool { - self.status == FileStatus::Failed - } -} - -/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。 -/// -/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。 -pub type ProgressSink = Arc; - -/// 一次安装的执行结果。 -#[derive(Debug, Clone)] -pub struct InstallReport { - /// 逐文件结果(顺序不保证,按角色/大小汇总即可)。 - pub files: Vec, - /// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 [`execute`] 的说明)。 - pub version_json: Option, - /// 计划阶段带来的告警(缺件降级说明)。 - pub warnings: Vec, -} - -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 { - 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, ) -> Result { + 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, + cancel: Option<&AtomicBool>, +) -> Result { + if is_cancelled(cancel) { + return Err(cancelled_error()); + } let mut outcomes: Vec = 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) { diff --git a/crates/core/src/game/install/mod.rs b/crates/core/src/game/install/mod.rs index 903bdae..63a3358 100644 --- a/crates/core/src/game/install/mod.rs +++ b/crates/core/src/game/install/mod.rs @@ -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", } } } diff --git a/crates/core/src/game/install/report.rs b/crates/core/src/game/install/report.rs new file mode 100644 index 0000000..576b4ad --- /dev/null +++ b/crates/core/src/game/install/report.rs @@ -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, + /// 失败详情。 + pub failure: Option, +} + +impl FileOutcome { + /// 是否失败。 + #[must_use] + pub fn failed(&self) -> bool { + self.status == FileStatus::Failed + } +} + +/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。 +/// +/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。 +pub type ProgressSink = Arc; + +/// 一次安装的执行结果。 +#[derive(Debug, Clone)] +pub struct InstallReport { + /// 逐文件结果(顺序不保证,按角色/大小汇总即可)。 + pub files: Vec, + /// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。 + pub version_json: Option, + /// 计划阶段带来的告警(缺件降级说明)。 + pub warnings: Vec, +} + +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 { + 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 + } +} diff --git a/crates/core/src/game/install/tests/execute.rs b/crates/core/src/game/install/tests/execute.rs index 80d5271..881ce3d 100644 --- a/crates/core/src/game/install/tests/execute.rs +++ b/crates/core/src/game/install/tests/execute.rs @@ -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(); +} diff --git a/crates/i18n/src/catalog/download.rs b/crates/i18n/src/catalog/download.rs index a378511..14b9c18 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -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", + "安装已取消 —— 已下好的文件保留,实例未登记" + ), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 7aac731..589310b 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -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", "打开目录"), diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index 248d628..e22bc0a 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -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), + /// 用户取消了安装(**不带人读文案**) + /// + /// 为什么只有信号没有文本:`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, String>, + }, /// 设置分组刷新(设置页右栏) SettingsChanged(Vec), /// 横幅通知刷新(隔离切换提示/启动失败等) @@ -110,6 +128,13 @@ pub enum AppCommand { CancelInstall, /// 新建实例(向导提交:版本 + 加载器 + 名字) CreateInstance(CreateRequest), + /// 拉取某原版版本可用的加载器版本(下载页「安装选项」的版本下拉) + ListLoaderVersions { + /// 加载器种类 + kind: LoaderKind, + /// 目标原版版本(`1.20.1`) + mc_version: String, + }, /// 修改实例隔离档位(写实例设置文件) SetIsolation { /// 实例 id diff --git a/crates/presenter/src/host/cancel_tests.rs b/crates/presenter/src/host/cancel_tests.rs new file mode 100644 index 0000000..c044e1b --- /dev/null +++ b/crates/presenter/src/host/cancel_tests.rs @@ -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 侧还在被事件喂」;抓不住事件之外的后台动作 | +//! | 断点文件 | `/**/*.ullpart*` 的总字节:取消时 vs 取消后 20 s,**并且**绝对值必须是 0 | 抓得住「还在写」与「留了半截垃圾」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) | +//! | 半成品变成品 | `/versions//.json`(版本清单)与 `/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()) { + // 中间文件 `.ullpart[--]` 与它的元数据 `.ullmeta.json` + if name.contains(".ullpart") { + total += meta.len(); + } + } + } + total +} + +/// 一次 tick 后抽干事件,返回本轮的全部事件。 +fn pump(host: &mut CoreHost, wait: Duration) -> Vec { + 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(); + // 实例登记的真相源是 `/config/profiles.json`(`LauncherLayout::profiles_config()`), + // 不是 `/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 = host.drain_events(); + assert!( + live.iter() + .any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallProgress(Some(_))))), + "当代次的事件必须照常点亮面板" + ); + + // 取消 + host.dispatch(AppCommand::CancelInstall); + let cancelled: Vec = 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 = 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(); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 9da6bf1..1d1b525 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -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) { diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index e6b135e..0585505 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -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))); diff --git a/crates/presenter/src/host/install.rs b/crates/presenter/src/host/install.rs index 55e368f..a8f404d 100644 --- a/crates/presenter/src/host/install.rs +++ b/crates/presenter/src/host/install.rs @@ -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) { +/// +/// `epoch` 是本次任务的代次([`super::CoreHost`] 的 `install_epoch`):它随每条进度回流, +/// 让 host 丢掉「已被取消 / 被下一轮顶掉」的旧任务的后续事件。 +pub fn spawn_install( + env: HostEnv, + tx: JobSender, + request: CreateRequest, + cancel: Arc, + 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 { { 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 { 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 { Ok(name) } -/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。 -fn install_vanilla( - job: &Job<'_>, - engine: &DownloadEngine, - request: &CreateRequest, -) -> Result { - 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::(version.to_string()) - }) -} - -/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。 -fn install_loader( - job: &Job<'_>, - engine: &DownloadEngine, - request: &CreateRequest, -) -> Result { - 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) } /// 安装错误 → 用户可读文案。 diff --git a/crates/presenter/src/host/install/routes.rs b/crates/presenter/src/host/install/routes.rs new file mode 100644 index 0000000..22f00bf --- /dev/null +++ b/crates/presenter/src/host/install/routes.rs @@ -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 { + 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::(version.to_string()) + }) +} + +/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。 +pub(super) fn install_loader( + job: &Job<'_>, + engine: &DownloadEngine, + request: &CreateRequest, +) -> Result { + 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) +} diff --git a/crates/presenter/src/host/install_processors.rs b/crates/presenter/src/host/install_processors.rs index cb8255c..8e47554 100644 --- a/crates/presenter/src/host/install_processors.rs +++ b/crates/presenter/src/host/install_processors.rs @@ -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>, 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, + }); }) } diff --git a/crates/presenter/src/host/install_processors_fixture.rs b/crates/presenter/src/host/install_processors_fixture.rs index 3f96509..83ae946 100644 --- a/crates/presenter/src/host/install_processors_fixture.rs +++ b/crates/presenter/src/host/install_processors_fixture.rs @@ -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() diff --git a/crates/presenter/src/host/install_tests.rs b/crates/presenter/src/host/install_tests.rs index 5b46f36..06dacbd 100644 --- a/crates/presenter/src/host/install_tests.rs +++ b/crates/presenter/src/host/install_tests.rs @@ -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; diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 49cfed9..39ec444 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -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, String>), + /// 加载器版本清单(下载页「安装选项」的下拉) + LoaderVersions { + /// 加载器种类 + kind: LoaderKind, + /// 目标原版版本(缓存键的一部分,回传原样) + mc_version: String, + /// `Ok(最新在前)` / `Err(人读原因)` + result: Result, String>, + }, /// 安装进度(高频,host 只做聚合与转发) - Install(Box), + Install { + /// 进度快照 + progress: Box, + /// 任务代次([`super::CoreHost`] 的 `install_epoch`) + /// + /// 取消/重新开工都会把代次 +1,于是**旧任务还在飞的事件**带着旧代次回来, + /// host 一眼就能认出来并丢掉。没有这一层的话:取消后 `InstallProgress(None)` 刚把面板 + /// 收回,下一条「下载中」事件立刻把它点亮——用户看到的就是「点了取消没反应」(M105 实测)。 + epoch: u64, + }, /// 安装收尾(成功带实例名,失败带文案) InstallDone { /// 最终进度快照 progress: Box, /// 成功时为 `Ok(实例名)` result: Result, + /// 任务代次(语义同 [`HostTask::Install`] 的 `epoch`) + epoch: u64, }, /// 账户列表 Accounts(Vec), diff --git a/crates/presenter/src/host/loader_versions.rs b/crates/presenter/src/host/loader_versions.rs new file mode 100644 index 0000000..e6de126 --- /dev/null +++ b/crates/presenter/src/host/loader_versions.rs @@ -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, 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(); + } +} diff --git a/crates/presenter/src/host/meter.rs b/crates/presenter/src/host/meter.rs index 0962dd9..d42c0c5 100644 --- a/crates/presenter/src/host/meter.rs +++ b/crates/presenter/src/host/meter.rs @@ -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>) -> 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, + }); }) } diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index f0d1485..b707942 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -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>, + /// 当前安装任务的代次。 + /// + /// 开工(`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) { 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; diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index a84135a..de73a69 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -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, String>; + /// 应用全局状态。 #[derive(Debug)] pub struct AppState { @@ -78,6 +81,21 @@ pub struct AppState { pub java: JavaPageView, /// 安装进度(None = 没有安装任务) pub install: Option, + /// 用户取消安装的**次数**(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>, /// 设置分组(设置页右栏) pub settings_groups: Vec, /// 当前选中的设置分组(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, } }