diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 758a961..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; @@ -136,6 +137,9 @@ pub struct App { /// 页面自己存不住「用户正在填但还没确认」的东西;这与分类筛选(`view.download.category`) /// 是同一个理由、同一个做法。 pub(super) download_draft: pages::InstallDraft, + /// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」, + /// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。 + pub(super) install_cancelled_seen: u64, } impl App { @@ -224,6 +228,7 @@ impl App { 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(); @@ -285,6 +290,15 @@ 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; diff --git a/crates/app-iced/src/app/update/download_wiring.rs b/crates/app-iced/src/app/update/download_wiring.rs index 7957940..30b262f 100644 --- a/crates/app-iced/src/app/update/download_wiring.rs +++ b/crates/app-iced/src/app/update/download_wiring.rs @@ -171,3 +171,52 @@ fn cancelling_gives_the_panel_back_to_the_install_options() { ); 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/view.rs b/crates/app-iced/src/app/view.rs index 4153422..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):它只影响**画出来的**状态层,不改任何状态机。 @@ -130,8 +137,7 @@ pub(super) fn model( _ => None, }; - // 语言入口还没接到 `Ui`(`theme.rs` 归 M93),产品路径先英文。 - let lang = Lang::En; + let lang = ui_lang(); pages::Model { screen, @@ -282,13 +288,8 @@ mod tests { /// 也抓不住不消费 `model.forced` 的裸 `iced::button`(设置页「外观」区就是)。 /// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。 fn at(state: &AppState, forced: Option) -> pages::Model { - model( - state, - 1180.0, - forced, - pages::Category::All, - &pages::InstallDraft::default(), - ) + let draft = pages::InstallDraft::default(); + model(state, 1180.0, forced, pages::Category::All, &draft) } #[test] diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index af8c5b3..f5a245f 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -75,7 +75,7 @@ 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; diff --git a/crates/app-iced/src/pages/download/model.rs b/crates/app-iced/src/pages/download/model.rs index 27cf7cc..186f505 100644 --- a/crates/app-iced/src/pages/download/model.rs +++ b/crates/app-iced/src/pages/download/model.rs @@ -29,10 +29,20 @@ 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)] pub enum Category { diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index c6a346e..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, InstallDraft, Loader}; +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 9f9730e..f5c380a 100644 --- a/crates/core/src/game/install/execute.rs +++ b/crates/core/src/game/install/execute.rs @@ -23,15 +23,32 @@ //! [`execute`] 与 CLI 的契约**一字不变**(不传取消标志 = 今天的行为); //! 取消走 [`execute_with_cancel`],判据三条: //! -//! 1. **不再起新文件**:每个文件开工前看一眼标志,置位就不进 `install_one`; +//! 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` 清掉(不留半截垃圾); +//! `.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::future::poll_fn; use std::path::Path; @@ -160,8 +177,17 @@ pub async fn execute_with_cancel( .map(|file| { let sink = sink.clone(); async move { - // 取消后不再起新文件;在飞的那个与取消 future 赛跑,取消赢就 drop 掉下载 future - // (响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉),于是取消延迟 = 一个数据块。 + // **每个 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); diff --git a/crates/core/src/game/install/tests/execute.rs b/crates/core/src/game/install/tests/execute.rs index adb7eb3..881ce3d 100644 --- a/crates/core/src/game/install/tests/execute.rs +++ b/crates/core/src/game/install/tests/execute.rs @@ -314,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 4f83294..14b9c18 100644 --- a/crates/i18n/src/catalog/download.rs +++ b/crates/i18n/src/catalog/download.rs @@ -86,6 +86,10 @@ macro_rules! download_entries { "{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 04dad58..589310b 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -86,6 +86,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("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 14b2bed..e22bc0a 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -71,6 +71,12 @@ pub enum CoreEvent { JavaChanged(JavaPageView), /// 安装进度(`None` = 清掉进度面板) InstallProgress(Option), + /// 用户取消了安装(**不带人读文案**) + /// + /// 为什么只有信号没有文本:`presenter` 不依赖 `unlucky-i18n`,任何写死的中文都会和 + /// 页面语言打架(下载页目前渲染英文,一条中文 Toast 会**同屏一中一英**,复审 P2-2)。 + /// 文案由 app 层按 `Key` 现求:见 `pages::download::cancelled_toast`。 + InstallCancelled, /// 某 (加载器, 原版版本) 可用的加载器版本列表(下载页「安装选项」的版本下拉)。 /// /// `result` 用 `Result` 而不是空列表:**取不到**与**确实没有**是两件事, diff --git a/crates/presenter/src/host/cancel_tests.rs b/crates/presenter/src/host/cancel_tests.rs index 99505e7..c044e1b 100644 --- a/crates/presenter/src/host/cancel_tests.rs +++ b/crates/presenter/src/host/cancel_tests.rs @@ -24,8 +24,8 @@ //! | 现象 | 怎么量 | 抓住什么 / 抓不住什么 | //! |---|---|---| //! | 进度还在推进 | 取消后收到的 `InstallProgress(Some)` 条数与 `files_done` 增量 | 抓得住「UI 侧还在被事件喂」;抓不住事件之外的后台动作 | -//! | 断点文件还在长 | `/**/*.ullpart*` 的总字节,取消时 vs 取消后 20 s | 抓得住「网络/磁盘确实还在写」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) | -//! | 实例被登记 | `/versions//.json` 与 `.minecraft/instances` 有没有多出来 | 抓得住「取消后仍然落了成品」;抓不住「用户看不到的临时目录」 | +//! | 断点文件 | `/**/*.ullpart*` 的总字节:取消时 vs 取消后 20 s,**并且**绝对值必须是 0 | 抓得住「还在写」与「留了半截垃圾」;抓不住「线程还活着」(`.ullpart` 不再长也可能是卡在等响应) | +//! | 半成品变成品 | `/versions//.json`(版本清单)与 `/config/profiles.json`(**实例登记的唯一真相源**,`LauncherLayout::profiles_config()`) | 抓得住「取消后仍然落了成品 / 登记了实例」;抓不住「用户看不到的临时目录」 | //! //! 线程与 socket 两样**抓不住**(没有可移植的查询口,见 `probe` 一节),所以这里不用它当判据。 @@ -95,6 +95,9 @@ fn real_install_cancel_stops_progress_and_leaves_no_half_product() { 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); // ---- 起点:取消之前 ---- @@ -193,20 +196,22 @@ fn real_install_cancel_stops_progress_and_leaves_no_half_product() { } // ---- 断点文件 / 临时目录 / 成品三样 ---- - // 版本 JSON 只在「全量就绪」才落盘(`execute` 的 complete 判定),取消后不该有成品; - // 实例登记在最后一步之后,取消后不该多出实例。 + // 版本 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] 版本清单 {} / 实例目录 {}", + "[cancel-test] 版本清单{} / profiles.json 里 {} 条实例", if version_json.is_file() { "已落盘" } else { "未落盘" }, - game.join("instances").is_dir() + profiles.profiles.len() ); // ---- 判据(修好之后必须成立的三条) ---- @@ -230,6 +235,15 @@ fn real_install_cancel_stops_progress_and_leaves_no_half_product() { 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(), "取消之后仍然落了版本清单:半成品被记成「已安装」" diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 2d0eddf..1d1b525 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -71,7 +71,9 @@ impl CoreHost { self.install_epoch = self.install_epoch.wrapping_add(1); self.install = None; self.send(CoreEvent::InstallProgress(None)); - self.toast(ToastKind::Info, install::CANCELLED); + // 只发信号、不发文案(复审 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/state.rs b/crates/presenter/src/state.rs index 72b23a7..de73a69 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -81,6 +81,11 @@ 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`, @@ -131,6 +136,7 @@ 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(), @@ -245,6 +251,9 @@ 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,