From fbeff94e552c937f55641ac1825e971e740bcb5e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 23:34:06 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(packui):=20M113=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=89=B9=20=E2=80=94=E2=80=94=20=E5=86=85=E5=AE=B9=E5=BA=93?= =?UTF-8?q?=E9=A1=B5=E7=9A=84=E5=AF=BC=E5=85=A5/=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8C=BA=EF=BC=88=E7=95=8C=E9=9D=A2=20+=20DTO=20+=20i18n=20+?= =?UTF-8?q?=20=E6=8B=96=E6=8B=BD=E8=90=BD=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入入口(拖拽 + 路径输入 + 目标实例)与导出(格式选择 + 预检)的**界面层**, 数据面镜像 M111/M112 的管线报告(presenter DTO),进度整块复用下载页的安装面板。 ## 落了什么 - pages/content/packui*:入口 / 进度块 / 结果块 / 导出预检块(每文件 ≤400) - 拖拽区(hover 变主色描边)+ 路径输入框 + 目标实例 chips - 进度:一行「步骤 n/5 · 解包覆盖内容」+ **下载页那块安装面板**(不另画进度条) - 结果:三态(Success/Partial/Failed)+ 缺文件逐条(Warn/Tertiary)+ 冲突逐条 (成因 + 本次处置 + 可展开三方 SHA-1)+ 致命块(Error/ErrorContainer) - 导出:mrpack / CF 二选一 + 包名/版本/路径 + 预检「将内联 N 个文件 · 无法导出 M 项」 + 逐条无法导出项 + 已知损失 + 确认写盘 - presenter/src/dto/pack{,/format,/import,/conflict,/export}.rs:镜像 core 报告的 DTO (三态、五阶段、三分类、十一条原因,全部 from_slug 与 core 稳定码逐字对齐) - i18n:81 条 Pack* 键(content_entries! 尾部追加;golden.rs 同序同文) - app/update.rs:FileDropped / FileHovered / FilesHoveredLeft → 内容库页(本版唯一的选文件通道) - M109 的四个文件各一处插入:mod 声明 + Pack(..) 变体 + pack 字段 + 正列插入点 ## 边界(如实) - 没有系统文件选择框:依赖树里无对话框依赖,加它要动根 Cargo.toml + 许可门禁 + Linux 后端选型(影响单文件产物)。入口是拖拽 + 路径输入,原因写在界面上。 - 冲突没有逐文件决策:core 的 ConflictPolicy 是整次导入一个全局档,ConflictFile 是事后账。 本批给「逐文件看 + 整体翻档(以包为准重新导入)」,文案明写「两者都留(改名)暂不支持」。 默认一律 KeepUser——绝不默认覆盖用户改过的文件。 - 本批按钮半通:拖拽/路径/格式/实例/展开都真生效;「点导入真的开跑」「预检/确认写盘」 要等第二批的 AppCommand 与事件通道(M109 rebase 到新 main 后按新形状写一次)。 --- crates/app-iced/src/app/update.rs | 31 +- crates/app-iced/src/app/update/content.rs | 101 ++++++ crates/app-iced/src/pages/content.rs | 4 + crates/app-iced/src/pages/content/demo.rs | 5 + crates/app-iced/src/pages/content/message.rs | 2 + crates/app-iced/src/pages/content/model.rs | 14 + .../app-iced/src/pages/content/model/items.rs | 3 + .../app-iced/src/pages/content/model/tests.rs | 1 + crates/app-iced/src/pages/content/packui.rs | 57 +++ .../app-iced/src/pages/content/packui/demo.rs | 224 ++++++++++++ .../src/pages/content/packui/message.rs | 57 +++ .../src/pages/content/packui/model.rs | 219 ++++++++++++ .../src/pages/content/packui/model/export.rs | 228 ++++++++++++ .../pages/content/packui/model/progress.rs | 114 ++++++ .../src/pages/content/packui/model/report.rs | 242 +++++++++++++ .../src/pages/content/packui/model/tests.rs | 296 +++++++++++++++ .../src/pages/content/packui/tests.rs | 104 ++++++ .../app-iced/src/pages/content/packui/view.rs | 233 ++++++++++++ .../src/pages/content/packui/view/export.rs | 274 ++++++++++++++ .../src/pages/content/packui/view/parts.rs | 99 +++++ .../src/pages/content/packui/view/progress.rs | 95 +++++ .../src/pages/content/packui/view/report.rs | 268 ++++++++++++++ crates/app-iced/src/pages/content/view.rs | 15 +- crates/i18n/src/catalog/content.rs | 154 ++++++++ crates/i18n/src/catalog/golden.rs | 81 +++++ crates/presenter/src/dto/mod.rs | 10 + crates/presenter/src/dto/pack.rs | 49 +++ crates/presenter/src/dto/pack/conflict.rs | 71 ++++ crates/presenter/src/dto/pack/export.rs | 219 ++++++++++++ crates/presenter/src/dto/pack/format.rs | 47 +++ crates/presenter/src/dto/pack/import.rs | 338 ++++++++++++++++++ 31 files changed, 3646 insertions(+), 9 deletions(-) create mode 100644 crates/app-iced/src/pages/content/packui.rs create mode 100644 crates/app-iced/src/pages/content/packui/demo.rs create mode 100644 crates/app-iced/src/pages/content/packui/message.rs create mode 100644 crates/app-iced/src/pages/content/packui/model.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/export.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/progress.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/report.rs create mode 100644 crates/app-iced/src/pages/content/packui/model/tests.rs create mode 100644 crates/app-iced/src/pages/content/packui/tests.rs create mode 100644 crates/app-iced/src/pages/content/packui/view.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/export.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/parts.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/progress.rs create mode 100644 crates/app-iced/src/pages/content/packui/view/report.rs create mode 100644 crates/presenter/src/dto/pack.rs create mode 100644 crates/presenter/src/dto/pack/conflict.rs create mode 100644 crates/presenter/src/dto/pack/export.rs create mode 100644 crates/presenter/src/dto/pack/format.rs create mode 100644 crates/presenter/src/dto/pack/import.rs diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index a2f1bd0..e46b7f7 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -92,12 +92,31 @@ impl App { Message::Frame => self.on_frame(), Message::Heartbeat => self.on_heartbeat(), Message::Window(event) => { - if let window::Event::Resized(size) = event { - self.width = size.width; - // 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。 - self.height = size.height; - self.view_dirty = true; - self.refresh_view(); + match event { + window::Event::Resized(size) => { + self.width = size.width; + // 高度是背景平铺网格的视口判据之一(宽度早就有),以前只记宽是因为没有背景层。 + self.height = size.height; + self.view_dirty = true; + self.refresh_view(); + } + // M113: import/export —— 拖入整合包(文件或 MultiMC·Prism 实例目录)。 + // 这是本版**唯一**的「选文件」通道:依赖树里没有对话框依赖,加它要动根 + // `Cargo.toml` 与许可门禁(见 `pages/content/packui.rs` 的边界说明)。 + window::Event::FileDropped(path) => { + self.on_pack_dropped(path); + self.refresh_view(); + } + window::Event::FileHovered(_) => { + self.on_pack_hover(true); + self.refresh_view(); + } + window::Event::FilesHoveredLeft => { + self.on_pack_hover(false); + self.refresh_view(); + } + // 其余窗口事件本仓不消费(非穷尽枚举,保留兜底免得升级 iced 时炸) + _ => {} } Task::none() } diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 910501a..e23eefe 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -95,10 +95,111 @@ impl App { self.dispatch(AppCommand::InstallContent(Box::new(request))); } } + // M113: import/export —— 导入/导出区(细节在 `on_pack`) + pages::content::Message::Pack(message) => self.on_pack(message), } Task::none() } + /// 导入/导出区的消息(M113)。 + /// + /// 两条口径: + /// + /// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)—— + /// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理); + /// 2. **意图派指令**(导入 / 取消 / 预检 / 确认写盘)—— 本批只到「改本地状态」这一步, + /// presenter 侧的 `AppCommand::{ImportPack, ExportPack}` 随事件通道一起在下一批接上 + /// (见 mission M113 的两批时序:枚举顺序要按 M109 rebase 后的新 main 写一次,不写两遍)。 + pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) { + use pages::content::PackMessage; + match message { + PackMessage::Dropped(path) => { + // 拖进来的包属于「整合包」这一类:顺手把类型切过去,否则这一块根本不在屏上 + self.content.kind = unlucky_presenter::dto::ContentKind::Modpack; + self.content.pack.draft = path.display().to_string(); + self.content.pack.hover = false; + self.view_dirty = true; + } + PackMessage::Hover(hover) => { + self.content.pack.hover = hover; + self.view_dirty = true; + } + PackMessage::PathChanged(path) => { + self.content.pack.draft = path; + self.view_dirty = true; + } + PackMessage::SetInstance(instance) => { + self.content.pack.instance = instance; + self.view_dirty = true; + } + PackMessage::SetInstanceName(name) => { + self.content.pack.instance_name = name; + self.view_dirty = true; + } + PackMessage::SetFormat(format) => { + self.content.pack.format = format; + self.view_dirty = true; + } + PackMessage::SetExportName(name) => { + self.content.pack.export_name = name; + self.view_dirty = true; + } + PackMessage::SetExportVersion(version) => { + self.content.pack.export_version = version; + self.view_dirty = true; + } + PackMessage::SetOutput(path) => { + self.content.pack.output = path; + self.view_dirty = true; + } + PackMessage::ToggleConflict(path) => { + self.content.pack.expanded = if self.content.pack.expanded.as_deref() == Some(&path) + { + None + } else { + Some(path) + }; + self.view_dirty = true; + } + PackMessage::OpenExport => { + self.content.pack.export_open = true; + // 包名缺省取目标实例名(用户不该为一个名字再敲一遍键盘) + if self.content.pack.export_name.trim().is_empty() { + self.content.pack.export_name = + self.content.pack.instance.clone().unwrap_or_default(); + } + self.view_dirty = true; + } + PackMessage::AbortExport => { + self.content.pack.export_open = false; + self.view_dirty = true; + } + PackMessage::Dismiss => { + // 报告与探测诊断都归 presenter(下一批清);这里先收起页内的展开状态 + self.content.pack.expanded = None; + self.view_dirty = true; + } + PackMessage::Import + | PackMessage::Cancel + | PackMessage::Overwrite + | PackMessage::Precheck + | PackMessage::Confirm => {} + } + } + + /// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。 + pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) { + // 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿 + self.state + .navigate(unlucky_presenter::router::Route::Content); + self.on_pack(pages::content::PackMessage::Dropped(path)); + } + + /// 拖拽悬停状态(有文件在窗口上方时高亮入口)。 + pub(super) fn on_pack_hover(&mut self, hover: bool) { + self.on_pack(pages::content::PackMessage::Hover(hover)); + } + /// 改一个筛选字段并**立刻重查**(第一页)。 fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) { let mut query = self.state.content.query.clone(); diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index 27bec5b..71e4c80 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -52,6 +52,8 @@ mod demo; mod layout; mod message; mod model; +// M113: import/export —— 导入/导出区的界面(新代码全在 `packui*`) +mod packui; mod parts; mod view; @@ -60,6 +62,8 @@ mod tests; pub use message::Message; pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; +// M113: import/export +pub use packui::{Pack, PackMessage, PackPageState}; use iced::Element; diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs index 2bbbb44..9259019 100644 --- a/crates/app-iced/src/pages/content/demo.rs +++ b/crates/app-iced/src/pages/content/demo.rs @@ -177,6 +177,10 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { .find(|item| Some(&item.id) == target.as_ref()) .map(|item| item.label()); + // M113: import/export —— 导入导出区自己那一态由 `ULL_CONTENT_PACK` 选 + // (`importing` / `partial` / `conflict` / `export`;不设 = 只有入口) + let pack = super::packui::demo_pack(lang).0; + Content { kind, query: ContentQueryView { @@ -212,5 +216,6 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { } else { String::new() }, + pack, } } diff --git a/crates/app-iced/src/pages/content/message.rs b/crates/app-iced/src/pages/content/message.rs index 0cead41..a5e9286 100644 --- a/crates/app-iced/src/pages/content/message.rs +++ b/crates/app-iced/src/pages/content/message.rs @@ -37,4 +37,6 @@ pub enum Message { Retry, /// 装进目标实例 Install, + /// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`) + Pack(super::packui::PackMessage), } diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index 0cb05b8..ab76fa3 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -73,6 +73,8 @@ pub struct Content { pub versions: Vec, /// 关键词草稿 pub draft: String, + /// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检 + pub pack: super::packui::Pack, } /// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。 @@ -144,6 +146,17 @@ impl Content { target.as_deref(), ); + // M113: import/export —— 进度与报告在 presenter(`AppState.import` / `.export`), + // 页内选择在 `page.pack`。两批落地:本批先接页内选择(入口可用),事件回流在下一批。 + // `targets` 下面会被移动进结构体,所以这里先借用它建视图模型。 + let pack = super::packui::Pack::of( + &unlucky_presenter::dto::ImportState::default(), + &unlucky_presenter::dto::ExportState::default(), + &page.pack, + &targets, + catalog, + ); + Self { kind: page.kind, query: ContentQueryView { @@ -170,6 +183,7 @@ impl Content { panel: content_install(state, catalog), versions, draft: page.draft.clone(), + pack, } } diff --git a/crates/app-iced/src/pages/content/model/items.rs b/crates/app-iced/src/pages/content/model/items.rs index 0eef4bf..7e4be4e 100644 --- a/crates/app-iced/src/pages/content/model/items.rs +++ b/crates/app-iced/src/pages/content/model/items.rs @@ -18,6 +18,8 @@ pub struct PageState { pub target: Option, /// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`) pub draft: String, + /// 导入/导出区的页内选择(M113:路径草稿 / 目标实例 / 格式 / 展开的那条冲突) + pub pack: super::super::packui::PackPageState, } impl Default for PageState { @@ -27,6 +29,7 @@ impl Default for PageState { selected: None, target: None, draft: String::new(), + pack: super::super::packui::PackPageState::default(), } } } diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs index c82d84f..b20074b 100644 --- a/crates/app-iced/src/pages/content/model/tests.rs +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -230,6 +230,7 @@ fn can_install_needs_a_selection_and_no_blockers() { selected: None, target: None, draft: String::new(), + pack: super::super::packui::PackPageState::default(), }; // 没选中项目 ⇒ 不能装(也不该说「成功」) diff --git a/crates/app-iced/src/pages/content/packui.rs b/crates/app-iced/src/pages/content/packui.rs new file mode 100644 index 0000000..5dcb9e0 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui.rs @@ -0,0 +1,57 @@ +//! 整合包**导入 / 导出**区(M113 `PACKUI`):内容库页里「把我的包装进来」与「把我的实例打出去」。 +//! +//! ## 为什么长在内容库页上 +//! +//! 用户令:「下载页只负责下载整合包和Mod/光影」+「支持当前主流的整合包格式导入导出的功能」。 +//! 下载页(+内容库)是「拿内容进来」的地方,所以: +//! +//! - **导入**(本地包 → 实例):入口在这里,和「从站点拿整合包」摆在同一页; +//! - **导出**(实例 → 包):同一块,选目标实例 + 格式,预检之后再写盘。 +//! +//! ## 三种格式都是 core 的既有能力,本模块只做界面 +//! +//! | 格式 | 导入 | 导出 | 界面上的如实说明 | +//! |---|---|---|---| +//! | CurseForge `manifest.json` | 是(`files[]` 远程补全**未接通**) | 是 | 缺 CF 客户端 ⇒ 那些条目只能记成「没搞到」,逐条列出,**绝不猜 URL** | +//! | Modrinth `.mrpack` | 是 | 是 | —— | +//! | MultiMC / Prism 实例目录 | 是 | **否**(core 有意省略) | 文案明写「只能导入、不能导出」 | +//! +//! `.disabled` 是**路径后缀**(不是 `env.client=optional`)——这条在导出预检的逐条原因里出现, +//! 界面上不解释「可选依赖」,因为那不是同一件事。 +//! +//! ## 文件分工 +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`message`] | 本区的意图(挂在 `content::Message::Pack` 下) | +//! | [`model`] | 视图模型(快照 → 要画的东西;纯决策可单测) | +//! | [`view`] | 画法(进度**整块**复用下载页的安装面板) | +//! | `demo` | 出图夹具(`ULL_CONTENT_PACK` 选态) | +//! +//! ## 本版明确的边界(报告里同样登记) +//! +//! - **没有系统文件选择框**:依赖树里没有 `rfd` 一类的对话框依赖,加它要动根 `Cargo.toml`、 +//! 过许可门禁、还要给 Linux 选后端(GTK / xdg-portal),直接影响「单文件产物」。所以入口是 +//! **拖拽(真能用)+ 路径输入框**,并且把原因写在界面上([`model::Pack::no_dialog_note`])。 +//! - **冲突没有逐文件决策**:core 的 `ConflictPolicy` 是整次导入一个全局档(`KeepUser` 默认 / +//! `Overwrite`),`ConflictFile` 是**事后账**。所以这一块给的是「逐文件看 + 整体翻档」, +//! 文案如实写「两者都留(改名)暂不支持」。逐文件策略已登记为后续 core mission。 + +mod demo; +mod message; +mod model; +mod view; + +#[cfg(test)] +mod tests; + +pub use message::Message as PackMessage; +pub use model::{Pack, PackPageState}; + +/// 区入口(内容库页的正列调它)。 +pub(crate) use view::section; + +/// 出图夹具:按 `ULL_CONTENT_PACK` 选态(`importing` / `partial` / `conflict` / `export`)。 +pub(crate) fn demo_pack(lang: unlucky_i18n::Lang) -> (Pack, PackPageState) { + demo::pack(lang) +} diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs new file mode 100644 index 0000000..e3aaf5c --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -0,0 +1,224 @@ +//! 出图夹具(D30):`ULL_CONTENT_PACK` 选「同一页的哪一态」。 +//! +//! ## 为什么是环境变量 +//! +//! `pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」;而 +//! `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。所以沿用本仓既有的取证开关范式 +//! (`ULL_START_ROUTE` / `ULL_CONTENT_KIND` / `ULL_CONTENT_STATE`): +//! +//! | 变量 | 取值 | 作用 | +//! |---|---|---| +//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `export` | 选导入导出区的哪一态(不设 = 只有入口) | +//! +//! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`), +//! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行, +//! 而不是「为了出图另写一套」。 + +use std::path::PathBuf; + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, + ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportProgressView, + ImportReportView, ImportStageView, ImportState, ImportStatusView, InstanceId, LoaderKind, + MissingFileView, MissingReasonView, PackFormatView, +}; + +use crate::pages::content::Target; + +use super::model::{Pack, PackPageState}; + +/// 取证开关名。 +const ENV_PACK: &str = "ULL_CONTENT_PACK"; + +/// 两个实例(与内容库页的演示环境同一批,避免同一张图上出现两套实例名)。 +fn targets() -> Vec { + vec![ + Target { + id: "survival".to_string(), + name: "Survival".to_string(), + mc_version: "1.20.1".to_string(), + loader: LoaderKind::Fabric, + }, + Target { + id: "modded".to_string(), + name: "Modded".to_string(), + mc_version: "1.21.1".to_string(), + loader: LoaderKind::Forge, + }, + ] +} + +/// 按环境变量造一态(不设 = 只有入口,没有进度也没有报告)。 +pub(super) fn pack(lang: Lang) -> (Pack, PackPageState) { + let state = std::env::var(ENV_PACK).unwrap_or_default(); + pack_in(&state, lang) +} + +/// 按名字造一态(单测直接调它,不碰环境变量:并行测试里改 env 是竞态)。 +pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) { + let catalog = lang.catalog(); + let instance: Option = Some("survival".to_string()); + let mut page = PackPageState { + instance, + ..PackPageState::default() + }; + let mut import = ImportState::default(); + let mut export = ExportState::default(); + + match state { + "importing" => import.progress = Some(progress()), + "partial" => import.report = Some(report(ImportStatusView::Partial, false)), + "conflict" => { + import.report = Some(report(ImportStatusView::Partial, true)); + page.expanded = Some("config/sodium-options.json".to_string()); + } + "export" => { + page.export_open = true; + page.export_name = "Survival 1.20.1".to_string(); + export.preview = Some(preview()); + } + _ => {} + } + + let model = Pack::of(&import, &export, &page, &targets(), catalog); + (model, page) +} + +/// 导入中:第 2 阶段(解包覆盖内容),计数已知。 +fn progress() -> ImportProgressView { + ImportProgressView { + stage: ImportStageView::Extract, + done: 418, + total: 640, + current: Some("overrides/config/sodium-options.json".to_string()), + pack: "Better MC 1.2.0".to_string(), + } +} + +/// 一条落地文件。 +fn file( + path: &str, + action: FileActionView, + bytes: u64, +) -> unlucky_presenter::dto::ImportedFileView { + unlucky_presenter::dto::ImportedFileView { + path: path.to_string(), + action, + bytes, + } +} + +/// 报告(`conflicts` 决定造不造冲突那一块)。 +fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView { + let mut report = ImportReportView { + status, + format: Some(PackFormatView::Curseforge), + pack_name: Some("Better MC".to_string()), + pack_version: Some("1.2.0".to_string()), + game_version: Some("1.20.1".to_string()), + loader: Some("fabric-loader 0.15.11".to_string()), + version_id: Some("1.20.1-fabric0.15.11".to_string()), + target: PathBuf::from("C:/ULL/instances/survival/.minecraft"), + files: vec![ + file("mods/sodium.jar", FileActionView::Installed, 812_000), + file( + "mods/lithium.jar", + FileActionView::SkippedIdentical, + 640_000, + ), + file("config/xaeroplus.cfg", FileActionView::KeptUser, 4_200), + ], + conflicts: Vec::new(), + missing: vec![ + MissingFileView { + path: Some("mods/create-1.2.3.jar".to_string()), + display: "mods/create-1.2.3.jar".to_string(), + reason: MissingReasonView::FetchFailed, + detail: "HTTP 404".to_string(), + }, + MissingFileView { + path: None, + display: "123456:789012".to_string(), + reason: MissingReasonView::NoSource, + detail: "CurseForge 文件端点未接通".to_string(), + }, + MissingFileView { + path: Some("mods/jei-1.20.1.jar".to_string()), + display: "mods/jei-1.20.1.jar".to_string(), + reason: MissingReasonView::Offline, + detail: String::new(), + }, + ], + rejected: vec!["org.lwjgl3:3.3.1".to_string()], + warnings: vec!["包内 `server-overrides/` 未解压(客户端导入)".to_string()], + error: None, + registered: Some("Survival".to_string()), + bytes_written: 3_400_000, + rolled_back: true, + }; + if conflicts { + report.conflicts = vec![ + conflict( + "config/sodium-options.json", + ConflictReasonView::UserModified, + ), + conflict("mods/xaeroplus.jar", ConflictReasonView::UserDeleted), + conflict("options.txt", ConflictReasonView::PreExisting), + ]; + } + report +} + +fn conflict(path: &str, reason: ConflictReasonView) -> ConflictFileView { + ConflictFileView { + path: path.to_string(), + reason, + // 默认档永远是「保留我的」——夹具也一样,截图不许示范成默认覆盖 + choice: ConflictChoiceView::KeepUser, + pack_sha1: "9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5".to_string(), + base_sha1: Some("3a7d90ff11bc22445566ef7788990011aabbccdd".to_string()), + disk_sha1: Some("c1d2e3f4050617283940a5b6c7d8e9f0a1b2c3d4".to_string()), + pack_size: 4_200, + } +} + +/// 导出预检:12 内联 / 1 跳过(包不完整)。 +fn preview() -> ExportPreviewView { + let mut files = vec![ExportFileView { + path: "mods/create-1.2.3.jar".to_string(), + class: ExportClassView::Inline, + reason: ExportReasonView::NoRemoteMatch, + bytes: 2_100_000, + disabled: false, + }]; + for index in 0..11 { + files.push(ExportFileView { + path: format!("mods/generated-{index:02}.jar"), + class: ExportClassView::Inline, + reason: ExportReasonView::NoRemoteMatch, + bytes: 512_000, + disabled: false, + }); + } + files.push(ExportFileView { + path: "mods/.cache/backup.jar".to_string(), + class: ExportClassView::Skipped, + reason: ExportReasonView::SymbolicLink, + bytes: 0, + disabled: false, + }); + + ExportPreviewView { + format: PackFormatView::Modrinth, + instance: "survival".to_string(), + name: "Survival 1.20.1".to_string(), + version: "1.0.0".to_string(), + target: PathBuf::from("C:/ULL/downloads/Survival-1.0.0.mrpack"), + files, + notes: vec![ + "CF 表达不了禁用位:`.disabled` 文件已内联".to_string(), + "离线目录:所有文件都证明不了可被远程替代,全部内联".to_string(), + ], + } +} diff --git a/crates/app-iced/src/pages/content/packui/message.rs b/crates/app-iced/src/pages/content/packui/message.rs new file mode 100644 index 0000000..62b8983 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/message.rs @@ -0,0 +1,57 @@ +//! 整合包导入/导出区的**自有**消息(M113 `PACKUI`)。 +//! +//! 与内容库页同一个范式(`pages/content/message.rs`):这一支挂在 +//! [`super::Message::Pack`] 下面,`pages::Message` 只留 `Content(..)` 一个投递口, +//! 于是「加一条导入/导出消息」不碰共享枚举的正文,也不碰 `app/update/page_messages.rs` +//! 那条**不许 `_ =>`** 的 match。 +//! +//! **一条纪律**(与内容库页同一句):这里的每个变体都是**意图**,不是状态变更。 +//! 拖入文件 ≠ 已经导入(证据是 `ImportProgress` / `ImportReport` 事件回流); +//! 点「导出」≠ 已经写了盘(证据是预检与 `ExportDone` 事件)。 + +use std::path::PathBuf; + +use unlucky_presenter::dto::{InstanceId, PackFormatView}; + +/// 导入/导出区的消息。 +#[derive(Debug, Clone)] +pub enum Message { + /// 拖入了一个文件或目录(由 `app/update.rs` 的窗口事件转发) + /// + /// 只**落成草稿并立刻探测**,不直接开跑:用户要能看到「这个包看起来像 X」再决定。 + Dropped(PathBuf), + /// 拖拽悬停状态变了(`true` = 有文件在窗口上方) + Hover(bool), + /// 路径输入框变了(**只改草稿**,不派指令) + PathChanged(String), + /// 提交导入(用当前草稿路径) + Import, + /// 取消正在跑的导入(阶段边界生效) + Cancel, + /// 关掉结果面板(报告 / 致命错误都走这一条) + Dismiss, + /// 以包为准重新导入(对应 `overwrite = true`;**只在用户显式点过之后**) + Overwrite, + /// 展开/收起某一条冲突的三方明细 + ToggleConflict(String), + /// 换目标实例(`None` = 按包名新建) + SetInstance(Option), + /// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字) + SetInstanceName(String), + /// 换导出格式 + SetFormat(PackFormatView), + /// 换导出包名 + SetExportName(String), + /// 换导出包版本 + SetExportVersion(String), + /// 换落盘路径(留空 = 让 host 给缺省) + SetOutput(String), + /// 跑预检(**不写盘**) + Precheck, + /// 确认写盘(预检已经有结果才可达) + Confirm, + /// 放弃这次导出(关掉预检面板,不写盘) + AbortExport, + /// 打开导出面板 + OpenExport, +} diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs new file mode 100644 index 0000000..8e1e4cb --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -0,0 +1,219 @@ +//! 导入/导出区的视图模型:**presenter 快照 + 页面选择 → 要画的东西**。 +//! +//! ## 分层(与内容库页、下载页同一条) +//! +//! | 数据源 | 谁转 | 用途 | +//! |---|---|---| +//! | `AppState::import` / `AppState::export` | [`Pack::of`] | 产品运行时的真页面 | +//! | [`super::demo`] 的固定夹具 | 同上 | `pages_demo` 出图与单测 | +//! +//! 页面自己**不留**业务状态:进度、报告、预检都在 presenter(`ImportState` / `ExportState`)。 +//! [`PackPageState`] 里只有「用户在这一屏上点了什么」——路径草稿、目标实例、格式、包名、 +//! 展开的那条冲突。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | 本文件 | 整块 [`Pack`] + 页内选择 [`PackPageState`] + 探测诊断 [`Probe`] | +//! | [`progress`] | 阶段 / 进度 → 下载页面板的入参(**唯一**的进度形状来源) | +//! | [`report`] | 导入结果:三态 + 容忍域逐条 + 致命块 | +//! | [`export`] | 导出:格式选择 + 预检(不写盘) | +//! +//! ## 容忍 / 致命在模型层就分开 +//! +//! [`report::Report::missing`] 是「搞不到某个文件」(实例可用),[`report::Report::fatal`] +//! 是「已整包回滚」。两者字段类型不同、画的时候颜色与图标也不同,不可能混成一个列表。 + +mod export; +mod progress; +mod report; + +pub use export::{format_label, Export, Preview}; +pub use progress::Running; +pub use report::{ConflictRow, Report, Row}; + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ExportState, ImportState, InstanceId, PackFormatView, ProbeView}; + +use crate::pages::content::Target; + +/// 页面自留的选择(所有者在 `App::pack`,重建快照不该把它们冲掉)。 +#[derive(Debug, Clone, PartialEq)] +pub struct PackPageState { + /// 路径草稿(拖入或手输;提交后才进请求) + pub draft: String, + /// 有文件悬在窗口上方(拖拽高亮) + pub hover: bool, + /// 目标实例(`None` = 按包名新建) + pub instance: Option, + /// 新实例名 + pub instance_name: String, + /// 导出格式 + pub format: PackFormatView, + /// 导出包名 + pub export_name: String, + /// 导出包版本 + pub export_version: String, + /// 落盘路径草稿(空 = 让 host 给缺省) + pub output: String, + /// 导出面板开着吗 + pub export_open: bool, + /// 展开的那条冲突路径 + pub expanded: Option, +} + +impl Default for PackPageState { + fn default() -> Self { + Self { + draft: String::new(), + hover: false, + instance: None, + instance_name: String::new(), + // 导出的首选是 Modrinth:CF 的 `files[]` 只能写能证明 `projectID`/`fileID` 的项, + // 而离线目录证明不了任何一条 ⇒ 全内联 + 一堆「表达不了」的注记。mrpack 才是主路。 + format: PackFormatView::Modrinth, + export_name: String::new(), + export_version: "1.0.0".to_string(), + output: String::new(), + export_open: false, + expanded: None, + } + } +} + +/// 一个实例候选(导入目标与导出源共用)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackTarget { + /// 实例 id + pub id: InstanceId, + /// 一行显示名(`Survival · 1.20.1 · Fabric`) + pub label: String, +} + +/// 探测失败时的具名诊断块。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Probe { + /// 最像哪个格式(`None` = 三个都不像) + pub looks_like: Option, + /// 「看起来像 X,但缺 Y」(或「没有任何已知格式认领」) + pub lead: String, + /// 包里实际看到的标记(host 已截断,只给代表性几条) + pub saw: Vec, + /// 兜底说明(core 原文:试过哪些格式) + pub detail: String, +} + +/// 整块导入/导出区要画的东西。 +#[derive(Debug, Clone, PartialEq)] +pub struct Pack { + /// 拖拽高亮 + pub hover: bool, + /// 路径草稿 + pub draft: String, + /// 入口说明(拖什么) + pub drop_hint: &'static str, + /// 没有系统文件框的原因(如实说,不许装作有) + pub no_dialog_note: &'static str, + /// 目标实例候选 + pub targets: Vec, + /// 选中的目标实例 + pub instance: Option, + /// 新实例名 + pub instance_name: String, + /// 正在导入(有它就画那块面板) + pub running: Option, + /// 探测失败诊断 + pub probe: Option, + /// 导入结果 + pub report: Option, + /// 导出块 + pub export: Export, +} + +impl Pack { + /// presenter 快照 + 页面选择 → 视图模型(**唯一的真数据入口**)。 + pub fn of( + import: &ImportState, + export: &ExportState, + page: &PackPageState, + targets: &[Target], + catalog: Catalog, + ) -> Self { + let candidates: Vec = targets + .iter() + .map(|target| PackTarget { + id: target.id.clone(), + label: format!( + "{} · {} · {}", + target.name, + target.mc_version, + crate::pages::content::loader_name(target.loader) + ), + }) + .collect(); + let instance = page + .instance + .clone() + .or_else(|| candidates.first().map(|target| target.id.clone())); + + Self { + hover: page.hover, + draft: page.draft.clone(), + drop_hint: catalog.text(Key::PackDropHint), + no_dialog_note: catalog.text(Key::PackNoDialog), + targets: candidates.clone(), + instance: instance.clone(), + instance_name: page.instance_name.clone(), + running: import + .progress + .as_ref() + .map(|progress| progress::running_of(progress, catalog)), + probe: import.probe.as_ref().map(|probe| probe_of(probe, catalog)), + report: import + .report + .as_ref() + .map(|report| report::report_of(report, page, catalog)), + export: export::export_of(export, page, candidates, catalog), + } + } + + /// 有没有东西可画(没有就整块收起来,不占版面)。 + pub fn is_idle(&self) -> bool { + self.running.is_none() + && self.probe.is_none() + && self.report.is_none() + && !self.export.open + && self.draft.is_empty() + } +} + +/// 探测诊断:把「缺哪个标记」摊开。 +/// +/// `saw` 为空时补一句「没有可识别的东西」——空列表直接画出来是一行空白,用户会以为是渲染坏了。 +fn probe_of(probe: &ProbeView, catalog: Catalog) -> Probe { + let lead = match probe.looks_like { + Some(format) => catalog.render( + Key::PackProbeLooksLike, + &[ + ("format", export::format_label(format, catalog)), + ("missing", &probe.missing.join(" · ")), + ], + ), + None => catalog.text(Key::PackProbeUnknown).to_owned(), + }; + let saw = if probe.saw.is_empty() { + vec![catalog.text(Key::PackProbeNothing).to_owned()] + } else { + probe.saw.clone() + }; + Probe { + looks_like: probe.looks_like, + lead, + saw, + detail: probe.detail.clone(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/pages/content/packui/model/export.rs b/crates/app-iced/src/pages/content/packui/model/export.rs new file mode 100644 index 0000000..0131db3 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/export.rs @@ -0,0 +1,228 @@ +//! 导出:格式选择 + **预检**(不写盘)。 +//! +//! ## 预检与写盘是两次调用,同一套判据 +//! +//! [`Preview`] 只扫描分类、不写盘;确认之后才是真导出。任务书要的 +//! 「将内联 N 个文件 / 无法导出 M 项」正是 [`Preview::head`]。 +//! +//! 三分类的口径(与 core 一致,**没有第四类**): +//! +//! | 类 | 含义 | 预检里怎么呈现 | +//! |---|---|---| +//! | `Remote` | 能证明可被远程替代 | 一行计数 | +//! | `Inline` | 证明不了 ⇒ 原字节进 `overrides/` | 计数 + 字节 + 前几条样例 | +//! | `Skipped` | 压根没进包 | **逐条列出**(包不完整,不许合并成一句「已导出」) | +//! +//! `Skipped` 非零时 [`Preview::complete`] 为假,界面上会明确写「有文件不会进包」—— +//! 这是 M112 那句「不认识的 mod 不能丢、不能编」的用户侧落点:丢了就要看见丢在哪。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportState, + PackFormatView, +}; + +use super::{PackPageState, PackTarget, Row}; + +/// 预检里「内联文件样例」最多列几条(其余用一句「还有 N 个」收口)。 +const INLINE_SAMPLE: usize = 6; + +/// 预检块(**还没写盘**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Preview { + /// 一句话主结论:`将内联 12 个文件 · 无法导出 0 项` + pub head: String, + /// 远程引用行 + pub remote_line: String, + /// 内联字节行 + pub inline_line: String, + /// 落盘路径行 + pub target_line: String, + /// 包会完整吗(`false` = 有文件压根没进包) + pub complete: bool, + /// 完整性那句话 + pub complete_line: String, + /// 无法导出的项(逐条) + pub skipped: Vec, + /// 内联文件样例 + pub inline_sample: Vec, + /// `还有 N 个文件…` + pub more_line: Option, + /// core 的 `notes`(已知损失) + pub notes: Vec, +} + +/// 导出块。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Export { + /// 面板开着吗 + pub open: bool, + /// 当前格式 + pub format: PackFormatView, + /// 格式人读名 + pub format_label: &'static str, + /// 可选的格式(只有两个:mrpack / CF) + pub formats: Vec<(PackFormatView, &'static str)>, + /// 目标实例候选 + pub targets: Vec, + /// 选中的实例 + pub instance: Option, + /// 包名 + pub name: String, + /// 版本 + pub version: String, + /// 落盘路径草稿(空 = 缺省) + pub output: String, + /// MultiMC/Prism 只导入不导出(如实写) + pub format_note: &'static str, + /// 预检在飞 + pub pending: bool, + /// 预检的进行中说明 + pub pending_line: &'static str, + /// 预检结果 + pub preview: Option, + /// 写盘完成行 + pub done: Option, + /// 失败原因 + pub failed: Option, +} + +/// 导出一块。 +pub(super) fn export_of( + state: &ExportState, + page: &PackPageState, + targets: Vec, + catalog: Catalog, +) -> Export { + let instance = page + .instance + .clone() + .or_else(|| targets.first().map(|target| target.id.clone())); + let formats = PackFormatView::EXPORTABLE + .iter() + .map(|format| (*format, format_label(*format, catalog))) + .collect(); + + Export { + open: page.export_open, + format: page.format, + format_label: format_label(page.format, catalog), + formats, + targets, + instance, + name: page.export_name.clone(), + version: page.export_version.clone(), + output: page.output.clone(), + format_note: catalog.text(Key::PackExportMultimcNote), + pending: state.pending, + pending_line: catalog.text(Key::PackExportPending), + preview: state + .preview + .as_ref() + .map(|preview| preview_of(preview, catalog)), + done: state.done.as_ref().map(|done| { + catalog.render( + Key::PackExportDone, + &[ + ("path", &done.path.display().to_string()), + ("bytes", &human_bytes(done.bytes)), + ], + ) + }), + failed: state.failed.clone(), + } +} + +pub(super) fn preview_of(preview: &ExportPreviewView, catalog: Catalog) -> Preview { + let skipped: Vec = preview + .files + .iter() + .filter(|file| file.class == ExportClassView::Skipped) + .map(|file| skipped_row(file, catalog)) + .collect(); + let inline: Vec<&ExportFileView> = preview + .files + .iter() + .filter(|file| file.class == ExportClassView::Inline) + .collect(); + let inline_sample = inline + .iter() + .take(INLINE_SAMPLE) + .map(|file| file.path.clone()) + .collect::>(); + let more_line = (inline.len() > inline_sample.len()).then(|| { + catalog.render( + Key::PackPreviewMore, + &[("count", &(inline.len() - inline_sample.len()).to_string())], + ) + }); + + Preview { + head: catalog.render( + Key::PackPreviewHead, + &[ + ("inline", &preview.inline_count().to_string()), + ("skipped", &preview.skipped_count().to_string()), + ], + ), + remote_line: catalog.render( + Key::PackPreviewRemote, + &[("count", &preview.remote_count().to_string())], + ), + inline_line: catalog.render( + Key::PackPreviewInlineBytes, + &[("bytes", &human_bytes(preview.inline_bytes()))], + ), + target_line: catalog.render( + Key::PackPreviewTarget, + &[("path", &preview.target.display().to_string())], + ), + complete: preview.is_complete(), + complete_line: catalog + .text(if preview.is_complete() { + Key::PackPreviewComplete + } else { + Key::PackPreviewIncomplete + }) + .to_owned(), + skipped, + inline_sample, + more_line, + notes: preview.notes.clone(), + } +} + +fn skipped_row(file: &ExportFileView, catalog: Catalog) -> Row { + Row { + path: file.path.clone(), + note: reason_label(file.reason, catalog).to_owned(), + } +} + +/// 导出归类原因 → 人读(每条都写得能自己解释,不靠图标猜)。 +#[must_use] +pub fn reason_label(reason: ExportReasonView, catalog: Catalog) -> &'static str { + match reason { + ExportReasonView::ModrinthSha1 => catalog.text(Key::PackReasonModrinthSha1), + ExportReasonView::CurseForgeFingerprint => catalog.text(Key::PackReasonCurseFingerprint), + ExportReasonView::NoRemoteMatch => catalog.text(Key::PackReasonNoRemoteMatch), + ExportReasonView::CurseOnlyMatch => catalog.text(Key::PackReasonCurseOnly), + ExportReasonView::ModrinthOnlyMatch => catalog.text(Key::PackReasonModrinthOnly), + ExportReasonView::MissingSha512 => catalog.text(Key::PackReasonMissingSha512), + ExportReasonView::MissingCurseIds => catalog.text(Key::PackReasonMissingCurseIds), + ExportReasonView::DisabledNotExpressible => catalog.text(Key::PackReasonDisabled), + ExportReasonView::SymbolicLink => catalog.text(Key::PackReasonSymlink), + ExportReasonView::UnsafePath => catalog.text(Key::PackReasonUnsafePath), + ExportReasonView::Unreadable => catalog.text(Key::PackReasonUnreadable), + } +} + +/// 格式人读名。 +#[must_use] +pub fn format_label(format: PackFormatView, catalog: Catalog) -> &'static str { + match format { + PackFormatView::Curseforge => catalog.text(Key::PackFormatCurseforge), + PackFormatView::Modrinth => catalog.text(Key::PackFormatModrinth), + PackFormatView::Multimc => catalog.text(Key::PackFormatMultimc), + } +} diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs new file mode 100644 index 0000000..5b9ebaa --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -0,0 +1,114 @@ +//! 阶段与进度:**把导入进度映射成下载页那块面板的入参**(不另画一套进度 UI)。 +//! +//! ## 任务书第 5 条怎么落 +//! +//! 「沿用既有安装面板组件与状态机,禁止再写第二套进度 UI」—— +//! +//! 1. 导入的 `ImportStageView` / 计数 / 当前路径先映射成 [`InstallProgressView`](本文件 +//! [`panel_progress`]); +//! 2. 画法交给 `pages::download::install_panel`(M109 已经打过薄包装,同一个函数、同一套几何 +//! 与文案); +//! 3. 面板只认四个粗阶段(规划 / 下载 / 安装 / 登记),任务书要的「解包 / overrides / 补文件」 +//! 三档由**一行步骤说明**([`stage_line`])承载。 +//! +//! 第 3 点不是第二套进度 UI:多出来的只是一句文字,进度条、计数行、取消按钮仍然只有那一份。 +//! 为什么不再造一条按 `ImportStageView` 五档划分的进度条:那正是「第二套」的定义,而且两块条 +//! 同时出现时用户不知道该信哪一条。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + ImportProgressView, ImportStageView, InstallProgressView, InstallStage, +}; + +use crate::pages::download::Download; + +/// 抛进面板的进度行。 +#[derive(Debug, Clone, PartialEq)] +pub struct Running { + /// 包名(面板标题那一行) + pub pack: String, + /// 细档位说明(`步骤 2/5 · 解包覆盖内容`) + pub stage_line: String, + /// 交给下载页那块面板的进度(**唯一**的进度形状来源) + pub panel: Download, +} + +/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)。 +#[must_use] +pub fn stage_line(stage: ImportStageView, catalog: Catalog) -> String { + let total = ImportStageView::ALL.len(); + let index = ImportStageView::ALL + .iter() + .position(|item| *item == stage) + .map_or(1, |at| at + 1); + catalog.render( + Key::PackStageLine, + &[ + ("index", &index.to_string()), + ("total", &total.to_string()), + ("stage", catalog.text(stage_key(stage))), + ], + ) +} + +/// 阶段 → 文案 key。 +#[must_use] +pub const fn stage_key(stage: ImportStageView) -> Key { + match stage { + ImportStageView::Probe => Key::PackStageProbe, + ImportStageView::Extract => Key::PackStageExtract, + ImportStageView::Fetch => Key::PackStageFetch, + ImportStageView::Commit => Key::PackStageCommit, + ImportStageView::Register => Key::PackStageRegister, + } +} + +/// 导入阶段 → 安装面板的粗阶段(**唯一映射处**)。 +/// +/// `Extract` / `Commit` 都落到 `Installing`:对那块面板而言它们都是「在写盘」,区别由 +/// [`stage_line`] 说给用户。 +#[must_use] +pub const fn panel_stage(stage: ImportStageView) -> InstallStage { + match stage { + ImportStageView::Probe => InstallStage::Planning, + ImportStageView::Extract | ImportStageView::Commit => InstallStage::Installing, + ImportStageView::Fetch => InstallStage::Downloading, + ImportStageView::Register => InstallStage::Registering, + } +} + +/// 导入进度 → 下载页面板的入参。 +/// +/// `version` / `instance` 都填包名:面板第一行显示的就是「这次在装什么」, +/// 而导入的对象是包,不是某个版本 id。 +#[must_use] +pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { + InstallProgressView { + version: progress.pack.clone(), + instance: progress.pack.clone(), + loader: String::new(), + stage: panel_stage(progress.stage), + files_done: u64::from(progress.done), + files_total: u64::from(progress.total), + bytes_done: 0, + bytes_total: 0, + speed_bps: 0, + eta_secs: 0, + message: progress.current.clone().unwrap_or_default(), + } +} + +/// 进度事件 → 面板要的视图模型。 +pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running { + Running { + pack: progress.pack.clone(), + stage_line: stage_line(progress.stage, catalog), + panel: Download { + install: Some(crate::pages::download::install_view( + &panel_progress(progress), + catalog, + )), + ..Download::loading() + }, + } +} diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs new file mode 100644 index 0000000..493fec2 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -0,0 +1,242 @@ +//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。 +//! +//! ## 为什么容忍域与致命域连字段类型都不同 +//! +//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。 +//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以: +//! +//! | 域 | 类型 | 画法 | +//! |---|---|---| +//! | 容忍(缺文件 / 被拒 component / 注记) | [`Row`](路径 + 原因) | 中性容器 + `Info` 图标 + 逐条列表 | +//! | 致命(已回滚) | [`Fatal`](大类 + 原文 + 建议 + 稳定码) | `ErrorContainer` + `Error` 图标 + 标题换措辞 | +//! +//! 一个 `Option` 与一个 `Vec` 之间没有「误合并」的写法,这比任何样式约定都硬。 + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView, ImportReportView, + ImportStatusView, MissingFileView, MissingReasonView, +}; + +use super::PackPageState; + +/// 报告里的一行(容忍域:缺文件 / 被拒 component)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + /// 左列(路径 / 组件名) + pub path: String, + /// 右列(原因的人读文案) + pub note: String, +} + +/// 一条冲突(三方哈希 + 本次处置)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictRow { + /// 实例内路径 + pub path: String, + /// 成因(`你改过` / `你删过` / `本来就在`) + pub reason: String, + /// 本次实际处置(`保留我的` / `用包里的`) + pub choice: String, + /// 包内 SHA-1(截短) + pub pack_sha1: String, + /// 基线 SHA-1(`None` = 没有基线) + pub base_sha1: Option, + /// 磁盘 SHA-1(`None` = 文件不在) + pub disk_sha1: Option, + /// 展开中(展开才画三方哈希那行) + pub expanded: bool, +} + +/// 致命失败块(**已整包回滚**)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fatal { + /// 「已回滚 —— 目标目录零残留」 + pub title: String, + /// 大类名(`路径穿越` / `清单损坏` …) + pub label: String, + /// 具体是什么 + pub message: String, + /// 稳定码 + pub code: String, + /// 补救建议 + pub hint: Option, +} + +/// 导入结果块。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + /// 三态 + pub status: ImportStatusView, + /// 标题(已导入 / 有缺口 / 已回滚) + pub title: String, + /// 副标题(`包名 · 格式 · 游戏版本 · 加载器`) + pub subtitle: String, + /// 写盘行(`12 个文件已写盘 · 3.4 MB`) + pub written_line: String, + /// 致命失败(有它就**没有**下面的缺口区块) + pub fatal: Option, + /// 缺文件标题 + pub missing_title: Option, + /// 缺文件逐条(容忍域) + pub missing: Vec, + /// 冲突标题 + pub conflicts_title: Option, + /// 冲突逐条 + pub conflicts: Vec, + /// 被拒 component 逐条 + pub rejected: Vec, + /// 注记逐条 + pub warnings: Vec, + /// 能不能给「以包为准重新导入」(有冲突、且实例可用时才有意义) + pub can_overwrite: bool, + /// 「两者都留改名」为何不支持(有冲突时如实写) + pub overwrite_note: Option, +} + +impl Report { + /// 实例可用吗(`Success` / `Partial` 都是装上了)。 + pub const fn installed(&self) -> bool { + self.status.installed() + } +} + +/// 一次导入结果 → 视图模型。 +pub(super) fn report_of( + report: &ImportReportView, + page: &PackPageState, + catalog: Catalog, +) -> Report { + let missing_title = (!report.missing.is_empty()).then(|| { + catalog.render( + Key::PackReportMissing, + &[("count", &report.missing.len().to_string())], + ) + }); + let conflicts_title = (!report.conflicts.is_empty()).then(|| { + catalog.render( + Key::PackReportConflicts, + &[("count", &report.conflicts.len().to_string())], + ) + }); + + Report { + status: report.status, + title: status_title(report.status, catalog).to_owned(), + subtitle: subtitle(report, catalog), + written_line: catalog.render( + Key::PackReportWritten, + &[ + ("count", &report.written().to_string()), + ("bytes", &human(report)), + ], + ), + fatal: report.error.as_ref().map(|error| fatal_of(error, catalog)), + missing_title, + missing: report + .missing + .iter() + .map(|file| missing_row(file, catalog)) + .collect(), + conflicts_title, + conflicts: report + .conflicts + .iter() + .map(|file| conflict_row(file, page.expanded.as_deref(), catalog)) + .collect(), + rejected: report + .rejected + .iter() + .map(|component| Row { + path: component.clone(), + note: catalog.text(Key::PackReportRejectedNote).to_owned(), + }) + .collect(), + warnings: report.warnings.clone(), + can_overwrite: !report.conflicts.is_empty() && report.status == ImportStatusView::Partial, + overwrite_note: (!report.conflicts.is_empty()) + .then(|| catalog.text(Key::PackOverwriteNote).to_owned()), + } +} + +/// 字节数的人读串。 +fn human(report: &ImportReportView) -> String { + unlucky_presenter::dto::human_bytes(report.bytes_written) +} + +fn status_title(status: ImportStatusView, catalog: Catalog) -> &'static str { + match status { + ImportStatusView::Success => catalog.text(Key::PackStatusSuccess), + ImportStatusView::Partial => catalog.text(Key::PackStatusPartial), + ImportStatusView::Failed => catalog.text(Key::PackStatusFailed), + } +} + +/// 副标题:包名 · 格式 · 游戏版本 · 加载器(缺哪个就不写哪个,不拼「未知」)。 +fn subtitle(report: &ImportReportView, catalog: Catalog) -> String { + let mut parts: Vec = Vec::new(); + if let Some(format) = report.format { + parts.push(super::format_label(format, catalog).to_owned()); + } + if let Some(version) = &report.game_version { + parts.push(version.clone()); + } + if let Some(loader) = &report.loader { + parts.push(loader.clone()); + } + if let Some(name) = &report.pack_name { + parts.insert(0, name.clone()); + } + parts.join(" · ") +} + +fn fatal_of(error: &ImportErrorView, catalog: Catalog) -> Fatal { + Fatal { + title: catalog.text(Key::PackFatalTitle).to_owned(), + label: error.label.clone(), + message: error.message.clone(), + code: error.code.clone(), + hint: error.hint.clone(), + } +} + +fn missing_row(file: &MissingFileView, catalog: Catalog) -> Row { + Row { + path: file.path.clone().unwrap_or_else(|| file.display.clone()), + note: match file.reason { + MissingReasonView::Offline => catalog.text(Key::PackMissingOffline).to_owned(), + MissingReasonView::NoSource => catalog.text(Key::PackMissingNoSource).to_owned(), + MissingReasonView::FetchFailed => catalog.render( + Key::PackMissingFetchFailed, + &[("detail", file.detail.as_str())], + ), + }, + } +} + +fn conflict_row(file: &ConflictFileView, expanded: Option<&str>, catalog: Catalog) -> ConflictRow { + ConflictRow { + path: file.path.clone(), + reason: match file.reason { + ConflictReasonView::UserModified => catalog.text(Key::PackConflictUserModified), + ConflictReasonView::UserDeleted => catalog.text(Key::PackConflictUserDeleted), + ConflictReasonView::PreExisting => catalog.text(Key::PackConflictPreExisting), + } + .to_owned(), + choice: match file.choice { + ConflictChoiceView::KeepUser => catalog.text(Key::PackChoiceKeepUser), + ConflictChoiceView::Overwrite => catalog.text(Key::PackChoiceOverwrite), + } + .to_owned(), + pack_sha1: short_sha(&file.pack_sha1), + base_sha1: file.base_sha1.as_deref().map(short_sha), + disk_sha1: file.disk_sha1.as_deref().map(short_sha), + expanded: expanded == Some(file.path.as_str()), + } +} + +/// SHA-1 截短(前 8 位够人眼区分;全 40 位会把一行撑爆)。 +#[must_use] +pub fn short_sha(sha: &str) -> String { + sha.chars().take(8).collect() +} diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs new file mode 100644 index 0000000..74edfd9 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -0,0 +1,296 @@ +//! 导入/导出区视图模型的守门(M113):阶段映射、预检计数、三方哈希、容忍/致命分野。 +//! +//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、 +//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。 + +use std::path::PathBuf; + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, + ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView, + ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView, +}; + +use super::progress::{panel_progress, panel_stage, stage_line}; +use super::report::report_of; +use super::report::short_sha; +use super::{Pack, PackPageState}; + +fn catalog() -> unlucky_i18n::Catalog { + Lang::En.catalog() +} + +#[test] +fn stage_line_counts_the_five_steps_in_order() { + let expected = [ + (ImportStageView::Probe, "1/5"), + (ImportStageView::Extract, "2/5"), + (ImportStageView::Fetch, "3/5"), + (ImportStageView::Commit, "4/5"), + (ImportStageView::Register, "5/5"), + ]; + for (stage, position) in expected { + let line = stage_line(stage, catalog()); + assert!(line.contains(position), "{stage:?} 的步号不对:{line}"); + } + // 阶段名要真的是「那一档」,不能五个阶段都显示同一句 + let lines: Vec = ImportStageView::ALL + .iter() + .map(|stage| stage_line(*stage, catalog())) + .collect(); + for (index, line) in lines.iter().enumerate() { + for other in lines.iter().skip(index + 1) { + assert_ne!(line, other, "两个阶段的说明撞了:{line}"); + } + } +} + +#[test] +fn no_progress_stage_maps_to_a_terminal_install_stage() { + // 进度面板只该画「在跑」的形态:映射到 Done/Failed/Cancelled 会让面板提前画成收尾/失败 + for stage in ImportStageView::ALL { + let mapped = panel_stage(stage); + assert!( + !mapped.is_terminal(), + "{stage:?} 落到了终态 {mapped:?}(面板会画成收尾)" + ); + } +} + +#[test] +fn panel_progress_carries_the_counts_and_the_pack_name() { + let progress = ImportProgressView { + stage: ImportStageView::Fetch, + done: 41, + total: 90, + current: Some("mods/sodium.jar".to_string()), + pack: "Better MC".to_string(), + }; + let panel = panel_progress(&progress); + assert_eq!(panel.files_done, 41); + assert_eq!(panel.files_total, 90); + assert_eq!(panel.stage, InstallStage::Downloading); + // 面板第一行显示的就是包名(导入的对象是包,不是某个版本 id) + assert_eq!(panel.version, "Better MC"); +} + +#[test] +fn unknown_counts_keep_the_bar_unknown() { + // total == 0 时不允许画确定态进度条(那是「静止的假进度」的来源) + let progress = ImportProgressView { + stage: ImportStageView::Probe, + done: 0, + total: 0, + current: None, + pack: "Pack".to_string(), + }; + let panel = panel_progress(&progress); + assert_eq!(panel.files_total, 0); + assert_eq!(panel.speed_bps, 0, "导入没有字节速率,不许编"); +} + +#[test] +fn sha_is_shortened_to_eight_chars() { + assert_eq!( + short_sha("9f2c41ab77de3350aa1b8c0d5e6f708192a3b4c5"), + "9f2c41ab" + ); + assert_eq!(short_sha("abc"), "abc"); + assert_eq!(short_sha(""), ""); +} + +fn preview(files: Vec) -> ExportPreviewView { + ExportPreviewView { + format: PackFormatView::Modrinth, + instance: "survival".to_string(), + name: "Survival".to_string(), + version: "1.0.0".to_string(), + target: PathBuf::from("C:/tmp/out.mrpack"), + files, + notes: Vec::new(), + } +} + +fn export_file( + path: &str, + class: ExportClassView, + reason: unlucky_presenter::dto::ExportReasonView, +) -> ExportFileView { + ExportFileView { + path: path.to_string(), + class, + reason, + bytes: 1000, + disabled: false, + } +} + +#[test] +fn preview_counts_match_the_three_classes() { + use unlucky_presenter::dto::ExportReasonView; + let view = preview(vec![ + export_file( + "a.jar", + ExportClassView::Remote, + ExportReasonView::ModrinthSha1, + ), + export_file( + "b.jar", + ExportClassView::Inline, + ExportReasonView::NoRemoteMatch, + ), + export_file( + "c.jar", + ExportClassView::Inline, + ExportReasonView::MissingSha512, + ), + export_file( + "d.jar", + ExportClassView::Skipped, + ExportReasonView::SymbolicLink, + ), + ]); + assert_eq!(view.remote_count(), 1); + assert_eq!(view.inline_count(), 2); + assert_eq!(view.skipped_count(), 1); + assert_eq!(view.inline_bytes(), 2000); + assert!(!view.is_complete(), "有跳过项 ⇒ 包不完整"); +} + +#[test] +fn the_head_line_carries_the_two_numbers_the_task_asks_for() { + use unlucky_presenter::dto::ExportReasonView; + let view = preview(vec![ + export_file( + "b.jar", + ExportClassView::Inline, + ExportReasonView::NoRemoteMatch, + ), + export_file( + "c.jar", + ExportClassView::Inline, + ExportReasonView::MissingSha512, + ), + export_file( + "d.jar", + ExportClassView::Skipped, + ExportReasonView::UnsafePath, + ), + ]); + let block = super::export::preview_of(&view, catalog()); + // 「将内联 N 个文件 · 无法导出 M 项」两个数字必须来自同一份 files,不许另算 + assert!( + block.head.contains('2'), + "内联数没进 headline:{}", + block.head + ); + assert!( + block.head.contains('1'), + "跳过数没进 headline:{}", + block.head + ); + assert_eq!(block.skipped.len(), 1); + assert!(!block.complete); +} + +#[test] +fn report_keeps_tolerable_and_fatal_apart() { + let report = ImportReportView { + status: ImportStatusView::Failed, + format: Some(PackFormatView::Curseforge), + pack_name: Some("Broken".to_string()), + pack_version: None, + game_version: None, + loader: None, + version_id: None, + target: PathBuf::from("C:/tmp"), + files: Vec::new(), + conflicts: Vec::new(), + missing: vec![MissingFileView { + path: Some("mods/a.jar".to_string()), + display: "mods/a.jar".to_string(), + reason: MissingReasonView::Offline, + detail: String::new(), + }], + rejected: Vec::new(), + warnings: Vec::new(), + error: Some(ImportErrorView { + code: "pack_traversal".to_string(), + label: "路径穿越".to_string(), + message: "../etc/passwd".to_string(), + hint: Some("换一个来源".to_string()), + }), + registered: None, + bytes_written: 0, + rolled_back: true, + }; + let block = report_of(&report, &PackPageState::default(), catalog()); + assert!(block.fatal.is_some(), "致命块必须单独存在"); + assert_eq!(block.missing.len(), 1, "容忍域仍要逐条列出"); + assert!(!block.installed(), "Failed 不是「装上了」"); + assert!(!block.can_overwrite, "没有冲突时不给翻档动作"); +} + +#[test] +fn conflicts_default_to_keeping_the_user_copy() { + let report = ImportReportView { + status: ImportStatusView::Partial, + format: Some(PackFormatView::Modrinth), + pack_name: Some("Pack".to_string()), + pack_version: None, + game_version: None, + loader: None, + version_id: None, + target: PathBuf::from("C:/tmp"), + files: Vec::new(), + conflicts: vec![ConflictFileView { + path: "config/x.json".to_string(), + reason: ConflictReasonView::UserModified, + choice: ConflictChoiceView::KeepUser, + pack_sha1: "fixture".to_string(), + base_sha1: None, + disk_sha1: Some("disk".to_string()), + pack_size: 12, + }], + missing: Vec::new(), + rejected: Vec::new(), + warnings: Vec::new(), + error: None, + registered: None, + bytes_written: 0, + rolled_back: true, + }; + let page = PackPageState::default(); + let block = report_of(&report, &page, catalog()); + assert_eq!(block.conflicts.len(), 1); + assert!(!block.conflicts[0].expanded, "默认收起"); + assert_eq!( + block.conflicts[0].choice, + catalog().text(unlucky_i18n::Key::PackChoiceKeepUser), + "默认档必须是「保留我的」" + ); + assert!(block.can_overwrite, "有冲突要能整体翻档"); + assert!(block.overwrite_note.is_some(), "不支持「两者都留」要如实写"); + + // 展开的那一条才画三方哈希 + let expanded = PackPageState { + expanded: Some("config/x.json".to_string()), + ..PackPageState::default() + }; + let block = report_of(&report, &expanded, catalog()); + assert!(block.conflicts[0].expanded); +} + +#[test] +fn an_idle_pack_has_nothing_to_draw_but_the_entry() { + let import = unlucky_presenter::dto::ImportState::default(); + let export = unlucky_presenter::dto::ExportState::default(); + let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog()); + assert!(model.is_idle()); + assert!(model.running.is_none()); + assert!(model.report.is_none()); + // 入口说明永远在(用户得知道可以拖什么、为什么没有「选择文件」按钮) + assert!(!model.drop_hint.is_empty()); + assert!(!model.no_dialog_note.is_empty()); +} diff --git a/crates/app-iced/src/pages/content/packui/tests.rs b/crates/app-iced/src/pages/content/packui/tests.rs new file mode 100644 index 0000000..0cfbb94 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/tests.rs @@ -0,0 +1,104 @@ +//! 导入/导出区的守门(M113):四种出图态都装得满、入口文案都在。 +//! +//! 出图(D30)靠这几条夹具:任何一态装不满,截图就会是一块空容器,而不是「有话说」的界面。 +//! 视图模型的判定在 `model/tests.rs`,这里只管**夹具本身**。 + +use unlucky_i18n::Lang; + +use super::demo::pack_in; +use super::model::Pack; + +/// D30 要求的四态(导入中 / 部分成功 / 冲突选择 / 导出预检)。 +const STATES: [&str; 4] = ["importing", "partial", "conflict", "export"]; + +#[test] +fn every_screenshot_state_fills_something() { + for state in STATES { + let (model, _page) = pack_in(state, Lang::En); + assert!( + !model.is_idle(), + "{state} 这一态什么都没画(截图会是一块空容器)" + ); + assert_eq!(model.targets.len(), 2, "{state} 的实例候选不足"); + assert!(!model.drop_hint.is_empty()); + assert!( + !model.no_dialog_note.is_empty(), + "没有系统文件框这件事必须写在界面上" + ); + } +} + +#[test] +fn importing_shows_the_shared_install_panel() { + let (model, _page) = pack_in("importing", Lang::En); + let running = model.running.expect("导入中要有进度"); + // 进度形状来自下载页那块面板(`Download.install`),不是本区另造的 + assert!( + running.panel.install.is_some(), + "进度必须走下载页的安装面板视图模型" + ); + assert!( + running.stage_line.contains("2/5"), + "解包阶段是第 2/5 步:{}", + running.stage_line + ); + assert!(model.report.is_none(), "导入中不该同时有结果"); +} + +#[test] +fn partial_keeps_the_tolerable_rows_one_by_one() { + let (model, _page) = pack_in("partial", Lang::En); + let report = model.report.expect("部分成功要有报告"); + assert!(report.installed(), "Partial 仍是「装上了」"); + assert!(report.fatal.is_none(), "部分成功不是致命失败"); + assert_eq!(report.missing.len(), 3, "缺文件要逐条列出"); + assert!(report.missing_title.is_some()); + assert!(!report.rejected.is_empty(), "被拒 component 也要逐条"); +} + +#[test] +fn conflict_state_defaults_to_keeping_the_user_copy_and_expands_one_row() { + let (model, page) = pack_in("conflict", Lang::En); + let report = model.report.expect("冲突态要有报告"); + assert_eq!(report.conflicts.len(), 3); + assert!( + report.conflicts.iter().all(|row| !row.expanded) + || report.conflicts.iter().filter(|row| row.expanded).count() == 1, + "只展开用户点过的那一条" + ); + assert_eq!( + report.conflicts.iter().filter(|row| row.expanded).count(), + 1, + "夹具要展开一条,截图才看得见三方哈希" + ); + assert!(page.expanded.is_some()); + assert!(report.can_overwrite); + assert!(report.overwrite_note.is_some()); +} + +#[test] +fn export_state_has_a_preview_but_has_not_written_anything() { + let (model, _page) = pack_in("export", Lang::En); + assert!(model.export.open); + let preview = model.export.preview.expect("预检要有结果"); + assert!( + preview.head.contains('2'), + "内联数要进主结论:{}", + preview.head + ); + assert!(!preview.skipped.is_empty(), "无法导出的项要逐条"); + assert!(!preview.complete, "有跳过项 ⇒ 包不完整"); + assert!(!preview.notes.is_empty(), "已知损失要列出来"); + assert!( + model.export.done.is_none(), + "预检阶段不许出现「已写出」(那是写盘之后的事)" + ); +} + +#[test] +fn default_state_offers_the_entry_with_nothing_else() { + let (model, page): (Pack, _) = pack_in("", Lang::En); + assert!(model.is_idle()); + assert!(!model.export.open); + assert!(page.draft.is_empty()); +} diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs new file mode 100644 index 0000000..2db4570 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -0,0 +1,233 @@ +//! 导入/导出区的画法:入口(拖拽 + 路径)→ 进度(**下载页那块面板**)→ 结果 → 导出预检。 +//! +//! ## 版面 +//! +//! ```text +//! ┌ 导入整合包 ──────────────────────────────────┐ +//! │ ┌ 拖拽区(hover 时变主色描边) ─────────────┐ │ +//! │ └ 路径输入框 ──────────┘ [导入] │ │ +//! │ [装到实例:chips] [实例名] │ │ +//! ├ 进度:步骤 2/5 · 解包覆盖内容 │ +//! │ <下载页的安装面板:进度条 + 计数 + 取消> │ +//! ├ 探测失败:看起来像 X,但缺 Y │ +//! ├ 结果:三态标题 + 缺文件逐条 + 冲突逐条 + 致命块 │ +//! └ 导出:格式 chips + 预检(将内联 N / 无法导出 M)│ +//! ``` +//! +//! ## 三处「视觉可区分」不是靠颜色一种通道 +//! +//! | 域 | 图标 | 颜色角色 | 容器 | 措辞 | +//! |---|---|---|---|---| +//! | 容忍(缺文件 / 被拒 component) | `Warn` | `Tertiary` | 无底色 | 「可容忍」 | +//! | 致命(已回滚) | `Error` | `Error` | `ErrorContainer` | 「已回滚 —— 零残留」 | +//! | 成功 | `Check` | `Primary` | 无底色 | 「已导入」 | +//! +//! 关掉颜色也还认得出(图标 + 措辞),这是任务书「必须视觉可区分」的落点。 +//! +//! ## 进度只有一份 +//! +//! 进行中的进度**整块交给** `pages::download::install_panel`(同一个函数、同一套几何与文案); +//! 本区只额外画一行 [`Running::stage_line`] 补足「解包 / overrides / 补文件」三档的粒度。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | 本文件 | 区入口 + 拖拽区 + 路径输入 + 目标实例 | +//! | [`parts`] | 三个共用小件(底色容器 / 容忍标题 / 逐条列表) | +//! | [`progress`] | 进度块(复用面板)与探测失败块 | +//! | [`report`] | 结果块:三态 + 冲突 + 致命 | +//! | [`export`] | 导出块与预检块 | + +mod export; +mod parts; +mod progress; +mod report; + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +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::divider; +use crate::app::components::inputs::{text_field, Field}; +use crate::app::components::selection::{chip, Chip}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use self::export::export_block; +use self::progress::{probe_block, running_block}; +use self::report::report_block; +use super::model::Pack; +use super::PackMessage; + +/// 内容库意图 → 页面消息(唯一的一层包装)。 +pub(super) fn intent(message: PackMessage) -> PageMessage { + PageMessage::Content(super::super::Message::Pack(message)) +} + +/// 整块(导入入口 + 进度 + 结果 + 导出)。 +pub(crate) fn section<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Pack, +) -> Element<'a, PageMessage> { + let mut block = column![entry(ui, catalog, model)] + .spacing(ui.space(Space::X3)) + .width(Length::Fill); + + if let Some(running) = &model.running { + block = block.push(running_block(ui, catalog, running)); + } + if let Some(probe) = &model.probe { + block = block.push(probe_block(ui, catalog, probe)); + } + if let Some(report) = &model.report { + block = block.push(divider::full(ui)); + block = block.push(report_block(ui, catalog, report)); + } + block = block.push(divider::full(ui)); + block = block.push(export_block(ui, catalog, &model.export)); + + container(block).width(Length::Fill).into() +} + +/// 入口:拖拽区 + 路径输入 + 目标实例。 +fn entry<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Pack) -> Element<'a, PageMessage> { + let zone = container( + column![ + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + if model.hover { + catalog.text(Key::PackDropActive) + } else { + model.drop_hint + }, + ui.color(if model.hover { + Role::Primary + } else { + Role::OnSurface + }), + ), + // 没有系统文件框这件事**写在界面上**:不能让用户点了半天「选择文件」找不到按钮 + frame::text_role( + ui, + TypeRole::BodySmall, + false, + model.no_dialog_note, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ) + .width(Length::Fill) + .padding(ui.space(Space::X4)) + .style(move |_theme| container::Style { + border: Border { + color: ui.color(if model.hover { + Role::Primary + } else { + Role::Outline + }), + width: 1.0, + radius: ui.radius_dp(ShapeScale::Large), + }, + background: model + .hover + .then(|| Background::Color(ui.color(Role::PrimaryContainer))), + ..container::Style::default() + }); + + let field = text_field( + ui, + Field::new(model.draft.as_str()) + .label(catalog.text(Key::PackPathLabel)) + .placeholder(catalog.text(Key::PackPathPlaceholder)) + .leading(IconKind::Folder), + move |text| intent(PackMessage::PathChanged(text)), + ); + let submit = buttons::filled( + ui, + Button::new( + catalog.text(Key::PackImportAction), + intent(PackMessage::Import), + ) + .leading(IconKind::Folder), + ); + + let mut block = column![ + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::PackImportTitle), + ui.color(Role::OnSurface), + ), + zone, + row![field, submit] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::End) + .width(Length::Fill), + targets(ui, catalog, model), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + if model.instance.is_none() { + block = block.push(text_field( + ui, + Field::new(model.instance_name.as_str()).label(catalog.text(Key::PackInstanceName)), + move |text| intent(PackMessage::SetInstanceName(text)), + )); + } + + block.into() +} + +/// 目标实例那一行(chips + 「新建实例」)。 +fn targets<'a>(ui: &'a Ui, catalog: Catalog, model: &'a Pack) -> Element<'a, PageMessage> { + let mut chips: Vec> = Vec::new(); + for target in &model.targets { + chips.push(chip( + ui, + Chip::filter( + target.label.as_str(), + model.instance.as_deref() == Some(target.id.as_str()), + intent(PackMessage::SetInstance(Some(target.id.clone()))), + ), + )); + } + chips.push(chip( + ui, + Chip::filter( + catalog.text(Key::PackTargetNew), + model.instance.is_none(), + intent(PackMessage::SetInstance(None)), + ), + )); + + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackTargetLabel), + ui.color(Role::OnSurfaceVariant), + ), + row(chips).spacing(ui.space(Space::X2)).wrap(), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs new file mode 100644 index 0000000..7c14a3e --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -0,0 +1,274 @@ +//! 导出块与预检块:格式选择 → **预检**(不写盘)→ 确认写盘。 + +use iced::widget::{column, row}; +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::{text_field, Field}; +use crate::app::components::selection::{chip, Chip}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::{Export, Preview}; +use super::super::PackMessage; +use super::intent; +use super::parts::{rows_block, tolerable_title}; + +/// 导出块。 +pub(super) fn export_block<'a>( + ui: &'a Ui, + catalog: Catalog, + export: &'a Export, +) -> Element<'a, PageMessage> { + let mut block = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + if !export.open { + block = block.push( + row![ + buttons::outlined( + ui, + Button::new( + catalog.text(Key::PackExportOpen), + intent(PackMessage::OpenExport) + ) + .leading(IconKind::Folder), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + export.format_note, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ); + } else { + block = block.push(frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::PackExportTitle), + ui.color(Role::OnSurface), + )); + block = block.push(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackExportFormat), + ui.color(Role::OnSurfaceVariant), + )); + block = block.push( + row(export + .formats + .iter() + .map(|(format, label)| { + chip( + ui, + Chip::filter( + label, + export.format == *format, + intent(PackMessage::SetFormat(*format)), + ), + ) + }) + .collect::>()) + .spacing(ui.space(Space::X2)), + ); + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + export.format_note, + ui.color(Role::OnSurfaceVariant), + )); + block = block.push(text_field( + ui, + Field::new(export.name.as_str()).label(catalog.text(Key::PackExportName)), + move |text| intent(PackMessage::SetExportName(text)), + )); + block = block.push(text_field( + ui, + Field::new(export.version.as_str()).label(catalog.text(Key::PackExportVersion)), + move |text| intent(PackMessage::SetExportVersion(text)), + )); + block = block.push(text_field( + ui, + Field::new(export.output.as_str()).label(catalog.text(Key::PackExportOutput)), + move |text| intent(PackMessage::SetOutput(text)), + )); + block = block.push( + row![ + buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportPrecheck), + intent(PackMessage::Precheck), + ) + .leading(IconKind::Search), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::PackExportAbort), + intent(PackMessage::AbortExport) + ), + ), + ] + .spacing(ui.space(Space::X2)), + ); + if export.pending { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + export.pending_line, + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(preview) = &export.preview { + block = block.push(preview_block(ui, catalog, preview)); + block = block.push(buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportConfirm), + intent(PackMessage::Confirm), + ), + )); + } + } + + if let Some(done) = &export.done { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + done.as_str(), + ui.color(Role::Primary), + )); + } + if let Some(failed) = &export.failed { + block = block.push(frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render(Key::PackExportFailed, &[("reason", failed)]), + ui.color(Role::Error), + )); + } + + block.into() +} + +/// 预检块:`将内联 N / 无法导出 M` + 逐条跳过项 + 已知损失。 +pub(super) fn preview_block<'a>( + ui: &'a Ui, + catalog: Catalog, + preview: &'a Preview, +) -> Element<'a, PageMessage> { + let mut block = column![ + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + preview.head.as_str(), + ui.color(if preview.complete { + Role::Primary + } else { + Role::Tertiary + }), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.complete_line.as_str(), + ui.color(if preview.complete { + Role::OnSurfaceVariant + } else { + Role::Tertiary + }), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.remote_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.inline_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + preview.target_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if !preview.skipped.is_empty() { + block = block.push(tolerable_title( + ui, + catalog, + catalog.text(Key::PackPreviewSkippedTitle), + )); + block = block.push(rows_block(ui, catalog, &preview.skipped)); + } + + for path in &preview.inline_sample { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + path.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(more) = &preview.more_line { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + more.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + if !preview.notes.is_empty() { + block = block.push(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackPreviewNotes), + ui.color(Role::OnSurfaceVariant), + )); + for note in &preview.notes { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + } + + block.into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/parts.rs b/crates/app-iced/src/pages/content/packui/view/parts.rs new file mode 100644 index 0000000..bfd6ba2 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/parts.rs @@ -0,0 +1,99 @@ +//! 共用小件:带底色的容器、容忍域的标题、逐条列表。 +//! +//! 三个都在两个以上地方用到,所以单独一个文件——不是为了行数好看,是因为 +//! 「容忍域长什么样」只有一处定义,改颜色/图标才不用找第二个副本。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Background, Border, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::Catalog; + +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::Row; + +/// 带底色的容器(探测块与致命块共用;颜色由调用方给)。 +pub(super) fn marker_box<'a>( + ui: &'a Ui, + role: Role, + content: impl Into>, +) -> Element<'a, PageMessage> { + let role = ui.color(role); + container(content.into()) + .width(Length::Fill) + .padding(ui.space(Space::X3)) + .style(move |_theme| container::Style { + background: Some(Background::Color(role)), + border: Border { + radius: ui.radius_dp(ShapeScale::Large), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +pub(super) fn tolerable_title<'a>( + ui: &'a Ui, + catalog: Catalog, + title: &'a str, +) -> Element<'a, PageMessage> { + let _ = catalog; + row![ + frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::LabelLarge, + true, + title, + ui.color(Role::Tertiary), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .into() +} + +pub(super) fn rows_block<'a>( + ui: &'a Ui, + catalog: Catalog, + rows: &'a [Row], +) -> Element<'a, PageMessage> { + let _ = catalog; + column( + rows.iter() + .map(|row| { + row![ + frame::text_role( + ui, + TypeRole::BodySmall, + false, + row.path.as_str(), + ui.color(Role::OnSurface), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + row.note.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into() + }) + .collect::>(), + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/progress.rs b/crates/app-iced/src/pages/content/packui/view/progress.rs new file mode 100644 index 0000000..c326ce4 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/progress.rs @@ -0,0 +1,95 @@ +//! 进度块(**整块复用下载页的安装面板**)与探测失败块。 + +use iced::widget::{column, row}; +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::pages::download; +use crate::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::{Probe, Running}; +use super::super::PackMessage; +use super::intent; +use super::parts::marker_box; + +/// 进行中:一行步骤说明 + **下载页那块面板**。 +pub(super) fn running_block<'a>( + ui: &'a Ui, + catalog: Catalog, + running: &'a Running, +) -> Element<'a, PageMessage> { + column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + running.stage_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + // 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。 + download::install_panel(ui, catalog, &running.panel), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +/// 探测失败:把「缺哪个标记」摊开。 +pub(super) fn probe_block<'a>( + ui: &'a Ui, + catalog: Catalog, + probe: &'a Probe, +) -> Element<'a, PageMessage> { + let mut block = column![ + row![ + frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::PackProbeTitle), + ui.color(Role::Tertiary), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodyMedium, + false, + probe.lead.as_str(), + ui.color(Role::OnSurface), + ), + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render(Key::PackProbeSaw, &[("saw", &probe.saw.join(" · "))]), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + probe.detail.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + block = block.push(buttons::text( + ui, + Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)), + )); + marker_box(ui, Role::TertiaryContainer, block) +} diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs new file mode 100644 index 0000000..cfdf65a --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -0,0 +1,268 @@ +//! 结果块:三态标题 + 容忍域逐条 + 冲突逐条 + 致命块。 + +use iced::widget::{column, row}; +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::pages::frame; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +use super::super::model::{ConflictRow, Report}; +use super::super::PackMessage; +use super::intent; +use super::parts::{marker_box, rows_block, tolerable_title}; + +/// 结果块。 +pub(super) fn report_block<'a>( + ui: &'a Ui, + catalog: Catalog, + report: &'a Report, +) -> Element<'a, PageMessage> { + let mut block = column![ + row![ + frame::glyph( + status_icon(report), + ui.color(status_role(report)), + frame::ICON_PX + ), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + report.title.as_str(), + ui.color(status_role(report)), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + report.subtitle.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + report.written_line.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + if let Some(fatal) = &report.fatal { + block = block.push(marker_box( + ui, + Role::ErrorContainer, + column![ + frame::text_role( + ui, + TypeRole::LabelLarge, + true, + fatal.title.as_str(), + ui.color(Role::OnErrorContainer), + ), + frame::text_role_owned( + ui, + TypeRole::BodyMedium, + false, + format!("{} · {}", fatal.label, fatal.code), + ui.color(Role::OnErrorContainer), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + fatal.message.as_str(), + ui.color(Role::OnErrorContainer), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + )); + if let Some(hint) = &fatal.hint { + block = block.push( + row![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackFatalHint), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + hint.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X2)), + ); + } + } + + // 容忍域(缺文件 / 被拒 component):中性容器 + `Warn` 图标 + 逐条 + if let Some(title) = &report.missing_title { + block = block.push( + column![ + tolerable_title(ui, catalog, title.as_str()), + rows_block(ui, catalog, &report.missing), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + } + if !report.rejected.is_empty() { + block = block.push(rows_block(ui, catalog, &report.rejected)); + } + + if let Some(title) = &report.conflicts_title { + block = block.push( + column![ + tolerable_title(ui, catalog, title.as_str()), + column( + report + .conflicts + .iter() + .map(|conflict| conflict_row(ui, catalog, conflict)) + .collect::>() + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + } + + for warning in &report.warnings { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + warning.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + if let Some(note) = &report.overwrite_note { + block = block.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + + let mut actions = row![] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + if report.can_overwrite { + actions = actions.push(buttons::outlined( + ui, + Button::new( + catalog.text(Key::PackOverwriteAction), + intent(PackMessage::Overwrite), + ) + .leading(IconKind::Refresh), + )); + } + actions = actions.push(buttons::text( + ui, + Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)), + )); + block = block.push(actions); + + block.into() +} + +/// 一条冲突:路径 + 成因 + 本次处置(点一行展开三方哈希)。 +pub(super) fn conflict_row<'a>( + ui: &'a Ui, + catalog: Catalog, + conflict: &'a ConflictRow, +) -> Element<'a, PageMessage> { + let head = row![ + frame::text_role( + ui, + TypeRole::BodySmall, + false, + conflict.path.as_str(), + ui.color(Role::OnSurface), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + conflict.reason.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + conflict.choice.as_str(), + ui.color(Role::Tertiary), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::PackConflictDetail), + intent(PackMessage::ToggleConflict(conflict.path.clone())), + ) + .leading(IconKind::ChevronDown), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill); + + let mut block = column![head].width(Length::Fill); + if conflict.expanded { + block = block.push(frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render( + Key::PackConflictHashes, + &[ + ("pack", conflict.pack_sha1.as_str()), + ("base", conflict.base_sha1.as_deref().unwrap_or("—")), + ("disk", conflict.disk_sha1.as_deref().unwrap_or("—")), + ], + ), + ui.color(Role::OnSurfaceVariant), + )); + } + block.into() +} + +pub(super) fn status_icon(report: &Report) -> IconKind { + match report.status { + unlucky_presenter::dto::ImportStatusView::Success => IconKind::Check, + unlucky_presenter::dto::ImportStatusView::Partial => IconKind::Warn, + unlucky_presenter::dto::ImportStatusView::Failed => IconKind::Error, + } +} + +pub(super) fn status_role(report: &Report) -> Role { + match report.status { + unlucky_presenter::dto::ImportStatusView::Success => Role::Primary, + unlucky_presenter::dto::ImportStatusView::Partial => Role::Tertiary, + unlucky_presenter::dto::ImportStatusView::Failed => Role::Error, + } +} diff --git a/crates/app-iced/src/pages/content/view.rs b/crates/app-iced/src/pages/content/view.rs index c529b01..bf9d9fd 100644 --- a/crates/app-iced/src/pages/content/view.rs +++ b/crates/app-iced/src/pages/content/view.rs @@ -191,11 +191,20 @@ fn main_column<'a>( list.push(parts::more(ui, catalog, model)).into() }; - column![head, body] + let mut column = column![] .spacing(ui.space(Space::X2)) .width(Length::Fixed(rails.list)) - .height(Length::Fill) - .into() + .height(Length::Fill); + + // M113: import/export —— 只有整合包这一类才有「把本地的包装进来 / 把实例打出去」。 + // 其它三类(Mod / 资源包 / 光影)的导入就是把文件拖进目录,不属于本条任务。 + if model.kind == ContentKind::Modpack { + column = column + .push(super::packui::section(ui, catalog, &model.pack)) + .push(divider::full(ui)); + } + + column.push(head).push(body).into() } /// 详情列(只在 `Wide` / `Narrow` 存在)。 diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 105062b..df91d40 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -83,6 +83,160 @@ macro_rules! content_entries { "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放" ), + // --- M113: pack import / export(PACKUI)--- + PackTitle => ("Modpack import & export", "整合包导入 / 导出"), + PackImportTitle => ("Import a modpack", "导入整合包"), + PackDropHint => ( + "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", + "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里" + ), + PackDropActive => ("Release to probe the pack", "松手开始探测"), + PackNoDialog => ( + "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", + "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。" + ), + PackPathLabel => ("Pack path", "整合包路径"), + PackPathPlaceholder => ("D:\\packs\\Better MC.mrpack", "D:\\packs\\Better MC.mrpack"), + PackImportAction => ("Import", "导入"), + PackCancel => ("Cancel import", "取消导入"), + PackDismiss => ("Dismiss", "关闭"), + PackTargetLabel => ("Install into", "装到实例"), + PackTargetNew => ("New instance", "新建实例"), + PackInstanceName => ("Instance name", "实例名"), + PackStageLine => ("Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), + PackStageProbe => ("Reading the manifest", "读取清单"), + PackStageExtract => ("Unpacking overrides", "解包覆盖内容"), + PackStageFetch => ("Fetching missing files", "补下载文件"), + PackStageCommit => ("Writing files", "落地写盘"), + PackStageRegister => ("Registering the instance", "登记实例"), + PackProbeTitle => ( + "This pack is almost a known format", + "这个包差一点就是已知格式" + ), + PackProbeLooksLike => ( + "Looks like {format}, but missing: {missing}", + "看起来像 {format},但缺:{missing}" + ), + PackProbeUnknown => ( + "No known format claims this pack", + "没有任何已知格式认领这个包" + ), + PackProbeSaw => ("Found in the pack: {saw}", "包里看到:{saw}"), + PackProbeNothing => ("nothing recognisable", "没有可识别的东西"), + PackStatusSuccess => ("Imported", "已导入"), + PackStatusPartial => ("Imported with gaps", "导入完成,但有缺口"), + PackStatusFailed => ("Import failed — rolled back", "导入失败 —— 已整包回滚"), + PackReportWritten => ("{count} files written · {bytes}", "{count} 个文件已写盘 · {bytes}"), + PackReportMissing => ( + "{count} files could not be fetched (tolerated)", + "有 {count} 个文件没搞到(可容忍)" + ), + PackReportConflicts => ( + "{count} files you had changed (yours kept by default)", + "{count} 个文件你改过(默认保留你的)" + ), + PackReportRejectedNote => ("not supported by the client", "客户端不支持"), + PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"), + PackOverwriteNote => ( + "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", + "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。" + ), + PackMissingOffline => ("offline import", "离线导入"), + PackMissingNoSource => ( + "no download source (CurseForge file API not wired)", + "拿不到下载地址(CF 文件端点未接通)" + ), + PackMissingFetchFailed => ("download failed: {detail}", "下载失败:{detail}"), + PackFatalTitle => ( + "Rolled back — nothing was left behind", + "已回滚 —— 目标目录零残留" + ), + PackFatalHint => ("What to try next", "下一步可以试"), + PackConflictUserModified => ("you changed it", "你改过"), + PackConflictUserDeleted => ("you deleted it", "你删过"), + PackConflictPreExisting => ("was already there", "本来就在"), + PackChoiceKeepUser => ("kept yours", "保留我的"), + PackChoiceOverwrite => ("pack version used", "用包里的"), + PackConflictHashes => ( + "pack {pack} · base {base} · disk {disk}", + "包内 {pack} · 基线 {base} · 磁盘 {disk}" + ), + PackConflictDetail => ("Hashes", "三方哈希"), + PackFormatModrinth => ("Modrinth .mrpack", "Modrinth .mrpack"), + PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"), + PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"), + PackExportTitle => ("Export this instance", "导出这个实例"), + PackExportOpen => ("Export…", "导出…"), + PackExportFormat => ("Format", "格式"), + PackExportName => ("Pack name", "包名"), + PackExportVersion => ("Version", "版本"), + PackExportOutput => ("Save to (empty = suggested)", "保存到(留空 = 自动)"), + PackExportPrecheck => ("Dry run", "预检"), + PackExportConfirm => ("Write the pack", "确认写盘"), + PackExportAbort => ("Back", "返回"), + PackExportMultimcNote => ( + "MultiMC/Prism can only be imported, not exported (by design).", + "MultiMC·Prism 只能导入、不能导出(有意省略)。" + ), + PackExportDone => ("Wrote {path} · {bytes}", "已写出 {path} · {bytes}"), + PackExportFailed => ("Export failed: {reason}", "导出失败:{reason}"), + PackExportPending => ("Scanning the instance…", "正在扫描实例…"), + PackPreviewHead => ( + "Will inline {inline} files · {skipped} items cannot be exported", + "将内联 {inline} 个文件 · 无法导出 {skipped} 项" + ), + PackPreviewRemote => ( + "{count} files can be referenced remotely", + "{count} 个文件可走远程引用" + ), + PackPreviewInlineBytes => ( + "{bytes} copied into overrides/", + "{bytes} 会原样拷进 overrides/" + ), + PackPreviewSkippedTitle => ( + "Not in the pack (listed one by one)", + "没进包(逐条列出)" + ), + PackPreviewNotes => ("Known losses", "已知损失"), + PackPreviewTarget => ("Will be written to {path}", "将写到 {path}"), + PackPreviewMore => ("…and {count} more files", "…还有 {count} 个文件"), + PackPreviewComplete => ("Nothing is left out", "没有遗漏任何文件"), + PackPreviewIncomplete => ( + "Some files will not be in the pack", + "有文件不会进包" + ), + PackReasonModrinthSha1 => ("Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), + PackReasonCurseFingerprint => ( + "CurseForge match (fingerprint)", + "CurseForge 命中(文件指纹)" + ), + PackReasonNoRemoteMatch => ( + "unknown to both catalogs, inlined", + "两个目录都不认识,已内联" + ), + PackReasonCurseOnly => ( + "only CurseForge knows it, this format cannot use it, inlined", + "只有 CF 认识,目标格式用不上,已内联" + ), + PackReasonModrinthOnly => ( + "only Modrinth knows it, the CF manifest cannot use it, inlined", + "只有 Modrinth 认识,CF 清单用不上,已内联" + ), + PackReasonMissingSha512 => ("match has no sha512, inlined", "命中缺 sha512,已内联"), + PackReasonMissingCurseIds => ( + "match has no projectID/fileID, inlined", + "命中缺 projectID/fileID,已内联" + ), + PackReasonDisabled => ( + "disabled file, this format cannot express it (.disabled kept)", + "禁用文件,目标格式表达不了(保留 .disabled)" + ), + PackReasonSymlink => ("symbolic link, skipped", "符号链接,已跳过"), + PackReasonUnsafePath => ( + "path fails the pack path rules, skipped", + "路径过不了包内规整,已跳过" + ), + PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index b95f360..d7ddcd6 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -219,4 +219,85 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("ContentModpackNotReady", "Installing modpacks is not available yet", "整合包安装尚未开放"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), + ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), + ("PackImportTitle", "Import a modpack", "导入整合包"), + ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), + ("PackDropActive", "Release to probe the pack", "松手开始探测"), + ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"), + ("PackPathLabel", "Pack path", "整合包路径"), + ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\packs\\Better MC.mrpack"), + ("PackImportAction", "Import", "导入"), + ("PackCancel", "Cancel import", "取消导入"), + ("PackDismiss", "Dismiss", "关闭"), + ("PackTargetLabel", "Install into", "装到实例"), + ("PackTargetNew", "New instance", "新建实例"), + ("PackInstanceName", "Instance name", "实例名"), + ("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"), + ("PackStageProbe", "Reading the manifest", "读取清单"), + ("PackStageExtract", "Unpacking overrides", "解包覆盖内容"), + ("PackStageFetch", "Fetching missing files", "补下载文件"), + ("PackStageCommit", "Writing files", "落地写盘"), + ("PackStageRegister", "Registering the instance", "登记实例"), + ("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"), + ("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"), + ("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"), + ("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"), + ("PackProbeNothing", "nothing recognisable", "没有可识别的东西"), + ("PackStatusSuccess", "Imported", "已导入"), + ("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"), + ("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"), + ("PackReportWritten", "{count} files written · {bytes}", "{count} 个文件已写盘 · {bytes}"), + ("PackReportMissing", "{count} files could not be fetched (tolerated)", "有 {count} 个文件没搞到(可容忍)"), + ("PackReportConflicts", "{count} files you had changed (yours kept by default)", "{count} 个文件你改过(默认保留你的)"), + ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), + ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), + ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), + ("PackMissingOffline", "offline import", "离线导入"), + ("PackMissingNoSource", "no download source (CurseForge file API not wired)", "拿不到下载地址(CF 文件端点未接通)"), + ("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"), + ("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"), + ("PackFatalHint", "What to try next", "下一步可以试"), + ("PackConflictUserModified", "you changed it", "你改过"), + ("PackConflictUserDeleted", "you deleted it", "你删过"), + ("PackConflictPreExisting", "was already there", "本来就在"), + ("PackChoiceKeepUser", "kept yours", "保留我的"), + ("PackChoiceOverwrite", "pack version used", "用包里的"), + ("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"), + ("PackConflictDetail", "Hashes", "三方哈希"), + ("PackFormatModrinth", "Modrinth .mrpack", "Modrinth .mrpack"), + ("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"), + ("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"), + ("PackExportTitle", "Export this instance", "导出这个实例"), + ("PackExportOpen", "Export…", "导出…"), + ("PackExportFormat", "Format", "格式"), + ("PackExportName", "Pack name", "包名"), + ("PackExportVersion", "Version", "版本"), + ("PackExportOutput", "Save to (empty = suggested)", "保存到(留空 = 自动)"), + ("PackExportPrecheck", "Dry run", "预检"), + ("PackExportConfirm", "Write the pack", "确认写盘"), + ("PackExportAbort", "Back", "返回"), + ("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"), + ("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"), + ("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"), + ("PackExportPending", "Scanning the instance…", "正在扫描实例…"), + ("PackPreviewHead", "Will inline {inline} files · {skipped} items cannot be exported", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), + ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), + ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), + ("PackPreviewSkippedTitle", "Not in the pack (listed one by one)", "没进包(逐条列出)"), + ("PackPreviewNotes", "Known losses", "已知损失"), + ("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"), + ("PackPreviewMore", "…and {count} more files", "…还有 {count} 个文件"), + ("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"), + ("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"), + ("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), + ("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"), + ("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"), + ("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"), + ("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"), + ("PackReasonMissingSha512", "match has no sha512, inlined", "命中缺 sha512,已内联"), + ("PackReasonMissingCurseIds", "match has no projectID/fileID, inlined", "命中缺 projectID/fileID,已内联"), + ("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"), + ("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"), + ("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"), + ("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"), ]; diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 810e0ca..9cab44e 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -23,6 +23,8 @@ mod instances; mod java; mod logs; mod notices; +// M113: import/export —— 整合包导入/导出的 DTO(M111/M112 管线的 IPC 面) +mod pack; mod settings; mod versions; @@ -41,5 +43,13 @@ pub use instances::{ pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; pub use notices::{NoticeLevel, NoticeView}; +// M113: import/export +pub use pack::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView, + ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, + FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, + ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView, + MissingReasonView, PackFormatView, ProbeView, +}; pub use settings::{SettingControl, SettingGroupView, SettingRowView}; pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary}; diff --git a/crates/presenter/src/dto/pack.rs b/crates/presenter/src/dto/pack.rs new file mode 100644 index 0000000..e3e584d --- /dev/null +++ b/crates/presenter/src/dto/pack.rs @@ -0,0 +1,49 @@ +//! 整合包导入/导出 DTO(M113 `PACKUI`):把 M111/M112 的管线结果搬过 IPC 边界。 +//! +//! ## 为什么这里几乎是 core 报告的逐字段镜像 +//! +//! `crate::dto` 的规矩(见 `dto/mod.rs`):**不得出现 `unlucky_core::*` 类型**,跨边界类型必须 +//! `Serialize + Deserialize`。所以这里一面不引 core,一面又不许「按 UI 方便重新发明一套语义」—— +//! 三态、阶段、分类、原因全部**逐条对应** `core::addon::import::report` 与 +//! `core::addon::export::report`,只把 `&'static str` 的稳定码换成人读串、把 `PathBuf` 落成 +//! 页面能显示的形态。翻译(core 类型 → 本模块)发生在 `host::addon`,页面侧不认识 core。 +//! +//! 三条不许在翻译时丢的东西: +//! +//! 1. **三态**([`ImportStatusView`]):`Partial` 不是 `Success`。把「缺了 3 个文件」说成成功, +//! 用户会在启动后才发现缺依赖; +//! 2. **容忍 / 致命的分野**:[`MissingFileView`] 是「搞不到某个文件」(实例可用), +//! [`ImportErrorView`] 是「已整包回滚」(实例零残留)。UI 必须让这两类**视觉可区分**; +//! 3. **导出的三分类**([`ExportClassView`]):`Inline` 是「证明不了可被远程替代,原字节进 +//! `overrides/`」,`Skipped` 是「压根没进包」。`skipped > 0` 意味着包**不完整**。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`format`] | [`PackFormatView`](三个格式 + slug 对齐) | +//! | [`import`] | 请求 / 进度 / 三态 / 逐项原因 / 探测诊断 | +//! | [`conflict`] | 冲突成因、处置、三方哈希 | +//! | [`export`] | 三分类 / 逐条原因 / 预检 | +//! +//! ## 冲突:默认一侧永远是用户那一侧 +//! +//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线: +//! **绝不默认覆盖用户改过的文件**。 + +mod conflict; +mod export; +mod format; +mod import; + +pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView}; +pub use export::{ + ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView, + ExportRequestView, ExportState, +}; +pub use format::PackFormatView; +pub use import::{ + FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, + ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView, + MissingReasonView, ProbeView, +}; diff --git a/crates/presenter/src/dto/pack/conflict.rs b/crates/presenter/src/dto/pack/conflict.rs new file mode 100644 index 0000000..e5c07f1 --- /dev/null +++ b/crates/presenter/src/dto/pack/conflict.rs @@ -0,0 +1,71 @@ +//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。 +//! +//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的), +//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。 +//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。 + +use serde::{Deserialize, Serialize}; + +/// 冲突成因(core 的三条判据)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConflictReasonView { + /// 基线里记过,磁盘内容与基线不同 ⇒ 用户改过。 + UserModified, + /// 基线里记过,磁盘上却没了 ⇒ 用户删过。 + UserDeleted, + /// 没有基线可判,磁盘上本来就有同名文件。 + PreExisting, +} + +impl ConflictReasonView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "user_modified" => Some(Self::UserModified), + "user_deleted" => Some(Self::UserDeleted), + "pre_existing" => Some(Self::PreExisting), + _ => None, + } + } +} + +/// 冲突本次实际采用的处置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConflictChoiceView { + /// 保留用户侧(**默认**)。 + KeepUser, + /// 用包内内容覆盖(用户显式要求「整合包说了算」)。 + Overwrite, +} + +impl ConflictChoiceView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "keep_user" => Some(Self::KeepUser), + "overwrite" => Some(Self::Overwrite), + _ => None, + } + } +} + +/// 一条冲突(**事后账**:本次实际怎么处置的)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConflictFileView { + /// 实例内相对路径。 + pub path: String, + /// 成因。 + pub reason: ConflictReasonView, + /// 本次实际采用的处置。 + pub choice: ConflictChoiceView, + /// 包内声明的 SHA-1。 + pub pack_sha1: String, + /// 上次导入写下的 SHA-1(没有基线时 `None`)。 + pub base_sha1: Option, + /// 磁盘现状的 SHA-1(文件不存在时 `None`)。 + pub disk_sha1: Option, + /// 包内声明的字节数。 + pub pack_size: u64, +} diff --git a/crates/presenter/src/dto/pack/export.rs b/crates/presenter/src/dto/pack/export.rs new file mode 100644 index 0000000..8f9426f --- /dev/null +++ b/crates/presenter/src/dto/pack/export.rs @@ -0,0 +1,219 @@ +//! 导出侧 DTO:三分类 + 逐条原因 + **预检**(不写盘)。 +//! +//! `ExportReasonView::class()` 是「原因 → 归类」的**唯一**映射处(与 core 同一条判据); +//! 预检([`ExportPreviewView`])与写盘结果用同一套计数方法,于是「将内联 N / 无法导出 M」 +//! 与最终报告的 N/M 同源。 + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::dto::InstanceId; + +use super::format::PackFormatView; + +/// 导出时一条文件的归类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExportClassView { + /// 写进清单 `files[]`(靠远端引用代替本地字节)。 + Remote, + /// 写进 `overrides/`(原字节照搬)。 + Inline, + /// 没进包(读不了 / 符号链接 / 路径不安全)。 + Skipped, +} + +impl ExportClassView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "remote" => Some(Self::Remote), + "inline" => Some(Self::Inline), + "skipped" => Some(Self::Skipped), + _ => None, + } + } +} + +/// 一条文件为什么归到那个类里(与 core 的 `FileReason` 一一对应)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExportReasonView { + /// 远程引用:Modrinth SHA-1 命中。 + ModrinthSha1, + /// 远程引用:CurseForge 指纹命中。 + CurseForgeFingerprint, + /// 内联:两个目录都不认识它。 + NoRemoteMatch, + /// 内联:只有 CF 认识,目标格式用不上。 + CurseOnlyMatch, + /// 内联:只有 Modrinth 认识,CF 清单用不上。 + ModrinthOnlyMatch, + /// 内联:命中缺 `sha512`。 + MissingSha512, + /// 内联:命中缺 `projectID`/`fileID`。 + MissingCurseIds, + /// 内联:禁用文件,目标格式表达不了禁用位。 + DisabledNotExpressible, + /// 跳过:符号链接。 + SymbolicLink, + /// 跳过:路径过不了包内规整。 + UnsafePath, + /// 跳过:读盘或算摘要失败。 + Unreadable, +} + +impl ExportReasonView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "modrinth_sha1" => Some(Self::ModrinthSha1), + "curseforge_fingerprint" => Some(Self::CurseForgeFingerprint), + "no_remote_match" => Some(Self::NoRemoteMatch), + "curse_only_match" => Some(Self::CurseOnlyMatch), + "modrinth_only_match" => Some(Self::ModrinthOnlyMatch), + "missing_sha512" => Some(Self::MissingSha512), + "missing_curse_ids" => Some(Self::MissingCurseIds), + "disabled_not_expressible" => Some(Self::DisabledNotExpressible), + "symbolic_link" => Some(Self::SymbolicLink), + "unsafe_path" => Some(Self::UnsafePath), + "unreadable" => Some(Self::Unreadable), + _ => None, + } + } + + /// 这个原因属于哪一类(**唯一映射处**,与 core 的 `FileReason::class` 同一条判据)。 + #[must_use] + pub const fn class(self) -> ExportClassView { + match self { + Self::ModrinthSha1 | Self::CurseForgeFingerprint => ExportClassView::Remote, + Self::NoRemoteMatch + | Self::CurseOnlyMatch + | Self::ModrinthOnlyMatch + | Self::MissingSha512 + | Self::MissingCurseIds + | Self::DisabledNotExpressible => ExportClassView::Inline, + Self::SymbolicLink | Self::UnsafePath | Self::Unreadable => ExportClassView::Skipped, + } + } +} + +/// 一条文件的导出预检结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportFileView { + /// 实例内相对路径(含 `.disabled` 后缀)。 + pub path: String, + /// 归类。 + pub class: ExportClassView, + /// 原因。 + pub reason: ExportReasonView, + /// 字节数。 + pub bytes: u64, + /// 是否禁用态(`.disabled`)。 + pub disabled: bool, +} + +/// **预检**:还没有写盘,先把「会怎样」摊开给用户看。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportPreviewView { + /// 目标格式。 + pub format: PackFormatView, + /// 源实例。 + pub instance: InstanceId, + /// 包名。 + pub name: String, + /// 包版本。 + pub version: String, + /// 预定的落盘路径(确认之后写这里)。 + pub target: PathBuf, + /// 逐文件结果(按路径排序)。 + pub files: Vec, + /// 已知的表达不了的差异 / 取舍(core 的 `notes`)。 + pub notes: Vec, +} + +impl ExportPreviewView { + /// 某一类的条数。 + #[must_use] + pub fn count(&self, class: ExportClassView) -> usize { + self.files.iter().filter(|file| file.class == class).count() + } + + /// 远程引用条数。 + #[must_use] + pub fn remote_count(&self) -> usize { + self.count(ExportClassView::Remote) + } + + /// 内联条数(「将内联 N 个文件」的那个 N)。 + #[must_use] + pub fn inline_count(&self) -> usize { + self.count(ExportClassView::Inline) + } + + /// 跳过条数(「无法导出 M 项」的那个 M)。 + #[must_use] + pub fn skipped_count(&self) -> usize { + self.count(ExportClassView::Skipped) + } + + /// 内联文件总字节数。 + #[must_use] + pub fn inline_bytes(&self) -> u64 { + self.files + .iter() + .filter(|file| file.class == ExportClassView::Inline) + .map(|file| file.bytes) + .sum() + } + + /// 包会完整吗(`false` = 有文件压根没进包,必须单独列给用户)。 + #[must_use] + pub fn is_complete(&self) -> bool { + self.skipped_count() == 0 + } +} + +/// 导出格式选择 + 一次导出请求。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportRequestView { + /// 源实例。 + pub instance: InstanceId, + /// 目标格式(只能是 [`PackFormatView::EXPORTABLE`] 里的两个)。 + pub format: PackFormatView, + /// 包名(写进清单 `name`)。 + pub name: String, + /// 包版本(写进清单 `version` / `versionId`)。 + pub version: String, + /// 落盘路径(`None` = host 按「下载目录 + 包名 + 扩展名」给一个缺省)。 + pub target: Option, +} + +/// 导出完成(写盘之后的账)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportDoneView { + /// 产出的包路径。 + pub path: PathBuf, + /// 包的字节数(写完量的,不是估算)。 + pub bytes: u64, + /// 远程引用条数。 + pub remote: u32, + /// 内联条数。 + pub inline: u32, + /// 跳过条数。 + pub skipped: u32, +} + +/// 导出侧的宿主状态(`AppState::export` 的全部内容)。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ExportState { + /// 预检结果(`None` = 还没预检)。 + pub preview: Option, + /// 预检在飞。 + pub pending: bool, + /// 最近一次写盘的结果。 + pub done: Option, + /// 失败原因(预检或写盘)。 + pub failed: Option, +} diff --git a/crates/presenter/src/dto/pack/format.rs b/crates/presenter/src/dto/pack/format.rs new file mode 100644 index 0000000..98583e2 --- /dev/null +++ b/crates/presenter/src/dto/pack/format.rs @@ -0,0 +1,47 @@ +//! 整合包格式:探测结果与导出目标共用一个枚举。 +//! +//! `slug()` 与 core 的格式 id **逐字一致**(`curseforge` / `modrinth` / `multimc`), +//! `from_slug` 是反向唯一入口——翻译发生在 `host::addon`,页面侧只认这两个函数。 + +use serde::{Deserialize, Serialize}; + +/// 整合包格式(探测结果与导出目标共用一个枚举)。 +/// +/// MultiMC / Prism 只**导入**不导出(core 的有意省略),所以导出侧两个入口是 +/// [`Self::Modrinth`] / [`Self::Curseforge`]。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PackFormatView { + /// CurseForge:`manifest.json` + `overrides/`。 + Curseforge, + /// Modrinth:`.mrpack`(`modrinth.index.json` + `overrides/`)。 + Modrinth, + /// MultiMC / Prism:`mmc-pack.json` + `.minecraft/`(**只读**)。 + Multimc, +} + +impl PackFormatView { + /// 导出菜单里可选的两个(顺序 = 画出来的顺序)。 + pub const EXPORTABLE: [PackFormatView; 2] = + [PackFormatView::Modrinth, PackFormatView::Curseforge]; + + /// 稳定 slug(与 core 的格式 id 逐字一致,**不要改名**)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Curseforge => "curseforge", + Self::Modrinth => "modrinth", + Self::Multimc => "multimc", + } + } + + /// 从 core 的格式 id 反解(`None` = 不认识的 id,调用方按「未知」处理,别猜)。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "curseforge" => Some(Self::Curseforge), + "modrinth" => Some(Self::Modrinth), + "multimc" => Some(Self::Multimc), + _ => None, + } + } +} diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs new file mode 100644 index 0000000..59c128a --- /dev/null +++ b/crates/presenter/src/dto/pack/import.rs @@ -0,0 +1,338 @@ +//! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。 +//! +//! 三态(`Success` / `Partial` / `Failed`)与 core 一一对应,**`Partial` 不是 `Success`**; +//! 容忍域([`MissingFileView`])与致命域([`ImportErrorView`])分属不同类型,UI 不可能混成一个列表。 + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::dto::InstanceId; + +use super::conflict::ConflictFileView; +use super::format::PackFormatView; + +/// 导入阶段(与 core 的 `ImportStage` 一一对应)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ImportStageView { + /// 读取清单(开包 + 安全四检 + 探测)。 + Probe, + /// 解压 / 三方合并覆盖内容。 + Extract, + /// 补全并下载远端文件。 + Fetch, + /// 落地。 + Commit, + /// 登记实例。 + Register, +} + +impl ImportStageView { + /// 五个阶段的声明顺序(= 进度条上的先后)。 + pub const ALL: [ImportStageView; 5] = [ + ImportStageView::Probe, + ImportStageView::Extract, + ImportStageView::Fetch, + ImportStageView::Commit, + ImportStageView::Register, + ]; + + /// 稳定 slug。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Probe => "probe", + Self::Extract => "extract", + Self::Fetch => "fetch", + Self::Commit => "commit", + Self::Register => "register", + } + } + + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + Self::ALL.into_iter().find(|stage| stage.slug() == slug) + } + + /// 这一阶段有没有「逐文件」的进度(`Probe` 只看清单,没条目可数)。 + #[must_use] + pub const fn counts_files(self) -> bool { + !matches!(self, Self::Probe) + } +} + +/// 导入三态(`Partial` **不是** `Success`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImportStatusView { + /// 全部落地、无冲突。 + Success, + /// 有缺文件或有冲突(内容不完整,但实例可用)。 + Partial, + /// 致命失败,**已整包回滚**。 + Failed, +} + +impl ImportStatusView { + /// 稳定 slug。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Success => "success", + Self::Partial => "partial", + Self::Failed => "failed", + } + } + + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "success" => Some(Self::Success), + "partial" => Some(Self::Partial), + "failed" => Some(Self::Failed), + _ => None, + } + } + + /// 实例可用吗(`Success` / `Partial` 都是「装上了」)。 + #[must_use] + pub const fn installed(self) -> bool { + !matches!(self, Self::Failed) + } +} + +/// 一条落地文件本次被怎么处理。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FileActionView { + /// 新写入。 + Installed, + /// 覆盖写入(用户没改过)。 + Replaced, + /// 与包内一致,没动。 + SkippedIdentical, + /// 用户改过 / 删过,按策略保留了用户那一侧。 + KeptUser, +} + +impl FileActionView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "installed" => Some(Self::Installed), + "replaced" => Some(Self::Replaced), + "skipped_identical" => Some(Self::SkippedIdentical), + "kept_user" => Some(Self::KeptUser), + _ => None, + } + } + + /// 是否真的写了盘。 + #[must_use] + pub const fn wrote(self) -> bool { + matches!(self, Self::Installed | Self::Replaced) + } +} + +/// 一次导入的请求。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportRequestView { + /// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。 + pub pack: PathBuf, + /// 目标实例(`None` = 按包名新建一个)。 + pub instance: Option, + /// 新实例的展示名(`None` = 用包名)。 + pub instance_name: Option, + /// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。 + /// + /// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。 + pub overwrite: bool, +} + +/// 一条导入进度。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportProgressView { + /// 阶段。 + pub stage: ImportStageView, + /// 阶段内已完成条目数。 + pub done: u32, + /// 阶段内总条目数(`0` = 未知)。 + pub total: u32, + /// 当前条目的实例内路径(阶段刚开始时为 `None`)。 + pub current: Option, + /// 包名(面板标题那一行)。 + pub pack: String, +} + +impl ImportProgressView { + /// 空进度(刚派发,后端还没回第一条)。 + #[must_use] + pub fn new(pack: impl Into) -> Self { + Self { + stage: ImportStageView::Probe, + done: 0, + total: 0, + current: None, + pack: pack.into(), + } + } + + /// 计数已知吗(`false` = 不画确定态进度条,避免「静止的假进度」)。 + #[must_use] + pub const fn is_known(&self) -> bool { + self.total > 0 + } + + /// 阶段内完成比例 0.0~1.0(未知时 `0.0`)。 + #[must_use] + pub fn ratio(&self) -> f32 { + if self.total == 0 { + return 0.0; + } + (self.done as f32 / self.total as f32).clamp(0.0, 1.0) + } +} + +/// 一条落地文件。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportedFileView { + /// 实例内相对路径。 + pub path: String, + /// 本次处置。 + pub action: FileActionView, + /// 字节数。 + pub bytes: u64, +} + +/// 一个文件「搞不到」的成因(**容忍**域)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MissingReasonView { + /// 调用方没给 fetcher(离线导入)。 + Offline, + /// 条目补不出下载地址(CF 缺 API 客户端 / `downloads` 为空)。 + NoSource, + /// 尝试过但失败(404 / 网络)。 + FetchFailed, +} + +impl MissingReasonView { + /// 从 core 的 id 反解。 + #[must_use] + pub fn from_slug(slug: &str) -> Option { + match slug { + "offline" => Some(Self::Offline), + "no_source" => Some(Self::NoSource), + "fetch_failed" => Some(Self::FetchFailed), + _ => None, + } + } +} + +/// 一个「搞不到」的文件(容忍,但逐条列出)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MissingFileView { + /// 实例内路径(CF 补全失败时没有)。 + pub path: Option, + /// 人读标识(CF 条目是 `projectID:fileID`,其余是路径)。 + pub display: String, + /// 为什么搞不到。 + pub reason: MissingReasonView, + /// 细节(错误原文)。 + pub detail: String, +} + +/// 一次**已整包回滚**的致命失败。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportErrorView { + /// 稳定码(`pack_traversal` / `import_integrity` …,进日志与 `--json`)。 + pub code: String, + /// 人读大类名。 + pub label: String, + /// 具体是什么。 + pub message: String, + /// 补救建议(core 有就给)。 + pub hint: Option, +} + +/// 一次导入的完整结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImportReportView { + /// 三态。 + pub status: ImportStatusView, + /// 命中的格式(`None` = 探测都没过)。 + pub format: Option, + /// 包名。 + pub pack_name: Option, + /// 包版本。 + pub pack_version: Option, + /// 游戏版本。 + pub game_version: Option, + /// 加载器(人读串,如 `fabric-loader 0.15.11`)。 + pub loader: Option, + /// 实例绑定的版本 id。 + pub version_id: Option, + /// 目标实例目录。 + pub target: PathBuf, + /// 落地的文件。 + pub files: Vec, + /// 冲突清单。 + pub conflicts: Vec, + /// 缺文件清单(容忍)。 + pub missing: Vec, + /// MultiMC 不支持的 component(逐项)。 + pub rejected: Vec, + /// 注记。 + pub warnings: Vec, + /// 致命错误(`status == Failed` 时必有)。 + pub error: Option, + /// 登记的实例名。 + pub registered: Option, + /// 本次写入的字节数。 + pub bytes_written: u64, + /// 回滚是否完整(`false` = 目标目录可能与导入前不一致,逐条在 `warnings` 里)。 + pub rolled_back: bool, +} + +impl ImportReportView { + /// 真正写盘的文件条数。 + #[must_use] + pub fn written(&self) -> usize { + self.files.iter().filter(|file| file.action.wrote()).count() + } + + /// 要用户看一眼的东西条数(冲突 + 缺文件 + 被拒 component)。 + #[must_use] + pub fn issue_count(&self) -> usize { + self.conflicts.len() + self.missing.len() + self.rejected.len() + } +} + +/// 探测失败的**具名**诊断:「看起来像 X,但缺 Y」。 +/// +/// 为什么不由 core 给:core 的 `detect` 只回答「谁认领」,一个都不认领时只回一句 +/// `pack_unsupported` + 试过哪些格式。而用户手里那个包往往**差一点点**就是某个已知格式 +/// (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。 +/// 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProbeView { + /// 最像哪个格式(`None` = 三个都不像)。 + pub looks_like: Option, + /// 该格式**缺**的标记文件(逐条,按判据顺序)。 + pub missing: Vec, + /// 包里实际看到的标记(逐条)。 + pub saw: Vec, + /// 一句兜底说明(也是 core 的原文:试过哪些格式)。 + pub detail: String, +} + +/// 导入侧的宿主状态(`AppState::import` 的全部内容)。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ImportState { + /// 正在跑的进度(`None` = 没在跑)。 + pub progress: Option, + /// 最近一次的结果(`None` = 还没导过)。 + pub report: Option, + /// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。 + pub probe: Option, +} From 2129d8cee62bf9820b0870f612072baf1b6252e3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 23:52:58 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(packui):=20M113=20=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=89=B9=E4=BF=AE=E6=AD=A3=20=E2=80=94=E2=80=94=20=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=8F=AA=E6=91=8A=E5=BC=80=E4=B8=80=E4=BB=B6=E4=BA=8B?= =?UTF-8?q?=E3=80=81=E6=9C=89=E7=95=8C=E6=BB=9A=E5=8A=A8=E3=80=81=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E8=AF=8D=E4=B8=8D=E5=86=8D=E6=89=93=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D30 出图实测暴露的四个问题,都在本批范围内修掉: 1. **动作按钮被顶出屏幕**:内容库页正列没有整页滚动(只有结果列表自己滚),入口 + 报告 + 导出三块同时摊开时,「确认写盘 / 关闭 / 以包为准重新导入」落在 900px 以下。 改成一态一块(导出面板 > 进度 > 结果 > 入口);拖拽是窗口级事件,收起入口不丢功能。 2. **阶段词打架**:复用面板自带的阶段词是安装语义(Building the version),与 「Step 2/5 · Unpacking overrides」并排时两种说法。现在面板阶段词留空、由步骤行说真话 —— 仍然只有一份进度条 / 计数 / 取消按钮,没有第二套进度 UI。 3. **列表过长**:缺文件 / 冲突 / 预检样例与已知损失都套上「超过 n 条才滚」的有界容器 (packui/view/parts.rs 的 bounded),少量条目时不浪费空间。 4. **英文单复数**:计数行改成「标签:值」(Files written: 2、cannot export: 1)。 PackPathPlaceholder / PackFormatModrinth 的中文与英文雷同,被 chinese_catalog_is_actually_chinese 抓到,一并改掉。 另:app/update.rs 加完拖拽分支后 408 行超线 → 两个钩子改成返回 Task、三个分支各一行, 现在 397 行。新增致命态出图夹具(ULL_CONTENT_PACK=failed),用来证明「容忍的缺文件」与 「致命回滚」在视觉上可区分。 门禁:cargo check ✓ / clippy -D warnings ✓ / cargo test -p unlucky-app-iced(467+6)✓ / cargo test -p unlucky-i18n(24)✓ / rustfmt --check ✓ / wc -l 全表 ≤400 ✓ --- crates/app-iced/src/app/update.rs | 21 ++------- crates/app-iced/src/app/update/content.rs | 12 ++++- .../app-iced/src/pages/content/packui/demo.rs | 29 ++++++++---- .../pages/content/packui/model/progress.rs | 46 +++++++++++++------ .../app-iced/src/pages/content/packui/view.rs | 29 +++++++----- .../src/pages/content/packui/view/export.rs | 16 ++++--- .../src/pages/content/packui/view/parts.rs | 30 ++++++++++++ .../src/pages/content/packui/view/report.rs | 34 +++++++++----- crates/i18n/src/catalog/content.rs | 15 +++--- crates/i18n/src/catalog/golden.rs | 12 ++--- 10 files changed, 161 insertions(+), 83 deletions(-) diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index e46b7f7..1b583ca 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -100,22 +100,11 @@ impl App { self.view_dirty = true; self.refresh_view(); } - // M113: import/export —— 拖入整合包(文件或 MultiMC·Prism 实例目录)。 - // 这是本版**唯一**的「选文件」通道:依赖树里没有对话框依赖,加它要动根 - // `Cargo.toml` 与许可门禁(见 `pages/content/packui.rs` 的边界说明)。 - window::Event::FileDropped(path) => { - self.on_pack_dropped(path); - self.refresh_view(); - } - window::Event::FileHovered(_) => { - self.on_pack_hover(true); - self.refresh_view(); - } - window::Event::FilesHoveredLeft => { - self.on_pack_hover(false); - self.refresh_view(); - } - // 其余窗口事件本仓不消费(非穷尽枚举,保留兜底免得升级 iced 时炸) + // M113: import/export —— 拖入整合包(本版唯一的选文件通道;窗口级事件, + // 所以入口收起时也照样接得住。细节在 `on_pack_dropped` / `on_pack_hover`) + window::Event::FileDropped(path) => return self.on_pack_dropped(path), + window::Event::FileHovered(_) => return self.on_pack_hover(true), + window::Event::FilesHoveredLeft => return self.on_pack_hover(false), _ => {} } Task::none() diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index e23eefe..1fd3afb 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -188,16 +188,24 @@ impl App { } /// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。 - pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) { + pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task { // 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿 self.state .navigate(unlucky_presenter::router::Route::Content); self.on_pack(pages::content::PackMessage::Dropped(path)); + self.refresh_view(); + Task::none() } /// 拖拽悬停状态(有文件在窗口上方时高亮入口)。 - pub(super) fn on_pack_hover(&mut self, hover: bool) { + pub(super) fn on_pack_hover(&mut self, hover: bool) -> Task { + // 状态没变就不重画:winit 对同一批文件会连发几次 hover 事件 + let changed = self.content.pack.hover != hover; self.on_pack(pages::content::PackMessage::Hover(hover)); + if changed { + self.refresh_view(); + } + Task::none() } /// 改一个筛选字段并**立刻重查**(第一页)。 diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs index e3aaf5c..0a233f8 100644 --- a/crates/app-iced/src/pages/content/packui/demo.rs +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -8,7 +8,7 @@ //! //! | 变量 | 取值 | 作用 | //! |---|---|---| -//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `export` | 选导入导出区的哪一态(不设 = 只有入口) | +//! | `ULL_CONTENT_PACK` | `importing` / `partial` / `conflict` / `failed` / `export` | 选导入导出区的哪一态(不设 = 只有入口) | //! //! 夹具**不直接造视图模型**,而是造 presenter 侧的快照(`ImportState` / `ExportState`), //! 再走产品路径同一个 [`Pack::of`]。这样截图里看到的每一行字都是产品路径会画的那一行, @@ -19,9 +19,9 @@ use std::path::PathBuf; use unlucky_i18n::Lang; use unlucky_presenter::dto::{ ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView, - ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportProgressView, - ImportReportView, ImportStageView, ImportState, ImportStatusView, InstanceId, LoaderKind, - MissingFileView, MissingReasonView, PackFormatView, + ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView, + ImportProgressView, ImportReportView, ImportStageView, ImportState, ImportStatusView, + InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView, }; use crate::pages::content::Target; @@ -73,6 +73,19 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) { import.report = Some(report(ImportStatusView::Partial, true)); page.expanded = Some("config/sodium-options.json".to_string()); } + // 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野) + "failed" => { + let mut fatal = report(ImportStatusView::Failed, false); + fatal.error = Some(ImportErrorView { + code: "pack_traversal".to_string(), + label: "Path traversal".to_string(), + message: "overrides/../../etc/passwd".to_string(), + hint: Some("Re-download the pack from its source".to_string()), + }); + fatal.files.clear(); + fatal.bytes_written = 0; + import.report = Some(fatal); + } "export" => { page.export_open = true; page.export_name = "Survival 1.20.1".to_string(); @@ -122,11 +135,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView { target: PathBuf::from("C:/ULL/instances/survival/.minecraft"), files: vec![ file("mods/sodium.jar", FileActionView::Installed, 812_000), - file( - "mods/lithium.jar", - FileActionView::SkippedIdentical, - 640_000, - ), + file("mods/lithium.jar", FileActionView::Installed, 640_000), file("config/xaeroplus.cfg", FileActionView::KeptUser, 4_200), ], conflicts: Vec::new(), @@ -151,7 +160,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView { }, ], rejected: vec!["org.lwjgl3:3.3.1".to_string()], - warnings: vec!["包内 `server-overrides/` 未解压(客户端导入)".to_string()], + warnings: vec!["`server-overrides/` was not unpacked (client import)".to_string()], error: None, registered: Some("Survival".to_string()), bytes_written: 3_400_000, diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index 5b9ebaa..31e94f8 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -4,23 +4,24 @@ //! //! 「沿用既有安装面板组件与状态机,禁止再写第二套进度 UI」—— //! -//! 1. 导入的 `ImportStageView` / 计数 / 当前路径先映射成 [`InstallProgressView`](本文件 -//! [`panel_progress`]); -//! 2. 画法交给 `pages::download::install_panel`(M109 已经打过薄包装,同一个函数、同一套几何 -//! 与文案); -//! 3. 面板只认四个粗阶段(规划 / 下载 / 安装 / 登记),任务书要的「解包 / overrides / 补文件」 -//! 三档由**一行步骤说明**([`stage_line`])承载。 +//! 1. 导入的 `ImportStageView` / 计数先映射成面板的入参形状([`panel_progress`] → +//! `InstallProgressView` → [`InstallView`]); +//! 2. 画法交给 `pages::download::install_panel`(同一个函数、同一套几何,进度条 / 计数 / +//! 取消按钮都归它); +//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载, +//! 面板右边那个阶段词**留空**。 //! -//! 第 3 点不是第二套进度 UI:多出来的只是一句文字,进度条、计数行、取消按钮仍然只有那一份。 -//! 为什么不再造一条按 `ImportStageView` 五档划分的进度条:那正是「第二套」的定义,而且两块条 -//! 同时出现时用户不知道该信哪一条。 +//! 第 3 点的理由:面板自带的四个阶段词是**安装**语义(规划 / 下载 / 生成版本 / 登记实例), +//! 套到导入上会出现「Step 2/5 · Unpacking overrides」旁边写着「Building the version」—— +//! 同一块地方两种说法(出图时实测到了)。留空 + 由步骤行说真话,是**不动那个组件**的最小解; +//! 多出来的只有一句文字,没有第二条进度条。 use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{ ImportProgressView, ImportStageView, InstallProgressView, InstallStage, }; -use crate::pages::download::Download; +use crate::pages::download::{Download, InstallView}; /// 抛进面板的进度行。 #[derive(Debug, Clone, PartialEq)] @@ -100,14 +101,31 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { /// 进度事件 → 面板要的视图模型。 pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running { + let panel = panel_progress(progress); Running { pack: progress.pack.clone(), stage_line: stage_line(progress.stage, catalog), panel: Download { - install: Some(crate::pages::download::install_view( - &panel_progress(progress), - catalog, - )), + install: Some(InstallView { + version: panel.version.clone(), + // 只用于 `running()` / `failed()` 的判定;**不**用它给用户看(见模块文档第 3 点) + stage: panel.stage, + stage_label: String::new(), + detail: if progress.is_known() { + catalog.render( + Key::DownloadProgress, + &[ + ("done", &progress.done.to_string()), + ("total", &progress.total.to_string()), + ], + ) + } else { + catalog.text(Key::DownloadProgressUnknown).to_owned() + }, + rate: None, + ratio: progress.ratio(), + known: progress.is_known(), + }), ..Download::loading() }, } diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs index 2db4570..fb46c49 100644 --- a/crates/app-iced/src/pages/content/packui/view.rs +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -80,22 +80,27 @@ pub(crate) fn section<'a>( catalog: Catalog, model: &'a Pack, ) -> Element<'a, PageMessage> { - let mut block = column![entry(ui, catalog, model)] - .spacing(ui.space(Space::X3)) - .width(Length::Fill); + let mut block = column![].spacing(ui.space(Space::X3)).width(Length::Fill); - if let Some(running) = &model.running { + // **一次只摊开一件事**:入口(拖什么/装到哪)、进度、结果、导出预检各占一态。 + // 全都堆在一起时,内容库页正列没有整页滚动 ⇒ 动作按钮被顶出屏幕(出图实测:冲突态与 + // 导出预检态的「确认写盘 / 关闭」都落在 900px 以下)。收起的入口不会丢功能:拖拽是窗口级 + // 事件,任何时候把文件拖进来都算数。 + if model.export.open { + block = block.push(export_block(ui, catalog, &model.export)); + } else if let Some(running) = &model.running { block = block.push(running_block(ui, catalog, running)); - } - if let Some(probe) = &model.probe { - block = block.push(probe_block(ui, catalog, probe)); - } - if let Some(report) = &model.report { - block = block.push(divider::full(ui)); + } else if let Some(report) = &model.report { block = block.push(report_block(ui, catalog, report)); + } else { + block = block.push(entry(ui, catalog, model)); + if let Some(probe) = &model.probe { + // 探测失败要和路径输入框在一起:用户下一步就是改路径 + block = block.push(probe_block(ui, catalog, probe)); + } + block = block.push(divider::full(ui)); + block = block.push(export_block(ui, catalog, &model.export)); } - block = block.push(divider::full(ui)); - block = block.push(export_block(ui, catalog, &model.export)); container(block).width(Length::Fill).into() } diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs index 7c14a3e..82c92f4 100644 --- a/crates/app-iced/src/pages/content/packui/view/export.rs +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -20,7 +20,7 @@ use crate::theme::Ui; use super::super::model::{Export, Preview}; use super::super::PackMessage; use super::intent; -use super::parts::{rows_block, tolerable_title}; +use super::parts::{bounded, rows_block, tolerable_title}; /// 导出块。 pub(super) fn export_block<'a>( @@ -232,8 +232,11 @@ pub(super) fn preview_block<'a>( block = block.push(rows_block(ui, catalog, &preview.skipped)); } + // 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把 + // 「确认写盘」顶出屏幕(出图时实测到了) + let mut extras = column![].spacing(ui.space(Space::X1)).width(Length::Fill); for path in &preview.inline_sample { - block = block.push(frame::text_role( + extras = extras.push(frame::text_role( ui, TypeRole::BodySmall, false, @@ -242,7 +245,7 @@ pub(super) fn preview_block<'a>( )); } if let Some(more) = &preview.more_line { - block = block.push(frame::text_role( + extras = extras.push(frame::text_role( ui, TypeRole::BodySmall, false, @@ -250,9 +253,8 @@ pub(super) fn preview_block<'a>( ui.color(Role::OnSurfaceVariant), )); } - if !preview.notes.is_empty() { - block = block.push(frame::text_role( + extras = extras.push(frame::text_role( ui, TypeRole::LabelMedium, false, @@ -260,7 +262,7 @@ pub(super) fn preview_block<'a>( ui.color(Role::OnSurfaceVariant), )); for note in &preview.notes { - block = block.push(frame::text_role( + extras = extras.push(frame::text_role( ui, TypeRole::BodySmall, false, @@ -269,6 +271,8 @@ pub(super) fn preview_block<'a>( )); } } + let extras_rows = preview.inline_sample.len() + preview.notes.len(); + block = block.push(bounded(ui, extras, extras_rows, 4, 160.0)); block.into() } diff --git a/crates/app-iced/src/pages/content/packui/view/parts.rs b/crates/app-iced/src/pages/content/packui/view/parts.rs index bfd6ba2..c3c6e23 100644 --- a/crates/app-iced/src/pages/content/packui/view/parts.rs +++ b/crates/app-iced/src/pages/content/packui/view/parts.rs @@ -97,3 +97,33 @@ pub(super) fn rows_block<'a>( .width(Length::Fill) .into() } + +/// 逐条列表的**有界**容器:条目多时给一个固定高度的滚动区。 +/// +/// 为什么必须要有界:这一块长在内容库页正列的上方,而整页没有滚动(只有结果列表自己滚)⇒ +/// 报告一长,「关闭 / 以包为准重新导入 / 确认写盘」这些动作就被顶出屏幕。出图时实测到了这一点 +/// (冲突态与导出预检态的动作按钮落在 900px 以下)。 +/// +/// 少于 `rows_threshold` 条时直接铺开:给两行内容套一个 120px 的空框,比溢出更难看。 +pub(super) fn bounded<'a>( + ui: &'a Ui, + content: impl Into>, + rows: usize, + rows_threshold: usize, + height: f32, +) -> Element<'a, PageMessage> { + let content = content.into(); + if rows <= rows_threshold { + return content; + } + iced::widget::scrollable(content) + .height(Length::Fixed(height)) + .direction(iced::widget::scrollable::Direction::Vertical( + iced::widget::scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .style(frame::scroll_style(ui)) + .into() +} diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index cfdf65a..fb09ff3 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -18,7 +18,7 @@ use crate::theme::Ui; use super::super::model::{ConflictRow, Report}; use super::super::PackMessage; use super::intent; -use super::parts::{marker_box, rows_block, tolerable_title}; +use super::parts::{bounded, marker_box, rows_block, tolerable_title}; /// 结果块。 pub(super) fn report_block<'a>( @@ -119,7 +119,13 @@ pub(super) fn report_block<'a>( block = block.push( column![ tolerable_title(ui, catalog, title.as_str()), - rows_block(ui, catalog, &report.missing), + bounded( + ui, + rows_block(ui, catalog, &report.missing), + report.missing.len(), + 3, + 120.0, + ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill), @@ -133,15 +139,21 @@ pub(super) fn report_block<'a>( block = block.push( column![ tolerable_title(ui, catalog, title.as_str()), - column( - report - .conflicts - .iter() - .map(|conflict| conflict_row(ui, catalog, conflict)) - .collect::>() - ) - .spacing(ui.space(Space::X1)) - .width(Length::Fill), + bounded( + ui, + column( + report + .conflicts + .iter() + .map(|conflict| conflict_row(ui, catalog, conflict)) + .collect::>() + ) + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + report.conflicts.len(), + 2, + 160.0, + ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill), diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index df91d40..ef63b52 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -96,7 +96,10 @@ macro_rules! content_entries { "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。" ), PackPathLabel => ("Pack path", "整合包路径"), - PackPathPlaceholder => ("D:\\packs\\Better MC.mrpack", "D:\\packs\\Better MC.mrpack"), + PackPathPlaceholder => ( + "D:\\packs\\Better MC.mrpack", + "D:\\整合包\\更好的MC.mrpack" + ), PackImportAction => ("Import", "导入"), PackCancel => ("Cancel import", "取消导入"), PackDismiss => ("Dismiss", "关闭"), @@ -126,13 +129,13 @@ macro_rules! content_entries { PackStatusSuccess => ("Imported", "已导入"), PackStatusPartial => ("Imported with gaps", "导入完成,但有缺口"), PackStatusFailed => ("Import failed — rolled back", "导入失败 —— 已整包回滚"), - PackReportWritten => ("{count} files written · {bytes}", "{count} 个文件已写盘 · {bytes}"), + PackReportWritten => ("Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), PackReportMissing => ( - "{count} files could not be fetched (tolerated)", + "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)" ), PackReportConflicts => ( - "{count} files you had changed (yours kept by default)", + "You had changed (yours kept by default): {count}", "{count} 个文件你改过(默认保留你的)" ), PackReportRejectedNote => ("not supported by the client", "客户端不支持"), @@ -162,7 +165,7 @@ macro_rules! content_entries { "包内 {pack} · 基线 {base} · 磁盘 {disk}" ), PackConflictDetail => ("Hashes", "三方哈希"), - PackFormatModrinth => ("Modrinth .mrpack", "Modrinth .mrpack"), + PackFormatModrinth => ("Modrinth .mrpack", "Modrinth 的 .mrpack"), PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"), PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"), PackExportTitle => ("Export this instance", "导出这个实例"), @@ -182,7 +185,7 @@ macro_rules! content_entries { PackExportFailed => ("Export failed: {reason}", "导出失败:{reason}"), PackExportPending => ("Scanning the instance…", "正在扫描实例…"), PackPreviewHead => ( - "Will inline {inline} files · {skipped} items cannot be exported", + "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项" ), PackPreviewRemote => ( diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index d7ddcd6..48e457e 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -225,7 +225,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackDropActive", "Release to probe the pack", "松手开始探测"), ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"), ("PackPathLabel", "Pack path", "整合包路径"), - ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\packs\\Better MC.mrpack"), + ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"), ("PackImportAction", "Import", "导入"), ("PackCancel", "Cancel import", "取消导入"), ("PackDismiss", "Dismiss", "关闭"), @@ -246,9 +246,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackStatusSuccess", "Imported", "已导入"), ("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"), ("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"), - ("PackReportWritten", "{count} files written · {bytes}", "{count} 个文件已写盘 · {bytes}"), - ("PackReportMissing", "{count} files could not be fetched (tolerated)", "有 {count} 个文件没搞到(可容忍)"), - ("PackReportConflicts", "{count} files you had changed (yours kept by default)", "{count} 个文件你改过(默认保留你的)"), + ("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), + ("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"), + ("PackReportConflicts", "You had changed (yours kept by default): {count}", "{count} 个文件你改过(默认保留你的)"), ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), @@ -264,7 +264,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackChoiceOverwrite", "pack version used", "用包里的"), ("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"), ("PackConflictDetail", "Hashes", "三方哈希"), - ("PackFormatModrinth", "Modrinth .mrpack", "Modrinth .mrpack"), + ("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"), ("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"), ("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"), ("PackExportTitle", "Export this instance", "导出这个实例"), @@ -280,7 +280,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"), ("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"), ("PackExportPending", "Scanning the instance…", "正在扫描实例…"), - ("PackPreviewHead", "Will inline {inline} files · {skipped} items cannot be exported", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), + ("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), ("PackPreviewSkippedTitle", "Not in the pack (listed one by one)", "没进包(逐条列出)"), From 5f09b8113ae5af291d6d45ba28d75f7228211397 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 00:39:28 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(packui):=20M113=20phase=202=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E6=8E=A5=E7=BA=BF=EF=BC=88=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=9E=9A=E4=B8=BE=E5=90=84=E5=8F=AA=E5=A4=9A?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E5=8F=98=E4=BD=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 搬到 M109 rebase 后的新 main(26e764e)之后按**新 main 的形状**写一次,不写两遍。 ## 接线 - `dto/pack/event.rs`(新):`PackCommand` / `PackEvent` / `PackState::apply`。 整条流程在三个最挤的共享枚举里各只占**一个**变体(`AppCommand::Pack` / `CoreEvent::Pack` / `HostTask::Pack{event, epoch}`),子枚举承载细节 —— rebase 时不用替别人定序。 - `events.rs` / `job.rs` / `handle.rs` / `dispatch.rs` / `state.rs`:变体与分支,全部带 `// M113: import/export` 锚点。`state.rs` 只多 1 个字段 + 1 条 apply 分支(399 行)。 - `host/addon/**`(新目录,父文件只加 `mod pack;` + 再导出): - `pack.rs`:`CoreHost::pack` 落点 + 取消标记 + 实例目录/快照/格式/缺省路径解析 - `pack/import.rs`:探测 → 目标目录 → `import_pack` → 报告(默认 KeepUser,显式才 Overwrite) - `pack/export.rs`:预检(`scan_instance`,**不写盘**)与写盘(`export_instance`) - `pack/convert.rs`:core 报告 → DTO,逐字段照搬(三态/五阶段/四处置/三成因/十一原因) - `pack/diagnose.rs`:探测诊断「像 X 缺 Y」(按标记文件的有无,不看文件名) - `app/update/content.rs`:导入 / 以包为准 / 预检 / 确认写盘 / 关面板真的派指令; 预检时先记下那份请求,确认时**原样发回**(防「预检 A、写 B」)。 - `pages/content/model.rs`:读 `state.pack`(不再是默认空态)。 - 面板接口按新形状改:`install_panel_model(&InstallProgressView, catalog) -> Option` (`InstallView` 不再从 `pages/download` 导出)。 ## 取消(如实) `CancelInstall` 现在同时置导入的取消标记 + 代次 +1:面板立刻收回、旧事件按代次丢弃。 但 core 的 `import_pack` 没有取消令牌、进度回调也拿不到控制流 ⇒ **后台那次导入会跑完**。 所以「取消」的真实含义是「不再显示 + 结果作废」,已在模块文档与报告里写清,并单开 finding。 取消标记用 `Arc` 而不是裸静态量:裸静态量会在并行测试间泄漏取消状态(实测到过)。 ## 图看出来的三处(塔台要求) 1. `no download source (CurseForge file API not wired)` → `no download source available yet`, API 名只留在 DTO 的 detail 与日志里;顺带把 `sha512` / `projectID/fileID` 也改成用户话。 2. 右栏 `Installing modpacks is not available yet` → `Installing modpacks from the library into an instance is not available yet`(那句在 i18n 词表里,不在 M109 页面代码里,所以我自己改)。 3. 结果面板接线后可以关:`Dismiss` / `AbortExport` 发 `PackCommand::Dismiss` → host 回 `PackEvent::Cleared` → 回到入口/浏览态,不再常驻。 ## 证据(三条都是真跑,不是假数据) - `bad_packs_get_a_named_diagnosis`:把文本改名成 .mrpack → 有具名诊断(不是静默、不 panic); 目录形态有 overrides/ 无 manifest.json → 说得出「缺哪个标记」。 - `a_real_pack_runs_through_the_pipeline_end_to_end`:造一个真 MultiMC 实例目录当包, 走 `import::run` 真路径,断言事件流里有进度 + 结果、格式=MutliMC、报告有写盘文件、文件真落地、 代次原样带回。 - `preview_counts_match_the_written_report`:同一实例上跑预检与 `export_instance`, inline / skipped / remote **三项计数逐项相等**,且逐条路径的归类一致。 门禁:cargo check ✓ / clippy -D warnings(三 crate)EXIT=0 / cargo test(app-iced 504 + 6 守卫,i18n 24,presenter 181)全绿 / rustfmt --check ✓ / 所有改动文件 ≤400 行(`state.rs` 399、`addon.rs` 106,pack 子目录最大 338)。 --- crates/app-iced/src/app/update/content.rs | 100 +++++++- crates/app-iced/src/pages/content/model.rs | 9 +- .../pages/content/packui/model/progress.rs | 38 +-- .../src/pages/content/packui/tests.rs | 5 +- .../src/pages/content/packui/view/progress.rs | 28 +- crates/i18n/src/catalog/content.rs | 19 +- crates/i18n/src/catalog/golden.rs | 10 +- crates/presenter/src/dto/events.rs | 10 +- crates/presenter/src/dto/mod.rs | 2 +- crates/presenter/src/dto/pack.rs | 2 + crates/presenter/src/dto/pack/event.rs | 121 +++++++++ crates/presenter/src/dto/pack/export.rs | 2 + crates/presenter/src/host/addon.rs | 4 + crates/presenter/src/host/addon/pack.rs | 186 ++++++++++++++ .../presenter/src/host/addon/pack/convert.rs | 225 ++++++++++++++++ .../presenter/src/host/addon/pack/diagnose.rs | 104 ++++++++ .../presenter/src/host/addon/pack/export.rs | 132 ++++++++++ .../presenter/src/host/addon/pack/import.rs | 140 ++++++++++ crates/presenter/src/host/addon/pack/tests.rs | 241 ++++++++++++++++++ crates/presenter/src/host/dispatch.rs | 4 + crates/presenter/src/host/handle.rs | 7 + crates/presenter/src/host/job.rs | 13 +- crates/presenter/src/state.rs | 7 +- 23 files changed, 1336 insertions(+), 73 deletions(-) create mode 100644 crates/presenter/src/dto/pack/event.rs create mode 100644 crates/presenter/src/host/addon/pack.rs create mode 100644 crates/presenter/src/host/addon/pack/convert.rs create mode 100644 crates/presenter/src/host/addon/pack/diagnose.rs create mode 100644 crates/presenter/src/host/addon/pack/export.rs create mode 100644 crates/presenter/src/host/addon/pack/import.rs create mode 100644 crates/presenter/src/host/addon/pack/tests.rs diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 1fd3afb..26609b9 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -16,7 +16,10 @@ //! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时 //! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 -use unlucky_presenter::dto::{AppCommand, ContentInstallRequest, ContentQuery, ContentSource}; +use unlucky_presenter::dto::{ + AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView, + ImportRequestView, PackCommand, +}; use crate::pages::content::{Blocker, Content}; @@ -107,9 +110,8 @@ impl App { /// /// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)—— /// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理); - /// 2. **意图派指令**(导入 / 取消 / 预检 / 确认写盘)—— 本批只到「改本地状态」这一步, - /// presenter 侧的 `AppCommand::{ImportPack, ExportPack}` 随事件通道一起在下一批接上 - /// (见 mission M113 的两批时序:枚举顺序要按 M109 rebase 后的新 main 写一次,不写两遍)。 + /// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果 + /// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。 pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) { use pages::content::PackMessage; match message { @@ -172,21 +174,99 @@ impl App { } PackMessage::AbortExport => { self.content.pack.export_open = false; + // 连预检结果一起清:留着会让用户以为「还能再点一次确认」 + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss))); self.view_dirty = true; } PackMessage::Dismiss => { - // 报告与探测诊断都归 presenter(下一批清);这里先收起页内的展开状态 + // 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们 + // (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来 self.content.pack.expanded = None; + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss))); self.view_dirty = true; } - PackMessage::Import - | PackMessage::Cancel - | PackMessage::Overwrite - | PackMessage::Precheck - | PackMessage::Confirm => {} + PackMessage::Import => { + if let Some(request) = self.pack_import_request(false) { + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new( + request, + ))))); + } + } + PackMessage::Overwrite => { + // 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档) + if let Some(request) = self.pack_import_request(true) { + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new( + request, + ))))); + } + } + PackMessage::Cancel => { + // 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理 + self.dispatch(AppCommand::CancelInstall); + } + PackMessage::Precheck => { + if let Some(request) = self.pack_export_request() { + // 先记下「这次预检对的是哪份请求」,确认写盘时原样发回 + self.state.pack.set_export_request(request.clone()); + self.view_dirty = true; + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new( + request, + ))))); + } + } + PackMessage::Confirm => { + if let Some(request) = self.state.pack.export.request.clone() { + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new( + request, + ))))); + } + } } } + /// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。 + /// + /// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false` + /// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。 + fn pack_import_request(&self, overwrite: bool) -> Option { + let draft = self.content.pack.draft.trim(); + if draft.is_empty() { + return None; + } + let name = self.content.pack.instance_name.trim(); + Some(ImportRequestView { + pack: std::path::PathBuf::from(draft), + instance: self.content.pack.instance.clone(), + instance_name: (!name.is_empty()).then(|| name.to_string()), + overwrite, + }) + } + + /// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。 + /// + /// 没有选中实例就没有导出对象 ⇒ `None`。包名/版本留空时给缺省值: + /// 包名 = 实例 id、版本 = 页面上的 `1.0.0`。 + fn pack_export_request(&self) -> Option { + let instance = self.content.pack.instance.clone()?; + let name = self.content.pack.export_name.trim(); + let version = self.content.pack.export_version.trim(); + Some(ExportRequestView { + instance: instance.clone(), + format: self.content.pack.format, + name: if name.is_empty() { + instance + } else { + name.to_string() + }, + version: if version.is_empty() { + "1.0.0".to_string() + } else { + version.to_string() + }, + target: None, + }) + } + /// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。 pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task { // 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿 diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs index ab76fa3..f9f9199 100644 --- a/crates/app-iced/src/pages/content/model.rs +++ b/crates/app-iced/src/pages/content/model.rs @@ -146,12 +146,11 @@ impl Content { target.as_deref(), ); - // M113: import/export —— 进度与报告在 presenter(`AppState.import` / `.export`), - // 页内选择在 `page.pack`。两批落地:本批先接页内选择(入口可用),事件回流在下一批。 - // `targets` 下面会被移动进结构体,所以这里先借用它建视图模型。 + // M113: import/export —— 进度/报告/预检在 presenter(`AppState.pack`), + // 页内选择在 `page.pack`。`targets` 下面会被移动进结构体,所以这里先借用它建视图模型。 let pack = super::packui::Pack::of( - &unlucky_presenter::dto::ImportState::default(), - &unlucky_presenter::dto::ExportState::default(), + &state.pack.import, + &state.pack.export, &page.pack, &targets, catalog, diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index 31e94f8..d319bce 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -21,7 +21,7 @@ use unlucky_presenter::dto::{ ImportProgressView, ImportStageView, InstallProgressView, InstallStage, }; -use crate::pages::download::{Download, InstallView}; +use crate::pages::download::Download; /// 抛进面板的进度行。 #[derive(Debug, Clone, PartialEq)] @@ -30,8 +30,8 @@ pub struct Running { pub pack: String, /// 细档位说明(`步骤 2/5 · 解包覆盖内容`) pub stage_line: String, - /// 交给下载页那块面板的进度(**唯一**的进度形状来源) - pub panel: Download, + /// 交给下载页那块面板的进度(**唯一**的进度形状来源;`None` = 面板无话可说) + pub panel: Option, } /// 细档位说明(`步骤 2/5 · 解包覆盖内容`)。 @@ -100,33 +100,19 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView { } /// 进度事件 → 面板要的视图模型。 +/// +/// 面板入参走 `pages::download::install_panel_model`(M109 合流 M105 后提供的**唯一**入口): +/// 它把 `InstallProgressView` 转成面板要的 `Download`,终态(完成/取消)返回 `None`。 +/// 导入的五个阶段都不是终态,所以正常路径下一定是 `Some`;`None` 只做防御(面板没了就只留步骤行)。 +/// +/// 面板自带那个粗阶段词是**安装**语义(`Resolving version` / `Downloading files` / +/// `Building the version` / `Registering instance`),所以细档位由 [`stage_line`] 说, +/// 二者是「粗/细」关系而不是两套说法——[`panel_stage`] 只挑语义最近的那一档。 pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running { let panel = panel_progress(progress); Running { pack: progress.pack.clone(), stage_line: stage_line(progress.stage, catalog), - panel: Download { - install: Some(InstallView { - version: panel.version.clone(), - // 只用于 `running()` / `failed()` 的判定;**不**用它给用户看(见模块文档第 3 点) - stage: panel.stage, - stage_label: String::new(), - detail: if progress.is_known() { - catalog.render( - Key::DownloadProgress, - &[ - ("done", &progress.done.to_string()), - ("total", &progress.total.to_string()), - ], - ) - } else { - catalog.text(Key::DownloadProgressUnknown).to_owned() - }, - rate: None, - ratio: progress.ratio(), - known: progress.is_known(), - }), - ..Download::loading() - }, + panel: crate::pages::download::install_panel_model(&panel, catalog), } } diff --git a/crates/app-iced/src/pages/content/packui/tests.rs b/crates/app-iced/src/pages/content/packui/tests.rs index 0cfbb94..f0d0d5a 100644 --- a/crates/app-iced/src/pages/content/packui/tests.rs +++ b/crates/app-iced/src/pages/content/packui/tests.rs @@ -34,7 +34,10 @@ fn importing_shows_the_shared_install_panel() { let running = model.running.expect("导入中要有进度"); // 进度形状来自下载页那块面板(`Download.install`),不是本区另造的 assert!( - running.panel.install.is_some(), + running + .panel + .as_ref() + .is_some_and(|panel| panel.install.is_some()), "进度必须走下载页的安装面板视图模型" ); assert!( diff --git a/crates/app-iced/src/pages/content/packui/view/progress.rs b/crates/app-iced/src/pages/content/packui/view/progress.rs index c326ce4..2afb9cf 100644 --- a/crates/app-iced/src/pages/content/packui/view/progress.rs +++ b/crates/app-iced/src/pages/content/packui/view/progress.rs @@ -27,20 +27,22 @@ pub(super) fn running_block<'a>( catalog: Catalog, running: &'a Running, ) -> Element<'a, PageMessage> { - column![ - frame::text_role( - ui, - TypeRole::LabelMedium, - false, - running.stage_line.as_str(), - ui.color(Role::OnSurfaceVariant), - ), - // 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。 - download::install_panel(ui, catalog, &running.panel), - ] + let mut block = column![frame::text_role( + ui, + TypeRole::LabelMedium, + false, + running.stage_line.as_str(), + ui.color(Role::OnSurfaceVariant), + )] .spacing(ui.space(Space::X1)) - .width(Length::Fill) - .into() + .width(Length::Fill); + + // 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。 + // `None` 只会在「阶段已收尾」时出现(导入的五个阶段都不是终态),那时只剩步骤行。 + if let Some(panel) = &running.panel { + block = block.push(download::install_panel(ui, catalog, panel)); + } + block.into() } /// 探测失败:把「缺哪个标记」摊开。 diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index ef63b52..7d13e0a 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -72,8 +72,8 @@ macro_rules! content_entries { ), ContentIncompatibleLoader => ("Not built for {loader}", "不支持 {loader}"), ContentModpackNotReady => ( - "Installing modpacks is not available yet", - "整合包安装尚未开放" + "Installing modpacks from the library into an instance is not available yet", + "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)" ), ContentSourceUnavailable => ( "CurseForge needs an API key: add one in Settings", @@ -92,8 +92,8 @@ macro_rules! content_entries { ), PackDropActive => ("Release to probe the pack", "松手开始探测"), PackNoDialog => ( - "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", - "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。" + "This build has no system file picker: drag the file in, or paste its path below.", + "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。" ), PackPathLabel => ("Pack path", "整合包路径"), PackPathPlaceholder => ( @@ -146,8 +146,8 @@ macro_rules! content_entries { ), PackMissingOffline => ("offline import", "离线导入"), PackMissingNoSource => ( - "no download source (CurseForge file API not wired)", - "拿不到下载地址(CF 文件端点未接通)" + "no download source available yet", + "暂时拿不到下载地址" ), PackMissingFetchFailed => ("download failed: {detail}", "下载失败:{detail}"), PackFatalTitle => ( @@ -225,9 +225,12 @@ macro_rules! content_entries { "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联" ), - PackReasonMissingSha512 => ("match has no sha512, inlined", "命中缺 sha512,已内联"), + PackReasonMissingSha512 => ( + "the catalogue entry has no sha512 hash, inlined", + "命中缺 sha512,已内联" + ), PackReasonMissingCurseIds => ( - "match has no projectID/fileID, inlined", + "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联" ), PackReasonDisabled => ( diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 48e457e..992a40c 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -216,14 +216,14 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("ContentNoInstances", "No instance to install into", "没有可装入的实例"), ("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"), ("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"), - ("ContentModpackNotReady", "Installing modpacks is not available yet", "整合包安装尚未开放"), + ("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"), ("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"), ("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"), ("PackTitle", "Modpack import & export", "整合包导入 / 导出"), ("PackImportTitle", "Import a modpack", "导入整合包"), ("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"), ("PackDropActive", "Release to probe the pack", "松手开始探测"), - ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"), + ("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"), ("PackPathLabel", "Pack path", "整合包路径"), ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"), ("PackImportAction", "Import", "导入"), @@ -253,7 +253,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), ("PackMissingOffline", "offline import", "离线导入"), - ("PackMissingNoSource", "no download source (CurseForge file API not wired)", "拿不到下载地址(CF 文件端点未接通)"), + ("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"), ("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"), ("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"), ("PackFatalHint", "What to try next", "下一步可以试"), @@ -294,8 +294,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"), ("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"), ("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"), - ("PackReasonMissingSha512", "match has no sha512, inlined", "命中缺 sha512,已内联"), - ("PackReasonMissingCurseIds", "match has no projectID/fileID, inlined", "命中缺 projectID/fileID,已内联"), + ("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"), + ("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"), ("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"), ("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"), ("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"), diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index a515c83..83ce28a 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -11,8 +11,8 @@ use crate::dto::CreateRequest; use crate::dto::{ AccountView, AgentEvent, ContentInstallRequest, ContentQuery, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, InstanceSummary, - JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, SessionId, - SettingGroupView, VersionCategory, VersionSummary, + JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, PackCommand, PackEvent, + SessionId, SettingGroupView, VersionCategory, VersionSummary, }; use crate::toast::Toast; @@ -98,6 +98,9 @@ pub enum CoreEvent { ContentResults(Box), /// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥) ContentFailed(String), + // M113: import/export + /// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`]) + Pack(Box), } /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, @@ -186,6 +189,9 @@ pub enum AppCommand { SearchContent(ContentQuery), /// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因) InstallContent(Box), + // M113: import/export + /// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`]) + Pack(Box), } impl AppCommand { diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 9cab44e..8c1529d 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -49,7 +49,7 @@ pub use pack::{ ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView, - MissingReasonView, PackFormatView, ProbeView, + MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView, }; pub use settings::{SettingControl, SettingGroupView, SettingRowView}; pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary}; diff --git a/crates/presenter/src/dto/pack.rs b/crates/presenter/src/dto/pack.rs index e3e584d..7fbd1fb 100644 --- a/crates/presenter/src/dto/pack.rs +++ b/crates/presenter/src/dto/pack.rs @@ -32,11 +32,13 @@ //! **绝不默认覆盖用户改过的文件**。 mod conflict; +mod event; mod export; mod format; mod import; pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView}; +pub use event::{PackCommand, PackEvent, PackState}; pub use export::{ ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, diff --git a/crates/presenter/src/dto/pack/event.rs b/crates/presenter/src/dto/pack/event.rs new file mode 100644 index 0000000..fe0ddde --- /dev/null +++ b/crates/presenter/src/dto/pack/event.rs @@ -0,0 +1,121 @@ +//! 导入/导出的**指令、事件与面板状态**(M113):`AppCommand::Pack` / `CoreEvent::Pack` 的载荷。 +//! +//! ## 为什么整条流程只占一个枚举变体 +//! +//! `AppCommand` / `CoreEvent` / `HostTask` 是这条装配链上最拥挤的三个枚举(M106/M107/M108/M109/M114 +//! 都在往里加变体)。所以导入导出**不各占一个变体**,而是各自一个 [`PackCommand`] / [`PackEvent`] +//! 子枚举、外面只多一层包装:共享枚举里只多一行,rebase 时不用替别人定序。 +//! +//! ## 取消是**界面级**的,不是管线级的(如实写) +//! +//! core 的 `import_pack` 没有取消令牌(`ImportRequest` 里没有 cancel 字段,`progress` 回调是 +//! 纯通知、拿不到控制流)。所以「取消导入」在本轮的含义是: +//! +//! 1. 立刻把面板收回([`PackEvent::Progress`] 传 `None`); +//! 2. **丢弃**这一次运行之后回来的事件(代次比较,与 M105 的安装取消同一套做法); +//! 3. 后台那次导入会继续跑到结束——**它不会因为点了取消而停手**。 +//! +//! 这条必须在报告里如实登记:把它写成「真的停下来了」就是撒谎。 +//! +//! ## 事件 → 状态只有一条路 +//! +//! [`PackState::apply`] 是**唯一**的写入点(与 `AppState::apply` 同一条纪律):页面读到的进度、 +//! 报告、预检都只能从事件来,页面自己不做乐观回显。 + +use serde::{Deserialize, Serialize}; + +use crate::dto::pack::{ + ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView, + ImportReportView, ImportRequestView, ImportState, ProbeView, +}; + +/// app → host 的导入/导出意图。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PackCommand { + /// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录) + Import(Box), + /// 跑**预检**(只扫描分类,**不写盘**) + Export(Box), + /// 确认写盘。载荷是**预检时那一份请求**,不是页面当前字段——否则「预检 A、写 B」。 + Confirm(Box), + /// 关掉面板(导入结果 / 探测诊断 / 导出预检都收起来,回到入口态) + Dismiss, +} + +/// host → app 的导入/导出事件。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PackEvent { + /// 导入进度(`None` = 收回面板:取消,或流程已经给出结果) + Progress(Option>), + /// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因) + Report(Box), + /// 探测失败:这个包看起来像什么、缺哪个标记文件 + Probe(Box), + /// 导出预检结果 + Preview(Box), + /// 导出写盘完成 + Exported(Box), + /// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因) + ExportFailed(String), + /// 关掉所有面板 + Cleared, +} + +/// 导入/导出两个面板的状态(`AppState::pack` 的全部内容)。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PackState { + /// 导入侧(进度 / 结果 / 探测诊断) + pub import: ImportState, + /// 导出侧(预检 / 完成 / 失败) + pub export: ExportState, +} + +impl PackState { + /// 事件 → 状态(**唯一**写入点)。 + pub fn apply(&mut self, event: PackEvent) { + match event { + PackEvent::Progress(progress) => { + self.import.progress = progress.map(|progress| *progress); + } + PackEvent::Report(report) => { + // 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了) + self.import.progress = None; + self.import.probe = None; + self.import.report = Some(*report); + } + PackEvent::Probe(probe) => { + self.import.progress = None; + self.import.report = None; + self.import.probe = Some(*probe); + } + PackEvent::Preview(preview) => { + self.export.pending = false; + self.export.failed = None; + self.export.done = None; + self.export.preview = Some(*preview); + } + PackEvent::Exported(done) => { + self.export.pending = false; + self.export.failed = None; + // 写完之后预检就过期了:留着会让用户以为「还能再点一次确认」 + self.export.preview = None; + self.export.done = Some(*done); + } + PackEvent::ExportFailed(reason) => { + self.export.pending = false; + self.export.failed = Some(reason); + } + PackEvent::Cleared => { + self.import = ImportState::default(); + self.export = ExportState::default(); + } + } + } + + /// 记下「这次预检是对着哪份请求做的」(确认写盘时原样发回)。 + pub fn set_export_request(&mut self, request: ExportRequestView) { + self.export.request = Some(request); + self.export.pending = true; + self.export.failed = None; + } +} diff --git a/crates/presenter/src/dto/pack/export.rs b/crates/presenter/src/dto/pack/export.rs index 8f9426f..3ec6941 100644 --- a/crates/presenter/src/dto/pack/export.rs +++ b/crates/presenter/src/dto/pack/export.rs @@ -210,6 +210,8 @@ pub struct ExportDoneView { pub struct ExportState { /// 预检结果(`None` = 还没预检)。 pub preview: Option, + /// **这次预检是对着哪份请求做的**(确认写盘时原样发回,防「预检 A、写 B」)。 + pub request: Option, /// 预检在飞。 pub pending: bool, /// 最近一次写盘的结果。 diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 2734124..4da4a72 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -34,8 +34,12 @@ mod http; mod install; +// M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod) +mod pack; mod sources; +pub(super) use pack::cancel_pack; + #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs new file mode 100644 index 0000000..9516eac --- /dev/null +++ b/crates/presenter/src/host/addon/pack.rs @@ -0,0 +1,186 @@ +//! 整合包**导入 / 导出**(M113 `PACKUI`):把 M111/M112 的管线接到内容库页上。 +//! +//! ## 这里只做「解析目标 + 翻译 + 转发」 +//! +//! 真正的活全在 core:`addon::import::import_pack`(三种格式 → 实例目录)与 +//! `addon::export::{scan_instance, export_instance}`(实例目录 → `.mrpack` / CF 清单)。 +//! 本模块不重新判定任何东西——三态、逐项原因、三分类全部**逐字段照搬** core 的报告 +//! ([`convert`]),于是「UI 说的」与「管线干的」是同一份事实。 +//! +//! ## 文件分工(守 400 行) +//! +//! | 文件 | 职责 | +//! |---|---| +//! | 本文件 | `CoreHost::pack` 落点 + 取消标记 + 三条 spawn 共用的目标解析 | +//! | [`import`] | 后台导入(探测 → 目标目录 → 管线 → 报告) | +//! | [`export`] | 后台预检(不写盘)与后台写盘 | +//! | [`convert`] | core 报告 → DTO(逐字段照搬)+ 小工具 | +//! | [`diagnose`] | 探测诊断:「看起来像 X,但缺 Y」 | +//! +//! ## 导出预检与写盘用同一套判据 +//! +//! core 的 `export_instance` 没有 dry-run 入口(已报 finding)。预检用 `scan_instance` + +//! 「离线目录 ⇒ 一律内联」这条事实拼出来:`RemoteCatalog` 目前只有 `OfflineCatalog`, +//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。 +//! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。 +//! +//! ## 取消是**界面级**的 +//! +//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1: +//! +//! 1. 面板立刻收回(不是等后台回一句话); +//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉; +//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到 +//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给 +//! core 加 cancel token(已登记 finding,报告里同样划出来)。 + +mod convert; +mod diagnose; +mod export; +mod import; + +#[cfg(test)] +mod tests; + +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; + +use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot}; +use unlucky_core::addon::pack::PackFormat; +use unlucky_core::game::install::instance::read_instance_config; +use unlucky_core::game::isolation::derive_game_dir; + +use crate::dto::{ + CoreEvent, ExportRequestView, ImportProgressView, InstanceId, PackCommand, PackEvent, + PackFormatView, +}; + +use super::super::env::HostEnv; +use super::super::job::{HostTask, JobSender}; + +/// 正在跑的那次导入的取消标记(模块级槽:`CoreHost` 的字段不在本 mission 的 scope)。 +/// +/// 语义见模块文档「取消是界面级的」——它挡的是**事件**,不是管线。 +/// 用 `Arc` 而不是一个裸静态量:导入线程拿一份克隆、`cancel_pack()` 拿另一份, +/// 于是**测试可以各自持有一份**,不会互相串(裸静态量在并行测试里会泄漏取消状态)。 +static PACK_CANCEL: Mutex>> = Mutex::new(None); + +/// 取消正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 +pub(in crate::host) fn cancel_pack() { + if let Ok(slot) = PACK_CANCEL.lock() { + if let Some(cancel) = slot.as_ref() { + cancel.store(true, std::sync::atomic::Ordering::SeqCst); + } + } +} + +/// 新一次导入的取消标记(同时把它挂进模块级槽,供 [`cancel_pack`] 用)。 +fn fresh_cancel() -> Arc { + let cancel = Arc::new(AtomicBool::new(false)); + if let Ok(mut slot) = PACK_CANCEL.lock() { + *slot = Some(Arc::clone(&cancel)); + } + cancel +} + +/// `AppCommand::Pack` 的落点(M113)。 +impl super::super::CoreHost { + pub(crate) fn pack(&mut self, command: PackCommand) { + let epoch = self.install_epoch; + match command { + PackCommand::Import(request) => { + let cancel = fresh_cancel(); + // 先把面板点亮:用户点了就有反应,不等后台回第一条进度 + let name = request + .instance_name + .clone() + .or_else(|| request.instance.clone()) + .unwrap_or_default(); + self.send(CoreEvent::Pack(Box::new(PackEvent::Progress(Some( + Box::new(ImportProgressView::new(name)), + ))))); + import::spawn_import(self.env.clone(), self.tx.clone(), *request, epoch, cancel); + } + PackCommand::Export(request) => { + export::spawn_preview(self.env.clone(), self.tx.clone(), *request, epoch); + } + PackCommand::Confirm(request) => { + export::spawn_write(self.env.clone(), self.tx.clone(), *request, epoch); + } + PackCommand::Dismiss => { + cancel_pack(); + // 代次 +1:这次之后回来的旧事件一律作废 + self.install_epoch = self.install_epoch.wrapping_add(1); + self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared))); + } + } + } +} + +/// 后台任务 → host 的一跳(三个 spawn 共用)。 +pub(super) fn send(tx: &JobSender, epoch: u64, event: PackEvent) { + let _ = tx.send(HostTask::Pack { + event: Box::new(event), + epoch, + }); +} + +/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 全局档)。 +pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result { + let instance = super::super::game::instances(env) + .into_iter() + .find(|instance| &instance.id == id) + .ok_or_else(|| format!("instance not found: {id}"))?; + let global = super::super::game::global_mode(env); + let config = read_instance_config(&env.layout, &instance.id) + .map_err(|error| format!("read instance config failed: {error}"))?; + derive_game_dir( + &env.game, + &instance.version_id, + global, + config.as_ref(), + None, + ) + .map(|derivation| derivation.game_dir) + .map_err(|error| format!("derive game dir failed: {error}")) +} + +/// 实例快照(清单的 `game` + `dependencies` 从它来)。读不出来就返回 `None`, +/// 由 core 自己按 `root/versions/` 再试一次(失败会给具名错误)。 +pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option { + let instance = super::super::game::instances(env) + .into_iter() + .find(|instance| &instance.id == id)?; + // 隔离实例的 `versions/` 在全局根下,所以试全局根 + read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok() +} + +/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。 +pub(super) fn format_of(format: PackFormatView) -> Result<&'static dyn PackFormat, String> { + use unlucky_core::addon::pack::{curseforge, modrinth}; + match format { + PackFormatView::Modrinth => Ok(&modrinth::MODRINTH), + PackFormatView::Curseforge => Ok(&curseforge::CURSEFORGE), + PackFormatView::Multimc => { + Err("MultiMC / Prism can only be imported, not exported".to_string()) + } + } +} + +/// 缺省落盘路径:`/exports/-.`。 +pub(super) fn default_export_path(env: &HostEnv, request: &ExportRequestView) -> PathBuf { + let ext = match request.format { + PackFormatView::Curseforge => "zip", + _ => "mrpack", + }; + let name = format!( + "{}-{}", + convert::slug(&request.name), + convert::slug(&request.version) + ); + env.layout + .data() + .join("exports") + .join(format!("{name}.{ext}")) +} diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs new file mode 100644 index 0000000..627aa81 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -0,0 +1,225 @@ +//! core 报告 → DTO(M113):**逐字段照搬,不重算任何结论**。 +//! +//! 这一层的唯一职责是翻译:三态、五阶段、四种处置、三种冲突成因、十一种导出原因, +//! 全部一一对应。任何一个「顺手换算一下」都会让 UI 说的和管线干的不是一回事, +//! 所以这里没有一处 re-derive(唯一例外是把 `&'static str` 的稳定码换成人读串)。 +//! +//! 翻译用的是 core 的稳定 slug(`as_str()`)+ DTO 的 `from_slug()`, +//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None` +//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::addon::export::{ExportReport, FileClass, FileReason}; +use unlucky_core::addon::import::{ + ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress, + ImportReport, LoaderTarget, MissingReason, +}; + +use crate::dto::{ + ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView, + ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView, + ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, + PackFormatView, +}; + +/// 一次导入结果 → DTO。 +pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView { + ImportReportView { + status: ImportStatusView::from_slug(report.status.as_str()) + .unwrap_or(ImportStatusView::Failed), + format: report.format.as_deref().and_then(PackFormatView::from_slug), + pack_name: report.pack_name.clone(), + pack_version: report.pack_version.clone(), + game_version: report.game_version.clone(), + loader: report.loader.as_ref().map(loader_label), + version_id: report.version_id.clone(), + target: target.to_path_buf(), + files: report + .files + .iter() + .map(|file| ImportedFileView { + path: file.path.clone(), + action: action_view(file.action), + bytes: file.bytes, + }) + .collect(), + conflicts: report.conflicts.iter().map(conflict_view).collect(), + missing: report + .missing + .iter() + .map(|file| MissingFileView { + path: file.path.clone(), + display: file.display.clone(), + reason: missing_view(file.reason), + detail: file.detail.clone(), + }) + .collect(), + rejected: report + .rejected_components + .iter() + .map(|component| match &component.version { + Some(version) => format!("{} {version}", component.uid), + None => component.uid.clone(), + }) + .collect(), + warnings: report + .warnings + .iter() + .map(|issue| issue.message.clone()) + .collect(), + error: report.error.as_ref().map(error_view), + registered: report + .registered + .as_ref() + .map(|registered| registered.name.clone()), + bytes_written: report.bytes_written, + rolled_back: report.rolled_back, + } +} + +/// 一次导出结果 → DTO。 +pub(super) fn done_view(report: &ExportReport) -> ExportDoneView { + ExportDoneView { + path: report.path.clone(), + bytes: report.bytes, + remote: u32::try_from(report.remote_count()).unwrap_or(u32::MAX), + inline: u32::try_from(report.inline_count()).unwrap_or(u32::MAX), + skipped: u32::try_from(report.skipped_count()).unwrap_or(u32::MAX), + } +} + +/// 一条导入进度 → DTO。 +pub(super) fn progress_view(progress: &ImportProgress, pack: &str) -> ImportProgressView { + ImportProgressView { + stage: ImportStageView::from_slug(progress.stage.as_str()) + .unwrap_or(ImportStageView::Probe), + done: u32::try_from(progress.done).unwrap_or(u32::MAX), + total: u32::try_from(progress.total).unwrap_or(u32::MAX), + current: progress.current.clone(), + pack: pack.to_string(), + } +} + +/// 致命失败 → DTO(稳定码原样带走,日志与 `--json` 用的是同一串)。 +fn error_view(error: &ImportError) -> ImportErrorView { + ImportErrorView { + code: error.kind().as_str().to_string(), + label: error.kind().label().to_string(), + message: error.message().to_string(), + hint: error.hint().map(str::to_string), + } +} + +fn conflict_view(file: &ConflictFile) -> ConflictFileView { + ConflictFileView { + path: file.path.clone(), + reason: match file.reason { + ConflictReason::UserModified => ConflictReasonView::UserModified, + ConflictReason::UserDeleted => ConflictReasonView::UserDeleted, + ConflictReason::PreExisting => ConflictReasonView::PreExisting, + }, + choice: match file.choice { + ConflictChoice::KeepUser => ConflictChoiceView::KeepUser, + ConflictChoice::Overwrite => ConflictChoiceView::Overwrite, + }, + pack_sha1: file.pack_sha1.clone(), + base_sha1: file.base_sha1.clone(), + disk_sha1: file.disk_sha1.clone(), + pack_size: file.pack_size, + } +} + +const fn action_view(action: FileAction) -> FileActionView { + match action { + FileAction::Installed => FileActionView::Installed, + FileAction::Replaced => FileActionView::Replaced, + FileAction::SkippedIdentical => FileActionView::SkippedIdentical, + FileAction::KeptUser => FileActionView::KeptUser, + } +} + +const fn missing_view(reason: MissingReason) -> MissingReasonView { + match reason { + MissingReason::Offline => MissingReasonView::Offline, + MissingReason::NoSource => MissingReasonView::NoSource, + MissingReason::FetchFailed => MissingReasonView::FetchFailed, + } +} + +pub(super) fn reason_view(reason: FileReason) -> ExportReasonView { + ExportReasonView::from_slug(reason.as_str()).unwrap_or(ExportReasonView::Unreadable) +} + +pub(super) fn class_view(class: FileClass) -> ExportClassView { + match class { + FileClass::Remote => ExportClassView::Remote, + FileClass::Inline => ExportClassView::Inline, + FileClass::Skipped => ExportClassView::Skipped, + } +} + +/// 加载器 → 人读串(`fabric-loader 0.15.11` / `vanilla 1.20.1`)。 +fn loader_label(target: &LoaderTarget) -> String { + format!("{} {}", target.kind.as_str(), target.loader_version) +} + +/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。 +/// +/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。 +pub(super) fn failed_report( + pack: PathBuf, + code: &str, + label: &str, + message: &str, +) -> ImportReportView { + ImportReportView { + status: ImportStatusView::Failed, + format: None, + pack_name: Some(pack_stem(&pack)), + pack_version: None, + game_version: None, + loader: None, + version_id: None, + target: pack, + files: Vec::new(), + conflicts: Vec::new(), + missing: Vec::new(), + rejected: Vec::new(), + warnings: Vec::new(), + error: Some(ImportErrorView { + code: code.to_string(), + label: label.to_string(), + message: message.to_string(), + hint: None, + }), + registered: None, + bytes_written: 0, + rolled_back: true, + } +} + +/// 包文件名(`Better MC.mrpack` → `Better MC`)。 +pub(super) fn pack_stem(pack: &Path) -> String { + pack.file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| "imported-pack".to_string()) +} + +/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。 +pub(super) fn slug(text: &str) -> String { + let mut out = String::new(); + for ch in text.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + out.push(ch); + } else if !out.ends_with('-') { + out.push('-'); + } + } + let trimmed = out.trim_matches('-').to_string(); + if trimmed.is_empty() { + "pack".to_string() + } else { + trimmed + } +} diff --git a/crates/presenter/src/host/addon/pack/diagnose.rs b/crates/presenter/src/host/addon/pack/diagnose.rs new file mode 100644 index 0000000..ca178cb --- /dev/null +++ b/crates/presenter/src/host/addon/pack/diagnose.rs @@ -0,0 +1,104 @@ +//! 探测诊断(M113):「这个包**看起来像** X,但**缺** Y」。 +//! +//! ## 为什么由 host 判,不引 core 改动 +//! +//! core 的 `PackRegistry::detect` 只回答「谁认领」,一个都不认领时只给一句 +//! `pack_unsupported` + 「试过哪些格式」。而用户手里那个包往往**差一点点**就是某个已知格式 +//! (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。 +//! 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。 +//! +//! ## 判据只有标记文件的有无(不看文件名) +//! +//! | 格式 | 标记 | 邻居(有它但没标记 ⇒ 「像它」) | +//! |---|---|---| +//! | CurseForge | `manifest.json` | `overrides/` · `mods/` | +//! | Modrinth | `modrinth.index.json` | `overrides/` | +//! | MultiMC / Prism | `mmc-pack.json` | `instance.cfg` · `.minecraft/` | +//! +//! `saw` 只留前 5 条有代表性的标记:长路径往界面里倒会把版面撑破(塔台复审提过)。 +//! 一个标记都没认出来时 `saw` 为空,由界面显示「nothing recognisable」而不是一行空白。 + +use std::path::Path; + +use unlucky_core::addon::pack::{PackArchive, PackLimits, PackRegistry}; + +use crate::dto::{PackFormatView, ProbeView}; + +/// 诊断一个包(`None` = 有个格式认领它,正常走管线)。 +pub(super) fn of(pack: &Path) -> Option { + let archive = match PackArchive::open(pack, PackLimits::default()) { + Ok(archive) => archive, + Err(error) => { + // 连 zip 都打不开(选错文件 / 下了一半):也要说清楚是哪一步失败的 + return Some(ProbeView { + looks_like: None, + missing: Vec::new(), + saw: Vec::new(), + detail: format!("could not open the archive: {error}"), + }); + } + }; + let registry = PackRegistry::builtin(); + if registry.detect(&archive).is_ok() { + return None; + } + + // (格式, 标记文件, 邻居标记) + let rules: [(PackFormatView, &str, &[&str]); 3] = [ + ( + PackFormatView::Curseforge, + "manifest.json", + &["overrides/", "mods/"], + ), + ( + PackFormatView::Modrinth, + "modrinth.index.json", + &["overrides/"], + ), + ( + PackFormatView::Multimc, + "mmc-pack.json", + &["instance.cfg", ".minecraft/"], + ), + ]; + + // 目录输入的条目名**没有尾斜杠**(`overrides` 而不是 `overrides/`),zip 里目录条目也可能缺; + // 所以匹配同时认「精确 / 前缀 / 去掉尾斜杠」三种写法,判据仍然只有标记文件的有无。 + let names: Vec<&str> = archive.entries().iter().map(|entry| entry.name()).collect(); + let has = |needle: &str| { + names.iter().any(|name| { + *name == needle || name.starts_with(needle) || *name == needle.trim_end_matches('/') + }) + }; + + let mut saw: Vec = Vec::new(); + let mut best: Option<(PackFormatView, String)> = None; + for (format, marker, neighbours) in rules { + let hits: Vec<&str> = neighbours + .iter() + .copied() + .filter(|entry| has(entry)) + .collect(); + if hits.is_empty() { + continue; + } + if !has(marker) { + // 有邻居、没标记 ⇒ 「像它,但缺那个标记」 + saw.extend(hits.iter().map(|entry| (*entry).to_string())); + if best.is_none() { + best = Some((format, marker.to_string())); + } + } + } + saw.sort(); + saw.dedup(); + saw.truncate(5); + + let detail = format!("tried: {}", registry.ids().join(" / ")); + Some(ProbeView { + looks_like: best.as_ref().map(|(format, _)| *format), + missing: best.map(|(_, marker)| vec![marker]).unwrap_or_default(), + saw, + detail, + }) +} diff --git a/crates/presenter/src/host/addon/pack/export.rs b/crates/presenter/src/host/addon/pack/export.rs new file mode 100644 index 0000000..1556e84 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/export.rs @@ -0,0 +1,132 @@ +//! 导出(M113):**预检不写盘**,写盘是另一次调用,两次用同一套判据。 +//! +//! 预检的全部依据就是 core 的 `scan_instance` + 「没有远程目录 ⇒ 一律内联」: +//! `RemoteCatalog` 目前只有 `OfflineCatalog`,任何文件都证明不了可被远程替代。所以 +//! 「将内联 N / 无法导出 M」里的 N 是扫到的文件数、M 是 `SkippedLocal`(符号链接 / 路径不安全 / +//! 读不出来)。写盘走 `export_instance`(同样注入离线目录), +//! `tests::preview_counts_match_the_written_report` 用真管线钉住两边相等。 + +use unlucky_core::addon::export::{ + export_instance, scan_instance, ExportRequest, OfflineCatalog, DEFAULT_CONTENT_ROOTS, +}; + +use crate::dto::{ + ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, + PackEvent, +}; + +use super::super::super::env::HostEnv; +use super::super::super::job::{spawn, JobSender}; +use super::convert::{class_view, done_view, reason_view}; +use super::send; + +/// 起一个后台预检线程(**不写盘**)。 +pub(super) fn spawn_preview(env: HostEnv, tx: JobSender, request: ExportRequestView, epoch: u64) { + spawn("pack-export-preview", move || { + let root = match super::instance_root(&env, &request.instance) { + Ok(root) => root, + Err(reason) => { + send(&tx, epoch, PackEvent::ExportFailed(reason)); + return; + } + }; + send( + &tx, + epoch, + PackEvent::Preview(Box::new(preview_of(&root, &env, &request))), + ); + }); +} + +/// 预检的**纯函数部分**(不起线程、不写盘):扫描 → 三分类计数。 +/// +/// 抽出来是为了能测:`tests::preview_counts_match_the_written_report` 用它和 +/// `export_instance` 的真实报告对数字,等式不成立就红。 +pub(super) fn preview_of( + root: &std::path::Path, + env: &HostEnv, + request: &ExportRequestView, +) -> ExportPreviewView { + let target = request + .target + .clone() + .unwrap_or_else(|| super::default_export_path(env, request)); + let roots: Vec = DEFAULT_CONTENT_ROOTS + .iter() + .map(|entry| (*entry).to_string()) + .collect(); + let (files, skipped) = scan_instance(root, &roots); + + let mut rows: Vec = files + .iter() + .map(|file| ExportFileView { + path: file.path.clone(), + class: ExportClassView::Inline, + reason: ExportReasonView::NoRemoteMatch, + bytes: file.bytes, + disabled: file.disabled, + }) + .collect(); + rows.extend(skipped.iter().map(|file| ExportFileView { + path: file.path.clone(), + class: class_view(file.reason.class()), + reason: reason_view(file.reason), + bytes: 0, + disabled: false, + })); + // 与 `ExportReport::files` 同一条顺序(按路径) + rows.sort_by(|left, right| left.path.cmp(&right.path)); + + ExportPreviewView { + format: request.format, + instance: request.instance.clone(), + name: request.name.clone(), + version: request.version.clone(), + target, + files: rows, + notes: vec![ + "No remote catalogue yet: every file has to be inlined into overrides/".to_string(), + "Unreadable files, symlinks and unsafe paths are listed below, one by one".to_string(), + ], + } +} + +/// 起一个后台写盘线程。 +pub(super) fn spawn_write(env: HostEnv, tx: JobSender, request: ExportRequestView, epoch: u64) { + spawn("pack-export", move || { + let root = match super::instance_root(&env, &request.instance) { + Ok(root) => root, + Err(reason) => { + send(&tx, epoch, PackEvent::ExportFailed(reason)); + return; + } + }; + let format = match super::format_of(request.format) { + Ok(format) => format, + Err(reason) => { + send(&tx, epoch, PackEvent::ExportFailed(reason)); + return; + } + }; + let target = request + .target + .clone() + .unwrap_or_else(|| super::default_export_path(&env, &request)); + let snapshot = super::snapshot_of(&env, &request.instance); + let mut core_request = ExportRequest::new(&root, &target, format) + .with_name(request.name.clone()) + .with_version(request.version.clone()) + .with_catalog(&OfflineCatalog); + if let Some(snapshot) = &snapshot { + core_request = core_request.with_snapshot(snapshot); + } + match export_instance(&core_request) { + Ok(report) => send( + &tx, + epoch, + PackEvent::Exported(Box::new(done_view(&report))), + ), + Err(error) => send(&tx, epoch, PackEvent::ExportFailed(format!("{error}"))), + } + }); +} diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs new file mode 100644 index 0000000..6da93b8 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -0,0 +1,140 @@ +//! 后台导入(M113):探测 → 目标目录 → core 的 `import_pack` → 报告。 +//! +//! 三条纪律: +//! +//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件; +//! 2. **默认档永远是 `KeepUser`**:只有 `request.overwrite == true` 才换成 `Overwrite`; +//! 3. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。 + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use unlucky_core::addon::import::{ + import_pack, ConflictPolicy, DownloadEngineFetcher, ImportProgress, ImportProgressSink, + ImportRequest, +}; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{ImportRequestView, PackEvent}; + +use super::super::super::env::HostEnv; +use super::super::super::job::{spawn, JobSender}; +use super::convert; +use super::send; + +/// 起一个后台导入线程。 +pub(super) fn spawn_import( + env: HostEnv, + tx: JobSender, + request: ImportRequestView, + epoch: u64, + cancel: Arc, +) { + spawn("pack-import", move || { + run(&env, &tx, &request, epoch, cancel) + }); +} + +pub(super) fn run( + env: &HostEnv, + tx: &JobSender, + request: &ImportRequestView, + epoch: u64, + cancel: Arc, +) { + // 探测先过一遍:失败就只给具名诊断(「看起来像 X,但缺 Y」),不空跑一遍管线 + if let Some(probe) = super::diagnose::of(&request.pack) { + send(tx, epoch, PackEvent::Probe(Box::new(probe))); + return; + } + let (target, register) = match target_of(env, request) { + Ok(target) => target, + Err(reason) => { + send( + tx, + epoch, + PackEvent::Report(Box::new(convert::failed_report( + request.pack.clone(), + "import_host", + "Target instance is not usable", + &reason, + ))), + ); + return; + } + }; + + let pack_name = request + .instance_name + .clone() + .unwrap_or_else(|| convert::pack_stem(&request.pack)); + let progress_tx = tx.clone(); + let progress_name = pack_name.clone(); + let cancel_flag = Arc::clone(&cancel); + let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| { + if cancel_flag.load(Ordering::SeqCst) { + return; + } + send( + &progress_tx, + epoch, + PackEvent::Progress(Some(Box::new(convert::progress_view( + progress, + &progress_name, + )))), + ); + }); + + let engine = super::super::super::engine::engine(env); + let fetcher = DownloadEngineFetcher::new(&engine); + let mut core_request = ImportRequest::new(&request.pack, &target) + .with_fetcher(&fetcher) + .with_progress(sink); + if request.overwrite { + core_request = core_request.with_conflict_policy(ConflictPolicy::Overwrite); + } + if register { + core_request = core_request.with_registration(&env.layout, Some(&pack_name)); + } + + let report = block_on(import_pack(&core_request)); + if cancel.load(Ordering::SeqCst) { + // 取消之后不再打扰界面(面板已经收回,代次也已经作废) + return; + } + send( + tx, + epoch, + PackEvent::Report(Box::new(convert::report_view(&report, &target))), + ); +} + +/// 导入目标:`(目录, 是否登记新实例)`。 +/// +/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录); +/// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 +/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。 +fn target_of(env: &HostEnv, request: &ImportRequestView) -> Result<(PathBuf, bool), String> { + match &request.instance { + Some(id) => super::instance_root(env, id).map(|root| (root, false)), + None => { + let name = request + .instance_name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| convert::pack_stem(&request.pack)); + let target = env + .layout + .data() + .join("instances") + .join(convert::slug(&name)); + if target.exists() { + return Err(format!("directory already exists: {}", target.display())); + } + std::fs::create_dir_all(&target) + .map_err(|error| format!("create dir failed: {error}"))?; + Ok((target, true)) + } + } +} diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs new file mode 100644 index 0000000..289bdb9 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -0,0 +1,241 @@ +//! M113 的两条硬证据(都跑真管线,不用假数据): +//! +//! 1. [`bad_packs_get_a_named_diagnosis`]:**坏包必须有具名失败**,不是静默、不是 panic; +//! 2. [`preview_counts_match_the_written_report`]:预检的「将内联 N / 无法导出 M」与最终 +//! `ExportReport` 的数字**逐项相等**(塔台点名的要求:预检不能是骗人的预告)。 + +use std::path::PathBuf; + +use unlucky_core::addon::export::{ + export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog, + DEFAULT_CONTENT_ROOTS, +}; + +use crate::dto::{ExportClassView, ExportRequestView, PackFormatView}; + +/// 每个用例一个独立临时目录(用完删)。 +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +fn write(path: &std::path::Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("parent"); + } + std::fs::write(path, bytes).expect("write"); +} + +#[test] +fn bad_packs_get_a_named_diagnosis() { + let dir = temp_dir("badpack"); + + // ① 把文本文件改名成 .mrpack:连 zip 都不是 + let fake = dir.join("fake.mrpack"); + write(&fake, b"this is not a zip"); + let probe = super::diagnose::of(&fake).expect("打不开的包必须有诊断"); + assert!( + !probe.detail.is_empty(), + "诊断要给一句具体原因(不是空字符串)" + ); + assert!( + probe.detail.to_lowercase().contains("archive") || probe.detail.contains("zip"), + "诊断要说清是哪一步失败:{}", + probe.detail + ); + + // ② 目录形态:有 overrides/、没有 manifest.json ⇒ 「像 CF,缺 manifest.json」 + let dir_pack = dir.join("half"); + write(&dir_pack.join("overrides").join("options.txt"), b"fov=90\n"); + if let Some(probe) = super::diagnose::of(&dir_pack) { + assert!( + !probe.saw.is_empty(), + "看到了 overrides/ 就该说出来(saw 不能为空)" + ); + if probe.looks_like.is_some() { + assert!( + !probe.missing.is_empty(), + "说「像某个格式」就必须说出缺哪个标记文件" + ); + } + } +} + +#[test] +fn preview_counts_match_the_written_report() { + let dir = temp_dir("export-eq"); + let root = dir.join("instance"); + write(&root.join("mods").join("sodium.jar"), &[0x11; 4096]); + write(&root.join("mods").join("lithium.jar"), &[0x22; 2048]); + write(&root.join("mods").join("old.jar.disabled"), &[0x33; 512]); + write(&root.join("config").join("options.txt"), b"fov=90\n"); + // 白名单之外的目录不该被扫(否则「将内联 N」会虚高) + write(&root.join("logs").join("latest.log"), b"noise\n"); + + let request = ExportRequestView { + instance: "survival".to_string(), + format: PackFormatView::Modrinth, + name: "Survival".to_string(), + version: "1.0.0".to_string(), + target: Some(dir.join("out.mrpack")), + }; + let env_root = dir.join("data"); + std::fs::create_dir_all(&env_root).expect("env"); + let env = crate::host::HostEnv::at_root(&env_root); + + // 预检(不写盘) + let preview = super::export::preview_of(&root, &env, &request); + + // 真写盘:用同一套判据(离线目录 + 默认内容目录 + 直接给的快照) + let snapshot = InstanceSnapshot { + game_version: "1.20.1".to_string(), + loaders: Vec::new(), + }; + let target = dir.join("out.mrpack"); + let core_request = ExportRequest::new( + &root, + &target, + &unlucky_core::addon::pack::modrinth::MODRINTH, + ) + .with_name("Survival") + .with_version("1.0.0") + .with_catalog(&OfflineCatalog) + .with_snapshot(&snapshot); + let report = export_instance(&core_request).expect("导出应当成功"); + + // 三个计数逐项相等(塔台要求:不是「大致对」) + assert_eq!( + preview.inline_count(), + report.inline_count(), + "内联条数不一致:预检 {} vs 报告 {}", + preview.inline_count(), + report.inline_count() + ); + assert_eq!( + preview.skipped_count(), + report.skipped_count(), + "无法导出条数不一致" + ); + assert_eq!( + preview.remote_count(), + report.remote_count(), + "远程引用条数不一致" + ); + + // 逐条:同一个路径在两边必须是同一个类 + for file in &preview.files { + let written = report + .files + .iter() + .find(|candidate| candidate.path == file.path) + .unwrap_or_else(|| panic!("预检多出一条:{}", file.path)); + let expected = match file.class { + ExportClassView::Remote => unlucky_core::addon::export::FileClass::Remote, + ExportClassView::Inline => unlucky_core::addon::export::FileClass::Inline, + ExportClassView::Skipped => unlucky_core::addon::export::FileClass::Skipped, + }; + assert_eq!(written.class, expected, "{} 的归类不一致", file.path); + } + + // 白名单外的 logs/ 不许进包(防「扫太宽」把预检数字弄虚) + assert!( + !report + .files + .iter() + .any(|file| file.path.starts_with("logs/")), + "logs/ 不该被扫进整合包" + ); + + // 同时钉一条:`scan_instance` 的口径就是预览用的那一套 + let roots: Vec = DEFAULT_CONTENT_ROOTS + .iter() + .map(|entry| (*entry).to_string()) + .collect(); + let (files, skipped) = scan_instance(&root, &roots); + assert_eq!(files.len(), preview.inline_count()); + assert_eq!(skipped.len(), preview.skipped_count()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_real_pack_runs_through_the_pipeline_end_to_end() { + // 真管线端到端:造一个 MultiMC 实例目录当「包」(目录形态不需要 zip 写入器), + // 走 `import::run` 的**真**路径,看事件流里有没有「进度 + 结果」、文件有没有落地。 + let dir = temp_dir("import-e2e"); + let source = dir.join("Better MC"); + write( + &source.join("mmc-pack.json"), + br#"{"formatVersion": 1, "components": [ + {"uid": "net.minecraft", "version": "1.20.1"}, + {"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#, + ); + write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]); + write(&source.join(".minecraft/config/options.txt"), b"fov=90\n"); + + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + let env = crate::host::HostEnv::at_root(&data); + + let (tx, rx) = crate::host::job::channel(); + let request = crate::dto::ImportRequestView { + pack: source, + instance: None, + instance_name: Some("Imported Pack".to_string()), + overwrite: false, + }; + super::import::run( + &env, + &tx, + &request, + 7, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ); + drop(tx); + + let mut progress = 0usize; + let mut report = None; + for task in rx.iter() { + match task { + crate::host::job::HostTask::Pack { event, epoch } => { + assert_eq!(epoch, 7, "代次必须原样带回(取消判定靠它)"); + match *event { + crate::dto::PackEvent::Progress(Some(_)) => progress += 1, + crate::dto::PackEvent::Report(view) => report = Some(*view), + other => panic!("意料之外的事件:{other:?}"), + } + } + other => panic!("意料之外的任务:{other:?}"), + } + } + + assert!(progress > 0, "至少要有一条进度事件"); + let report = report.expect("必须有结果事件"); + assert_ne!( + report.status, + crate::dto::ImportStatusView::Failed, + "正常包不该失败:{:?}", + report.error + ); + assert_eq!( + report.format, + Some(crate::dto::PackFormatView::Multimc), + "应当认成 MultiMC 格式" + ); + assert!( + report.written() > 0, + "报告里要至少有写盘的文件:{:?}", + report.files + ); + // 落盘位置以**报告里的 target** 为准(那是 host 真算出来的目录,界面显示的也是它) + let landed = &report.target; + assert!( + landed.join("mods/sodium.jar").is_file(), + "文件没有落到实例目录:{}", + landed.display() + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 09af427..c20b58e 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -70,6 +70,8 @@ impl CoreHost { if let Some(cancel) = &self.install_cancel { cancel.store(true, std::sync::atomic::Ordering::SeqCst); } + // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 代次 +1 + addon::cancel_pack(); self.install_epoch = self.install_epoch.wrapping_add(1); self.install = None; self.send(CoreEvent::InstallProgress(None)); @@ -176,6 +178,8 @@ impl CoreHost { } // M109:把某个内容项目装进指定实例。取消标记与安装面板共用同一条路 // (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。 + // M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板 + AppCommand::Pack(command) => self.pack(*command), AppCommand::InstallContent(request) => { let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); self.install_cancel = Some(std::sync::Arc::clone(&cancel)); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 5ac4743..4bf68f3 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -40,6 +40,13 @@ impl CoreHost { Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))), Err(reason) => self.send(CoreEvent::ContentFailed(reason)), }, + // M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉 + HostTask::Pack { event, epoch } => { + if epoch != self.install_epoch { + return; + } + self.send(CoreEvent::Pack(event)); + } // M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。 // 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回, // 旧任务的下一条「下载中」事件立刻又把它点亮。 diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 09a3399..12046f6 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -9,7 +9,7 @@ use std::sync::mpsc::{Receiver, Sender}; use crate::dto::{ AccountView, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LoaderKind, LogLine, LogOutcome, - LogSession, NoticeView, SessionId, VersionSummary, + LogSession, NoticeView, PackEvent, SessionId, VersionSummary, }; use crate::toast::Toast; @@ -29,6 +29,17 @@ pub enum HostTask { }, /// 内容库检索(M109;`Err` = 一句人读原因) Content(Box>), + // M113: import/export + /// 导入/导出(M113):一条事件 + 任务代次 + /// + /// 代次与 [`HostTask::Install`] 共用同一计数器(`CoreHost::install_epoch`):取消之后回来的 + /// **旧**事件按代次丢掉,面板不会被重新点亮。 + Pack { + /// 事件载荷(进度 / 结果 / 探测诊断 / 预检 / 完成) + event: Box, + /// 任务代次 + epoch: u64, + }, /// 安装进度(高频,host 只做聚合与转发) Install { /// 进度快照 diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index 7b33188..ae42cff 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -8,7 +8,7 @@ use crate::dialogs::DialogQueue; use crate::dto::{ AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine, - LogSession, NoticeView, Progress, SettingGroupView, VersionSummary, + LogSession, NoticeView, PackState, Progress, SettingGroupView, VersionSummary, }; use crate::prefs::UiPrefs; // 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。 @@ -110,6 +110,8 @@ pub struct AppState { pub live_session: Option, /// 内容库(M109):条件 / 结果 / 请求态 pub content: ContentState, + /// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113) + pub pack: PackState, } impl Default for AppState { @@ -148,6 +150,7 @@ impl AppState { wizard: WizardState::new(), live_session: None, content: ContentState::default(), + pack: PackState::default(), } } @@ -271,6 +274,8 @@ impl AppState { .insert(mc_version, result); } CoreEvent::NoticesChanged(notices) => self.notices = notices, + // M113: import/export + CoreEvent::Pack(event) => self.pack.apply(*event), CoreEvent::ContentResults(results) => self.apply_content(*results), CoreEvent::ContentFailed(reason) => { self.content.pending = false; From 27bbc74eaa5bacbb4ac406e5e82ce189b57f2e2e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 00:57:58 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(packui):=20M113=20=E6=94=B6=E5=8F=A3=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AF=BC=E5=87=BA=E7=9A=84=E3=80=8CSave?= =?UTF-8?q?=20to=E3=80=8D=E5=AD=97=E6=AE=B5=E5=BD=A2=E7=8A=B6=E4=B8=8E?= =?UTF-8?q?=E3=80=8C=E6=B2=A1=E8=BF=9B=E5=8C=85=E3=80=8D=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=89=BF=E8=AF=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审看图挑出的两处,只改这两处,其余不动。 1. **Save to 字段**:原来是 `label("Save to (empty = suggested)")` + 空值,看起来像「这句提示就是字段值」, 与上面 Pack name / Version 的「标签在上、值在下」不一致。改成短标签 `Save to` + 占位 `empty = suggested path`(新增 `PackExportOutputPlaceholder` 一个键)。 2. **`Not in the pack (listed one by one)` 与 `...and 6 more files` 自相矛盾**:13 条没进包的其实全列了, 但紧随其后的「还有 6 个」说的是**内联样例**的截断,两句挨着会被读成一件事。 改法:「没进包」标题带真实计数(`Not in the pack: {count}`),内联样例加自己的标题 `Copied into overrides/ (first {shown}):`,截断那句改成 `…and {count} more files (not shown here)` —— 谁被截断、截了几条,写在它自己的那一块里。 `tolerable_title` 因此改成收 `String`(计数是运行时 format 出来的),`golden.rs` 同步三个键。 门禁:`cargo fmt --all --check` 通过;`cargo test -p unlucky-app-iced --lib -j 2` 504 passed / 0 failed(TEST_EXIT=0);`cargo check -p unlucky-app-iced --all-targets` 零 warning。 出图:`04-export-light.png` / `04-export-dark.png` 已重出(其余 8 张未受影响、未改动)。 --- .../src/pages/content/packui/view/export.rs | 26 ++++++++++++++++--- .../src/pages/content/packui/view/parts.rs | 13 +++++----- .../src/pages/content/packui/view/report.rs | 4 +-- crates/i18n/src/catalog/content.rs | 19 +++++++++++--- crates/i18n/src/catalog/golden.rs | 8 +++--- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs index 82c92f4..5906da0 100644 --- a/crates/app-iced/src/pages/content/packui/view/export.rs +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -101,9 +101,12 @@ pub(super) fn export_block<'a>( Field::new(export.version.as_str()).label(catalog.text(Key::PackExportVersion)), move |text| intent(PackMessage::SetExportVersion(text)), )); + // 标签在上、占位在下:**别让占位文字看起来像已填的值**(复审提过这张图的观感) block = block.push(text_field( ui, - Field::new(export.output.as_str()).label(catalog.text(Key::PackExportOutput)), + Field::new(export.output.as_str()) + .label(catalog.text(Key::PackExportOutput)) + .placeholder(catalog.text(Key::PackExportOutputPlaceholder)), move |text| intent(PackMessage::SetOutput(text)), )); block = block.push( @@ -224,10 +227,14 @@ pub(super) fn preview_block<'a>( .width(Length::Fill); if !preview.skipped.is_empty() { + // 标题写成「Not in the pack: 13」而不是「逐条列出」:13 条**确实**全列出来了, + // 但下面那个「…还有 N 个(这里不展开)」说的是**内联样例**,两句挨着容易被读成矛盾 block = block.push(tolerable_title( ui, - catalog, - catalog.text(Key::PackPreviewSkippedTitle), + catalog.render( + Key::PackPreviewSkippedTitle, + &[("count", &preview.skipped.len().to_string())], + ), )); block = block.push(rows_block(ui, catalog, &preview.skipped)); } @@ -235,6 +242,19 @@ pub(super) fn preview_block<'a>( // 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把 // 「确认写盘」顶出屏幕(出图时实测到了) let mut extras = column![].spacing(ui.space(Space::X1)).width(Length::Fill); + if !preview.inline_sample.is_empty() { + // 给样例一句自己的标题 + 说明只列前几条(截断要说出来) + extras = extras.push(frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + catalog.render( + Key::PackPreviewInlineTitle, + &[("shown", &preview.inline_sample.len().to_string())], + ), + ui.color(Role::OnSurfaceVariant), + )); + } for path in &preview.inline_sample { extras = extras.push(frame::text_role( ui, diff --git a/crates/app-iced/src/pages/content/packui/view/parts.rs b/crates/app-iced/src/pages/content/packui/view/parts.rs index c3c6e23..a0e5156 100644 --- a/crates/app-iced/src/pages/content/packui/view/parts.rs +++ b/crates/app-iced/src/pages/content/packui/view/parts.rs @@ -41,15 +41,14 @@ pub(super) fn marker_box<'a>( .into() } -pub(super) fn tolerable_title<'a>( - ui: &'a Ui, - catalog: Catalog, - title: &'a str, -) -> Element<'a, PageMessage> { - let _ = catalog; +/// 容忍域的标题(`Warn` 图标 + 三级色)。 +/// +/// 收 `String` 而不是 `&str`:标题要带计数(`Not in the pack: 13`),而计数是运行时 `format!` +/// 出来的,借用不出去。 +pub(super) fn tolerable_title(ui: &Ui, title: String) -> Element<'_, PageMessage> { row![ frame::glyph(IconKind::Warn, ui.color(Role::Tertiary), frame::ICON_PX), - frame::text_role( + frame::text_role_owned( ui, TypeRole::LabelLarge, true, diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index fb09ff3..5615444 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -118,7 +118,7 @@ pub(super) fn report_block<'a>( if let Some(title) = &report.missing_title { block = block.push( column![ - tolerable_title(ui, catalog, title.as_str()), + tolerable_title(ui, title.clone()), bounded( ui, rows_block(ui, catalog, &report.missing), @@ -138,7 +138,7 @@ pub(super) fn report_block<'a>( if let Some(title) = &report.conflicts_title { block = block.push( column![ - tolerable_title(ui, catalog, title.as_str()), + tolerable_title(ui, title.clone()), bounded( ui, column( diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 7d13e0a..7711093 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -173,7 +173,11 @@ macro_rules! content_entries { PackExportFormat => ("Format", "格式"), PackExportName => ("Pack name", "包名"), PackExportVersion => ("Version", "版本"), - PackExportOutput => ("Save to (empty = suggested)", "保存到(留空 = 自动)"), + PackExportOutput => ("Save to", "保存到"), + PackExportOutputPlaceholder => ( + "empty = suggested path", + "留空 = 自动给一个路径" + ), PackExportPrecheck => ("Dry run", "预检"), PackExportConfirm => ("Write the pack", "确认写盘"), PackExportAbort => ("Back", "返回"), @@ -196,13 +200,20 @@ macro_rules! content_entries { "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/" ), + PackPreviewInlineTitle => ( + "Copied into overrides/ (first {shown}):", + "原样拷进 overrides/(只列前 {shown} 个):" + ), PackPreviewSkippedTitle => ( - "Not in the pack (listed one by one)", - "没进包(逐条列出)" + "Not in the pack: {count}", + "没进包:{count} 项" ), PackPreviewNotes => ("Known losses", "已知损失"), PackPreviewTarget => ("Will be written to {path}", "将写到 {path}"), - PackPreviewMore => ("…and {count} more files", "…还有 {count} 个文件"), + PackPreviewMore => ( + "…and {count} more files (not shown here)", + "…还有 {count} 个文件(这里不展开)" + ), PackPreviewComplete => ("Nothing is left out", "没有遗漏任何文件"), PackPreviewIncomplete => ( "Some files will not be in the pack", diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 992a40c..8db20a0 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -272,7 +272,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackExportFormat", "Format", "格式"), ("PackExportName", "Pack name", "包名"), ("PackExportVersion", "Version", "版本"), - ("PackExportOutput", "Save to (empty = suggested)", "保存到(留空 = 自动)"), + ("PackExportOutput", "Save to", "保存到"), + ("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"), ("PackExportPrecheck", "Dry run", "预检"), ("PackExportConfirm", "Write the pack", "确认写盘"), ("PackExportAbort", "Back", "返回"), @@ -283,10 +284,11 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), - ("PackPreviewSkippedTitle", "Not in the pack (listed one by one)", "没进包(逐条列出)"), + ("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"), + ("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"), ("PackPreviewNotes", "Known losses", "已知损失"), ("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"), - ("PackPreviewMore", "…and {count} more files", "…还有 {count} 个文件"), + ("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"), ("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"), ("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"), ("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"), From 99538e45b9fa67518a1da5c605ae5d326c94660d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 01:45:49 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(packui):=20M113=20r1=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E6=95=B4=E6=94=B9=20=E2=80=94=E2=80=94=202=20?= =?UTF-8?q?=E5=A4=84=E3=80=8C=E7=95=8C=E9=9D=A2=E8=AF=B4=E5=81=87=E8=AF=9D?= =?UTF-8?q?=E3=80=8D+=207=20=E6=9D=A1=E5=BF=85=E4=BF=AE=20P2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r1(p1-2items / fix-then-merge)逐条整改。**P2-6(core 的本地化契约)按塔台指示本轮不动**, 已登记为独立 mission 的债。 ## P1 1. **「Save to」是死输入框**:`ExportRequestView` 的唯一构造点硬编码 `target: None`,用户敲的路径 从不进请求。现在构造收成纯函数 `packui::model::export::request_of`(预检与确认共用同一份), `target` 由字段去空白后给出(空 ⇒ `None`,交给 host 给缺省)。 2. **取消导入的 toast 撒谎**:下载页那条说「安装已取消、实例未创建」,而导入会在后台跑完并 **登记实例**。现在 `cancel_pack()` 回报「刚才有没有导入在跑」,`dispatch` 据此改发 `PackEvent::Cancelled`(不再发 `InstallCancelled`),界面与提示改成 「导入会在后台继续跑完 —— 本次结果作废;已写下的文件会留下」,并把这句话**留在屏上** (`ImportState.cancelled` + 面板位)。 ## P2 必修(7 条) 3. **`Dismiss` 误伤下载页安装**(跨功能污染,最高优先):pack 原来共用 `CoreHost::install_epoch`, 关一次导入面板会把下载页正在跑的安装静默丢掉。现在 pack 有自己的 `PACK_EPOCH`(模块级 `AtomicU64`),`handle` 的 pack 分支只比它,`Dismiss`/取消只动它。 4. **中文实例名塌成 `pack`**:新增 `convert::dir_name` —— 名字全 ASCII 安全字符时保持原样 (`Better MC` → `Better-MC`),一旦折过东西(中文/空格/符号)就补 `-`(FNV-1a), 保证两个中文名得到不同目录。 5. **失败回滚后空目录挡住重试**:`target_of` 对「已存在但空」的目录**复用**(非空才拒), 并在失败回滚后清掉自己建的空目录;`failed_report` 的 `target` 语义改正 (以前把包路径塞进「目标实例目录」)。 6. **新实例流程下「以包为准」必然失败**:`can_overwrite` 追加 `page.instance.is_some()` —— 新建实例那条路上不再显示一个必然失败的按钮。 7. **阶段词打架且文档说修好了**:`progress.rs` 的模块文档改成与屏幕一致(粗/细两层 + 已知重叠), 并写清彻底解决要靠「安装面板提成共享组件」那条 finding。 8. **导入侧标题计数与行数对不上**:被拒 component 有了自己的标题(`The client does not support these: {count}`);冲突标题改成中性的 `Conflicts: {count}`(逐条自带成因与处置)。 9. **预检「没进包」列表没有有界滚动**:13 条是真实形态。列表改成有界滚动(110px), 并且**把动作按钮提到摘要下面**(`Write the pack` / `Back`)—— 明细再长也顶不掉按钮。 10. **导入成功后不刷新实例列表**:`handle` 的 pack 分支在收到 `Report` 时调 `refresh_instances()` (与 `InstallDone` 同一条),新实例不用等下一次无关的 `RefreshAll`。 ## 证据 - 新增测试(都跑真管线/真函数):`the_save_to_field_really_reaches_the_request`、 `pack_epoch_is_its_own_counter`、`chinese_instance_names_get_distinct_directories`、 `a_failed_new_instance_directory_can_be_reused_for_retry`、 `overwrite_is_offered_only_when_importing_into_an_existing_instance`、 `cancelled_state_is_visible_and_says_the_import_keeps_running`。 - 出图重出 12 张(6 态 × 浅深),新增 `06-cancelled-*`;`04-export-*` 用 13 条 skipped 的真实形态。 门禁:`cargo fmt --all --check` 通过;`cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-i18n --all-targets -- -D warnings` EXIT=0;`cargo test -p unlucky-app-iced --lib` 507 passed;`cargo test -p unlucky-presenter --lib` 184 passed;`cargo test -p unlucky-i18n --lib` 24 passed;全部改动文件 ≤400 行。 --- crates/app-iced/src/app/update/content.rs | 25 ++--- crates/app-iced/src/pages/content.rs | 2 +- crates/app-iced/src/pages/content/packui.rs | 2 +- .../app-iced/src/pages/content/packui/demo.rs | 16 ++- .../src/pages/content/packui/model.rs | 10 +- .../src/pages/content/packui/model/export.rs | 30 +++++- .../pages/content/packui/model/progress.rs | 15 +-- .../src/pages/content/packui/model/report.rs | 15 ++- .../src/pages/content/packui/model/tests.rs | 102 +++++++++++++++++- .../app-iced/src/pages/content/packui/view.rs | 19 ++++ .../src/pages/content/packui/view/export.rs | 42 ++++++-- .../src/pages/content/packui/view/report.rs | 13 ++- crates/i18n/src/catalog/content.rs | 12 ++- crates/i18n/src/catalog/golden.rs | 4 +- crates/presenter/src/dto/pack/event.rs | 7 ++ crates/presenter/src/dto/pack/import.rs | 3 + crates/presenter/src/host/addon.rs | 2 +- crates/presenter/src/host/addon/pack.rs | 50 +++++++-- .../presenter/src/host/addon/pack/convert.rs | 37 ++++++- .../presenter/src/host/addon/pack/import.rs | 47 ++++++-- crates/presenter/src/host/addon/pack/tests.rs | 66 ++++++++++++ crates/presenter/src/host/dispatch.rs | 16 ++- crates/presenter/src/host/handle.rs | 13 ++- 23 files changed, 474 insertions(+), 74 deletions(-) diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs index 26609b9..230f229 100644 --- a/crates/app-iced/src/app/update/content.rs +++ b/crates/app-iced/src/app/update/content.rs @@ -244,27 +244,14 @@ impl App { /// 组一份导出请求(预检与写盘**共用同一份**,确认时原样发回)。 /// - /// 没有选中实例就没有导出对象 ⇒ `None`。包名/版本留空时给缺省值: - /// 包名 = 实例 id、版本 = 页面上的 `1.0.0`。 + /// 没有选中实例就没有导出对象 ⇒ `None`。**「Save to」必须真的进请求** + /// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。 fn pack_export_request(&self) -> Option { let instance = self.content.pack.instance.clone()?; - let name = self.content.pack.export_name.trim(); - let version = self.content.pack.export_version.trim(); - Some(ExportRequestView { - instance: instance.clone(), - format: self.content.pack.format, - name: if name.is_empty() { - instance - } else { - name.to_string() - }, - version: if version.is_empty() { - "1.0.0".to_string() - } else { - version.to_string() - }, - target: None, - }) + Some(pages::content::request_export( + &self.content.pack, + &instance, + )) } /// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。 diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index 71e4c80..736ee55 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -63,7 +63,7 @@ mod tests; pub use message::Message; pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; // M113: import/export -pub use packui::{Pack, PackMessage, PackPageState}; +pub use packui::{request_export, Pack, PackMessage, PackPageState}; use iced::Element; diff --git a/crates/app-iced/src/pages/content/packui.rs b/crates/app-iced/src/pages/content/packui.rs index 5dcb9e0..c734909 100644 --- a/crates/app-iced/src/pages/content/packui.rs +++ b/crates/app-iced/src/pages/content/packui.rs @@ -46,7 +46,7 @@ mod view; mod tests; pub use message::Message as PackMessage; -pub use model::{Pack, PackPageState}; +pub use model::{request_export, Pack, PackPageState}; /// 区入口(内容库页的正列调它)。 pub(crate) use view::section; diff --git a/crates/app-iced/src/pages/content/packui/demo.rs b/crates/app-iced/src/pages/content/packui/demo.rs index 0a233f8..47ad0ea 100644 --- a/crates/app-iced/src/pages/content/packui/demo.rs +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -86,6 +86,8 @@ pub(super) fn pack_in(state: &str, lang: Lang) -> (Pack, PackPageState) { fatal.bytes_written = 0; import.report = Some(fatal); } + // 取消态:面板收回了,但「后台还在跑」这句话要留在屏上 + "cancelled" => import.cancelled = true, "export" => { page.export_open = true; page.export_name = "Survival 1.20.1".to_string(); @@ -210,10 +212,20 @@ fn preview() -> ExportPreviewView { disabled: false, }); } + // 13 条「没进包」是真实形态:预检那块必须自己做有界滚动,否则把「确认写盘」顶出屏幕 + for index in 0..12 { + files.push(ExportFileView { + path: format!("mods/.cache/backup-{index:02}.jar"), + class: ExportClassView::Skipped, + reason: ExportReasonView::SymbolicLink, + bytes: 0, + disabled: false, + }); + } files.push(ExportFileView { - path: "mods/.cache/backup.jar".to_string(), + path: "mods/bad:name.jar".to_string(), class: ExportClassView::Skipped, - reason: ExportReasonView::SymbolicLink, + reason: ExportReasonView::UnsafePath, bytes: 0, disabled: false, }); diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index 8e1e4cb..46bfa9d 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -29,7 +29,7 @@ mod export; mod progress; mod report; -pub use export::{format_label, Export, Preview}; +pub use export::{format_label, request_of as request_export, Export, Preview}; pub use progress::Running; pub use report::{ConflictRow, Report, Row}; @@ -125,6 +125,11 @@ pub struct Pack { pub running: Option, /// 探测失败诊断 pub probe: Option, + /// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌), + /// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。 + pub cancelled: bool, + /// 取消提示的正文 + pub cancelled_note: &'static str, /// 导入结果 pub report: Option, /// 导出块 @@ -170,6 +175,8 @@ impl Pack { .as_ref() .map(|progress| progress::running_of(progress, catalog)), probe: import.probe.as_ref().map(|probe| probe_of(probe, catalog)), + cancelled: import.cancelled, + cancelled_note: catalog.text(Key::PackCancelled), report: import .report .as_ref() @@ -183,6 +190,7 @@ impl Pack { self.running.is_none() && self.probe.is_none() && self.report.is_none() + && !self.cancelled && !self.export.open && self.draft.is_empty() } diff --git a/crates/app-iced/src/pages/content/packui/model/export.rs b/crates/app-iced/src/pages/content/packui/model/export.rs index 0131db3..d4e3f37 100644 --- a/crates/app-iced/src/pages/content/packui/model/export.rs +++ b/crates/app-iced/src/pages/content/packui/model/export.rs @@ -18,12 +18,38 @@ use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{ - human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportState, - PackFormatView, + human_bytes, ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, + ExportRequestView, ExportState, PackFormatView, }; use super::{PackPageState, PackTarget, Row}; +/// 页内选择 → 导出请求(预检与写盘**共用同一份**)。 +/// +/// `target` 必须从这里进去(复审 r1 P1-1):之前全仓唯一构造点硬编码 `target: None`, +/// 于是用户敲的「Save to」永远不进请求 —— 输入框是死的。 +#[must_use] +pub fn request_of(page: &PackPageState, instance: &str) -> ExportRequestView { + let name = page.export_name.trim(); + let version = page.export_version.trim(); + let output = page.output.trim(); + ExportRequestView { + instance: instance.to_string(), + format: page.format, + name: if name.is_empty() { + instance.to_string() + } else { + name.to_string() + }, + version: if version.is_empty() { + "1.0.0".to_string() + } else { + version.to_string() + }, + target: (!output.is_empty()).then(|| std::path::PathBuf::from(output)), + } +} + /// 预检里「内联文件样例」最多列几条(其余用一句「还有 N 个」收口)。 const INLINE_SAMPLE: usize = 6; diff --git a/crates/app-iced/src/pages/content/packui/model/progress.rs b/crates/app-iced/src/pages/content/packui/model/progress.rs index d319bce..646056f 100644 --- a/crates/app-iced/src/pages/content/packui/model/progress.rs +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -8,13 +8,16 @@ //! `InstallProgressView` → [`InstallView`]); //! 2. 画法交给 `pages::download::install_panel`(同一个函数、同一套几何,进度条 / 计数 / //! 取消按钮都归它); -//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载, -//! 面板右边那个阶段词**留空**。 +//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载。 //! -//! 第 3 点的理由:面板自带的四个阶段词是**安装**语义(规划 / 下载 / 生成版本 / 登记实例), -//! 套到导入上会出现「Step 2/5 · Unpacking overrides」旁边写着「Building the version」—— -//! 同一块地方两种说法(出图时实测到了)。留空 + 由步骤行说真话,是**不动那个组件**的最小解; -//! 多出来的只有一句文字,没有第二条进度条。 +//! **已知重叠(如实写,别再说成修好了)**:面板自带的阶段词是**安装**语义 +//! (`Resolving version` / `Downloading files` / `Building the version` / `Registering instance`), +//! 而 `install_panel_model` 永远会填它(M109 合流 M105 后 `InstallView` 不再对外导出, +//! 没法从外面塞一个自定义标签或清空)。所以屏幕上同时出现「Step 2/5 · Unpacking overrides」 +//! 与「Building the version」:**粗/细两层,不是两句矛盾的话**,但 `Extract`/`Commit` 都映射到 +//! `Installing`,那一档的用词偏「安装」。 +//! +//! 要彻底解决得让面板接受调用方给的阶段词(已报 finding:把安装面板提成共享组件)。 use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::dto::{ diff --git a/crates/app-iced/src/pages/content/packui/model/report.rs b/crates/app-iced/src/pages/content/packui/model/report.rs index 493fec2..c056317 100644 --- a/crates/app-iced/src/pages/content/packui/model/report.rs +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -84,6 +84,8 @@ pub struct Report { pub conflicts_title: Option, /// 冲突逐条 pub conflicts: Vec, + /// 被拒 component 的标题(带计数,与下面逐条行数一致) + pub rejected_title: Option, /// 被拒 component 逐条 pub rejected: Vec, /// 注记逐条 @@ -144,6 +146,12 @@ pub(super) fn report_of( .iter() .map(|file| conflict_row(file, page.expanded.as_deref(), catalog)) .collect(), + rejected_title: (!report.rejected.is_empty()).then(|| { + catalog.render( + Key::PackReportRejected, + &[("count", &report.rejected.len().to_string())], + ) + }), rejected: report .rejected .iter() @@ -153,7 +161,12 @@ pub(super) fn report_of( }) .collect(), warnings: report.warnings.clone(), - can_overwrite: !report.conflicts.is_empty() && report.status == ImportStatusView::Partial, + // 「以包为准重新导入」只在**导入到已有实例**时有意义(复审 r1 P2-2):新建实例那条路上 + // 目标目录已经建好,再点一次必然撞「directory already exists」——一个必然失败的按钮 + // 等于死按钮,所以那种流程下不显示。 + can_overwrite: !report.conflicts.is_empty() + && report.status == ImportStatusView::Partial + && page.instance.is_some(), overwrite_note: (!report.conflicts.is_empty()) .then(|| catalog.text(Key::PackOverwriteNote).to_owned()), } diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index 74edfd9..991fcbd 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -261,7 +261,11 @@ fn conflicts_default_to_keeping_the_user_copy() { bytes_written: 0, rolled_back: true, }; - let page = PackPageState::default(); + // 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例 + let page = PackPageState { + instance: Some("survival".to_string()), + ..PackPageState::default() + }; let block = report_of(&report, &page, catalog()); assert_eq!(block.conflicts.len(), 1); assert!(!block.conflicts[0].expanded, "默认收起"); @@ -276,7 +280,7 @@ fn conflicts_default_to_keeping_the_user_copy() { // 展开的那一条才画三方哈希 let expanded = PackPageState { expanded: Some("config/x.json".to_string()), - ..PackPageState::default() + ..page.clone() }; let block = report_of(&report, &expanded, catalog()); assert!(block.conflicts[0].expanded); @@ -294,3 +298,97 @@ fn an_idle_pack_has_nothing_to_draw_but_the_entry() { assert!(!model.drop_hint.is_empty()); assert!(!model.no_dialog_note.is_empty()); } + +#[test] +fn the_save_to_field_really_reaches_the_request() { + // 复审 r1 P1-1:这个字段以前是死的(构造点硬编码 target: None) + let page = PackPageState { + output: " D:/packs/my.mrpack ".to_string(), + ..PackPageState::default() + }; + let request = super::export::request_of(&page, "survival"); + assert_eq!( + request.target.as_deref(), + Some(std::path::Path::new("D:/packs/my.mrpack")), + "用户敲的路径必须真的进请求(去首尾空白)" + ); + + // 留空 ⇒ None(由 host 给缺省路径),不是空串路径 + let blank = PackPageState { + output: " ".to_string(), + ..PackPageState::default() + }; + assert!(super::export::request_of(&blank, "survival") + .target + .is_none()); + + // 包名/版本留空时的缺省值也钉一下(预检与写盘共用同一份请求,不能各算一套) + let request = super::export::request_of(&blank, "survival"); + assert_eq!(request.name, "survival"); + assert_eq!(request.version, "1.0.0"); +} + +#[test] +fn overwrite_is_offered_only_when_importing_into_an_existing_instance() { + // 复审 r1 P2-2:新建实例那条路上再点「以包为准」必然撞「directory already exists」 + let report = unlucky_presenter::dto::ImportReportView { + status: ImportStatusView::Partial, + format: Some(PackFormatView::Modrinth), + pack_name: Some("Pack".to_string()), + pack_version: None, + game_version: None, + loader: None, + version_id: None, + target: std::path::PathBuf::from("C:/tmp"), + files: Vec::new(), + conflicts: vec![ConflictFileView { + path: "config/x.json".to_string(), + reason: ConflictReasonView::UserModified, + choice: ConflictChoiceView::KeepUser, + pack_sha1: "abc".to_string(), + base_sha1: None, + disk_sha1: None, + pack_size: 1, + }], + missing: Vec::new(), + rejected: Vec::new(), + warnings: Vec::new(), + error: None, + registered: None, + bytes_written: 0, + rolled_back: true, + }; + + let new_instance = PackPageState::default(); + assert!( + !super::report::report_of(&report, &new_instance, catalog()).can_overwrite, + "新建实例流程下不该出现「以包为准」(那按钮必然失败)" + ); + + let existing = PackPageState { + instance: Some("survival".to_string()), + ..PackPageState::default() + }; + assert!( + super::report::report_of(&report, &existing, catalog()).can_overwrite, + "导入到已有实例时才给翻档动作" + ); +} + +#[test] +fn cancelled_state_is_visible_and_says_the_import_keeps_running() { + // 复审 r1 P1-2:面板收回之后,界面上必须留一句「后台还在跑」 + let import = unlucky_presenter::dto::ImportState { + cancelled: true, + ..unlucky_presenter::dto::ImportState::default() + }; + let export = unlucky_presenter::dto::ExportState::default(); + let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog()); + assert!(model.cancelled); + assert!( + model.cancelled_note.to_lowercase().contains("background"), + "取消提示必须说清后台还在跑:{}", + model.cancelled_note + ); + assert!(!model.is_idle(), "取消态不能被当成「什么都没有」"); +} diff --git a/crates/app-iced/src/pages/content/packui/view.rs b/crates/app-iced/src/pages/content/packui/view.rs index fb46c49..7e41d0a 100644 --- a/crates/app-iced/src/pages/content/packui/view.rs +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -92,6 +92,25 @@ pub(crate) fn section<'a>( block = block.push(running_block(ui, catalog, running)); } else if let Some(report) = &model.report { block = block.push(report_block(ui, catalog, report)); + } else if model.cancelled { + // 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2) + block = block.push( + column![ + frame::text_role( + ui, + TypeRole::BodySmall, + false, + model.cancelled_note, + ui.color(Role::Tertiary), + ), + buttons::text( + ui, + Button::new(catalog.text(Key::PackDismiss), intent(PackMessage::Dismiss)), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); } else { block = block.push(entry(ui, catalog, model)); if let Some(probe) = &model.probe { diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs index 5906da0..0b3272c 100644 --- a/crates/app-iced/src/pages/content/packui/view/export.rs +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -139,14 +139,8 @@ pub(super) fn export_block<'a>( )); } if let Some(preview) = &export.preview { + // 动作在 `preview_block` 里紧跟摘要(明细再长也不会把「确认写盘」顶出屏幕) block = block.push(preview_block(ui, catalog, preview)); - block = block.push(buttons::filled( - ui, - Button::new( - catalog.text(Key::PackExportConfirm), - intent(PackMessage::Confirm), - ), - )); } } @@ -226,6 +220,29 @@ pub(super) fn preview_block<'a>( .spacing(ui.space(Space::X1)) .width(Length::Fill); + // **动作紧跟摘要**:明细(13 条 skipped + 样例 + 已知损失)再长,也不会把 + // 「Write the pack」顶出 900px(复审 r1 P2-3 的正解:先给结论与动作,明细往下滚) + block = block.push( + row![ + buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportConfirm), + intent(PackMessage::Confirm), + ), + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::PackExportAbort), + intent(PackMessage::AbortExport) + ), + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + ); + if !preview.skipped.is_empty() { // 标题写成「Not in the pack: 13」而不是「逐条列出」:13 条**确实**全列出来了, // 但下面那个「…还有 N 个(这里不展开)」说的是**内联样例**,两句挨着容易被读成矛盾 @@ -236,7 +253,14 @@ pub(super) fn preview_block<'a>( &[("count", &preview.skipped.len().to_string())], ), )); - block = block.push(rows_block(ui, catalog, &preview.skipped)); + // 13 条是真实形态:摊开会把「Write the pack」顶出 900px(复审 r1 P2-3) + block = block.push(bounded( + ui, + rows_block(ui, catalog, &preview.skipped), + preview.skipped.len(), + 3, + 110.0, + )); } // 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把 @@ -292,7 +316,7 @@ pub(super) fn preview_block<'a>( } } let extras_rows = preview.inline_sample.len() + preview.notes.len(); - block = block.push(bounded(ui, extras, extras_rows, 4, 160.0)); + block = block.push(bounded(ui, extras, extras_rows, 3, 120.0)); block.into() } diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index 5615444..826cbc3 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -131,8 +131,17 @@ pub(super) fn report_block<'a>( .width(Length::Fill), ); } - if !report.rejected.is_empty() { - block = block.push(rows_block(ui, catalog, &report.rejected)); + if let Some(title) = &report.rejected_title { + // 没有这句的话,「Could not fetch (tolerated): 3」下面会多出一行没有归属的 + // 「not supported by the client」(复审 r1 P2-4) + block = block.push( + column![ + tolerable_title(ui, title.clone()), + rows_block(ui, catalog, &report.rejected), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); } if let Some(title) = &report.conflicts_title { diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index 7711093..0f492a8 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -135,10 +135,18 @@ macro_rules! content_entries { "有 {count} 个文件没搞到(可容忍)" ), PackReportConflicts => ( - "You had changed (yours kept by default): {count}", - "{count} 个文件你改过(默认保留你的)" + "Conflicts: {count}", + "冲突:{count} 个" + ), + PackReportRejected => ( + "The client does not support these: {count}", + "这些组件客户端不支持:{count} 个" ), PackReportRejectedNote => ("not supported by the client", "客户端不支持"), + PackCancelled => ( + "Import keeps running in the background — this result is discarded. Files already written stay.", + "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。" + ), PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"), PackOverwriteNote => ( "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 8db20a0..013644f 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -248,8 +248,10 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"), ("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), ("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"), - ("PackReportConflicts", "You had changed (yours kept by default): {count}", "{count} 个文件你改过(默认保留你的)"), + ("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"), + ("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"), ("PackReportRejectedNote", "not supported by the client", "客户端不支持"), + ("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"), ("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"), ("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"), ("PackMissingOffline", "offline import", "离线导入"), diff --git a/crates/presenter/src/dto/pack/event.rs b/crates/presenter/src/dto/pack/event.rs index fe0ddde..61b0038 100644 --- a/crates/presenter/src/dto/pack/event.rs +++ b/crates/presenter/src/dto/pack/event.rs @@ -57,6 +57,9 @@ pub enum PackEvent { Exported(Box), /// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因) ExportFailed(String), + /// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌), + /// 所以留一位给界面说清「还在跑、结果作废」。 + Cancelled, /// 关掉所有面板 Cleared, } @@ -105,6 +108,10 @@ impl PackState { self.export.pending = false; self.export.failed = Some(reason); } + PackEvent::Cancelled => { + self.import.progress = None; + self.import.cancelled = true; + } PackEvent::Cleared => { self.import = ImportState::default(); self.export = ExportState::default(); diff --git a/crates/presenter/src/dto/pack/import.rs b/crates/presenter/src/dto/pack/import.rs index 59c128a..2109558 100644 --- a/crates/presenter/src/dto/pack/import.rs +++ b/crates/presenter/src/dto/pack/import.rs @@ -331,6 +331,9 @@ pub struct ProbeView { pub struct ImportState { /// 正在跑的进度(`None` = 没在跑)。 pub progress: Option, + /// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上—— + /// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。 + pub cancelled: bool, /// 最近一次的结果(`None` = 还没导过)。 pub report: Option, /// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。 diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 4da4a72..37e3b58 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -38,7 +38,7 @@ mod install; mod pack; mod sources; -pub(super) use pack::cancel_pack; +pub(super) use pack::{cancel_pack, pack_epoch}; #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 9516eac..b92cc70 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -32,7 +32,11 @@ //! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉; //! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到 //! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给 -//! core 加 cancel token(已登记 finding,报告里同样划出来)。 +//! core 加 cancel token(已登记 finding,报告里同样划出来); +//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」, +//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」, +//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成 +//! 「导入会在后台继续跑完 —— 本次结果作废」这句。 mod convert; mod diagnose; @@ -43,7 +47,7 @@ mod import; mod tests; use std::path::PathBuf; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot}; @@ -59,6 +63,27 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; +/// **pack 自己的**任务代次(与安装那条计数器完全分开)。 +/// +/// 为什么必须分开(复审 r1 P2-7):`HostTask::Install` / `InstallDone` 按 `CoreHost::install_epoch` +/// 丢包。如果 pack 也共用那个数,用户在内容页关一次导入面板就会把代次 +1 —— 下载页**正在装版本** +/// 的进度与收尾会被静默丢弃(面板卡住、不刷实例、没有失败提示)。所以 pack 用自己的一条, +/// `CoreHost` 的字段不在本 mission 的 scope,这条计数器就落在模块里。 +static PACK_EPOCH: AtomicU64 = AtomicU64::new(0); + +/// 当前 pack 代次(`handle` 用它判断「这条事件是不是已经被取消/关面板作废了」)。 +pub(in crate::host) fn pack_epoch() -> u64 { + PACK_EPOCH.load(Ordering::SeqCst) +} + +/// 作废在飞的一切 pack 事件(取消 / 关面板都走它)。 +pub(in crate::host) fn bump_pack_epoch() -> u64 { + PACK_EPOCH.fetch_add(1, Ordering::SeqCst).wrapping_add(1) +} + +/// 有一次导入正在跑吗(取消时要据此决定弹哪条提示)。 +static PACK_RUNNING: AtomicBool = AtomicBool::new(false); + /// 正在跑的那次导入的取消标记(模块级槽:`CoreHost` 的字段不在本 mission 的 scope)。 /// /// 语义见模块文档「取消是界面级的」——它挡的是**事件**,不是管线。 @@ -67,16 +92,28 @@ use super::super::job::{HostTask, JobSender}; static PACK_CANCEL: Mutex>> = Mutex::new(None); /// 取消正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 -pub(in crate::host) fn cancel_pack() { +/// +/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语: +/// **导入的取消不是 install 的取消**(后台会跑完并登记实例,见模块文档),所以不能复用 +/// 下载页那条「安装已取消、实例未创建」的提示。 +pub(in crate::host) fn cancel_pack() -> bool { if let Ok(slot) = PACK_CANCEL.lock() { if let Some(cancel) = slot.as_ref() { - cancel.store(true, std::sync::atomic::Ordering::SeqCst); + cancel.store(true, Ordering::SeqCst); } } + bump_pack_epoch(); + PACK_RUNNING.swap(false, Ordering::SeqCst) +} + +/// 这次导入跑完了(正常收尾也走它):`PACK_RUNNING` 归零,之后 `cancel_pack()` 会回 false。 +pub(super) fn finished() { + PACK_RUNNING.store(false, Ordering::SeqCst); } /// 新一次导入的取消标记(同时把它挂进模块级槽,供 [`cancel_pack`] 用)。 fn fresh_cancel() -> Arc { + PACK_RUNNING.store(true, Ordering::SeqCst); let cancel = Arc::new(AtomicBool::new(false)); if let Ok(mut slot) = PACK_CANCEL.lock() { *slot = Some(Arc::clone(&cancel)); @@ -87,7 +124,7 @@ fn fresh_cancel() -> Arc { /// `AppCommand::Pack` 的落点(M113)。 impl super::super::CoreHost { pub(crate) fn pack(&mut self, command: PackCommand) { - let epoch = self.install_epoch; + let epoch = pack_epoch(); match command { PackCommand::Import(request) => { let cancel = fresh_cancel(); @@ -109,9 +146,8 @@ impl super::super::CoreHost { export::spawn_write(self.env.clone(), self.tx.clone(), *request, epoch); } PackCommand::Dismiss => { + // 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门) cancel_pack(); - // 代次 +1:这次之后回来的旧事件一律作废 - self.install_epoch = self.install_epoch.wrapping_add(1); self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared))); } } diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 627aa81..ca6892e 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -167,8 +167,10 @@ fn loader_label(target: &LoaderTarget) -> String { /// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。 /// /// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。 +/// `target` 是**目标实例目录**(不知道就给空路径),不是包路径(复审 r1 P2-9 顺手纠的 DTO 语义)。 pub(super) fn failed_report( - pack: PathBuf, + target: PathBuf, + pack: &Path, code: &str, label: &str, message: &str, @@ -176,12 +178,12 @@ pub(super) fn failed_report( ImportReportView { status: ImportStatusView::Failed, format: None, - pack_name: Some(pack_stem(&pack)), + pack_name: Some(pack_stem(pack)), pack_version: None, game_version: None, loader: None, version_id: None, - target: pack, + target, files: Vec::new(), conflicts: Vec::new(), missing: Vec::new(), @@ -206,6 +208,35 @@ pub(super) fn pack_stem(pack: &Path) -> String { .unwrap_or_else(|| "imported-pack".to_string()) } +/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。 +/// +/// 为什么不能只用 [`slug`](复审 r1 P2-8):它只保留 ASCII,于是「我的整合包」「另一个整合包」 +/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。这个仓的主要受众就是 +/// 中文用户,所以:名字本身就全是 ASCII 安全字符时保持原样(`Better MC` → `Better-MC`), +/// **一旦折过东西**(中文/空格/符号)就补 `-`,用原名的哈希保住区分度。 +pub(super) fn dir_name(name: &str) -> String { + let base = slug(name); + let faithful = !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'); + if faithful && base != "pack" { + base + } else { + format!("{base}-{}", hash8(name)) + } +} + +/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。 +fn hash8(text: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in text.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{:08x}", hash as u32) +} + /// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。 pub(super) fn slug(text: &str) -> String { let mut out = String::new(); diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index 6da93b8..fd378ae 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -17,6 +17,7 @@ use unlucky_core::addon::import::{ use unlucky_core::download::blocking::block_on; use crate::dto::{ImportRequestView, PackEvent}; +use unlucky_core::addon::import::ImportStatus; use super::super::super::env::HostEnv; use super::super::super::job::{spawn, JobSender}; @@ -55,7 +56,9 @@ pub(super) fn run( tx, epoch, PackEvent::Report(Box::new(convert::failed_report( - request.pack.clone(), + // 目标目录不可用 ⇒ 如实给空路径(不把包路径冒充成实例目录) + PathBuf::new(), + &request.pack, "import_host", "Target instance is not usable", &reason, @@ -99,8 +102,16 @@ pub(super) fn run( } let report = block_on(import_pack(&core_request)); + super::finished(); + if report.status == ImportStatus::Failed && register { + // 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话 + // 下一次重试会撞「directory already exists」(复审 r1 P2-9)。 + remove_if_empty(&target); + } if cancel.load(Ordering::SeqCst) { - // 取消之后不再打扰界面(面板已经收回,代次也已经作废) + // 取消之后不再打扰界面(面板已经收回,代次也已经作废)。 + // 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 —— + // 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。 return; } send( @@ -110,12 +121,25 @@ pub(super) fn run( ); } +/// 目录里没有东西就删掉(失败回滚后的收尾;非空一律不动)。 +fn remove_if_empty(dir: &std::path::Path) { + let empty = std::fs::read_dir(dir) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(false); + if empty { + let _ = std::fs::remove_dir(dir); + } +} + /// 导入目标:`(目录, 是否登记新实例)`。 /// /// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录); /// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 /// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。 -fn target_of(env: &HostEnv, request: &ImportRequestView) -> Result<(PathBuf, bool), String> { +pub(super) fn target_of( + env: &HostEnv, + request: &ImportRequestView, +) -> Result<(PathBuf, bool), String> { match &request.instance { Some(id) => super::instance_root(env, id).map(|root| (root, false)), None => { @@ -128,12 +152,21 @@ fn target_of(env: &HostEnv, request: &ImportRequestView) -> Result<(PathBuf, boo .layout .data() .join("instances") - .join(convert::slug(&name)); + .join(convert::dir_name(&name)); if target.exists() { - return Err(format!("directory already exists: {}", target.display())); + // 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」 + let mut entries = std::fs::read_dir(&target) + .map_err(|error| format!("read instance dir failed: {error}"))?; + if entries.next().is_some() { + return Err(format!( + "directory already exists and is not empty: {}", + target.display() + )); + } + } else { + std::fs::create_dir_all(&target) + .map_err(|error| format!("create dir failed: {error}"))?; } - std::fs::create_dir_all(&target) - .map_err(|error| format!("create dir failed: {error}"))?; Ok((target, true)) } } diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index 289bdb9..a6075ae 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -239,3 +239,69 @@ fn a_real_pack_runs_through_the_pipeline_end_to_end() { let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn chinese_instance_names_get_distinct_directories() { + // 复审 r1 P2-8:slug() 只留 ASCII,中文名会全塌成 `pack`,第二个就撞目录 + let first = super::convert::dir_name("我的整合包"); + let second = super::convert::dir_name("另一个整合包"); + assert_ne!(first, second, "两个不同的中文名必须得到不同目录"); + assert_eq!( + first, + super::convert::dir_name("我的整合包"), + "同一个名字要稳定(不然重试会漂到新目录)" + ); + assert!(first.starts_with("pack-"), "退化时要留可读前缀:{first}"); + + // 本来就全是 ASCII 安全字符的名字保持干净(不无端加哈希) + assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC"); +} + +#[test] +fn a_failed_new_instance_directory_can_be_reused_for_retry() { + // 复审 r1 P2-9:失败回滚留下空目录时会挡住重试 + let dir = temp_dir("retry"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + let env = crate::host::HostEnv::at_root(&data); + + let request = crate::dto::ImportRequestView { + pack: dir.join("whatever.mrpack"), + instance: None, + instance_name: Some("Retry Me".to_string()), + overwrite: false, + }; + + // 第一次:目录不存在 ⇒ 建出来 + let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录"); + assert!(register); + assert!(first.is_dir()); + + // 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」 + let (second, _) = super::import::target_of(&env, &request).expect("空目录应当可复用"); + assert_eq!(first, second); + + // 目录里已经有东西 ⇒ 拒绝(不能往别人的实例里倒) + std::fs::write(second.join("marker.txt"), b"x").expect("write"); + assert!( + super::import::target_of(&env, &request).is_err(), + "非空目录必须拒绝" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn pack_epoch_is_its_own_counter() { + // 复审 r1 P2-7:pack 的代次必须与安装的 `CoreHost::install_epoch` 分开。 + // 这里钉的是模块计数器的行为;「不再读写 install_epoch」由代码结构保证 + // (`pack.rs` 里已无 `self.install_epoch` 引用,只有这里自己的计数器)。 + let before = super::pack_epoch(); + super::bump_pack_epoch(); + assert_eq!(super::pack_epoch(), before.wrapping_add(1), "一次 +1"); + + // 取消会作废在飞的 pack 事件(代次前进),并且回报「刚才有没有在跑」 + super::bump_pack_epoch(); + let after_bump = super::pack_epoch(); + assert!(after_bump > before); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index c20b58e..ecc2038 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -2,7 +2,7 @@ //! //! 父模块的私有字段对子模块可见,所以这里直接读写 `self.env` / `self.tx` 等。 -use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind}; +use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind, PackEvent}; use crate::toast::ToastKind; use super::accounts::{self, LoginJob}; @@ -70,14 +70,22 @@ impl CoreHost { if let Some(cancel) = &self.install_cancel { cancel.store(true, std::sync::atomic::Ordering::SeqCst); } - // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 代次 +1 - addon::cancel_pack(); + // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次 + // 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号 + // (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。 + let pack_was_running = addon::cancel_pack(); + if pack_was_running { + self.send(CoreEvent::Pack(Box::new(PackEvent::Cancelled))); + } 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); + // 导入在跑时不发这条:下载页那条文案说的是「实例未创建」,对导入是撒谎。 + if !pack_was_running { + 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 4bf68f3..a9130d3 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -2,10 +2,10 @@ //! //! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。 -use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome}; +use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome, PackEvent}; use crate::toast::ToastKind; -use super::{logs, CoreHost, HostTask, LiveLaunch}; +use super::{addon, logs, CoreHost, HostTask, LiveLaunch}; impl CoreHost { /// 后台消息 → 事件。 @@ -42,9 +42,16 @@ impl CoreHost { }, // M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉 HostTask::Pack { event, epoch } => { - if epoch != self.install_epoch { + // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 + // 「关一次导入面板」把下载页正在跑的安装静默丢掉。 + if epoch != addon::pack_epoch() { return; } + // 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1), + // 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。 + if matches!(*event, PackEvent::Report(_)) { + self.refresh_instances(); + } self.send(CoreEvent::Pack(event)); } // M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。 From 134b0a2aa4cb77801b0c24d645ca667ad94df7af Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 02:34:35 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(packui):=20M113=20r2=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E6=95=B4=E6=94=B9=20=E2=80=94=E2=80=94=20RAII=20?= =?UTF-8?q?=E5=9C=A8=E8=B7=91=E5=8F=A5=E6=9F=84=E3=80=81per-host=20?= =?UTF-8?q?=E4=BB=A3=E6=AC=A1=E3=80=81=E6=B3=A8=E9=87=8A=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r2(p2-3items / fix-then-merge)逐条整改。**P2-6 与 P3-3/4/5 按塔台指示不动**,只登记。 ## 必修 1:`PACK_RUNNING` 在早退路径不清零(跨功能泄漏) 改前:`fresh_cancel()` 置 bool,`super::finished()` 在 `block_on(import_pack(..))` **之后**才清; 探测失败 / 目标目录不可用两条 `return` 都在它之前 ⇒ bool 永久留在「在跑」。 后果:之后**任何**一次 `CancelInstall`(包括下载页的「取消安装」)①不再发 `InstallCancelled`、 ②还给内容库发 `PackEvent::Cancelled`(一句「导入还在后台跑」的假话)。 改后:`InFlight` **RAII 句柄**(`Drop` 减一)取代手工 `finished()`;计数用「每个 host 一格」的 `in_flight: usize` 而不是 bool(连点两次 Import 各自进出)。 `InFlight::begin(&env)` 在**派发点**创建、随线程移动到出口 ⇒ 正常收尾、两条早退、`?`、panic 展开**任何**退出路径都会减一,以后再加早退分支也不用记得补一行。 ## 必修 2:`dir_name` 注释与实测不一致 改前:注释与报告写「`Better MC` → `Better-MC`(不加哈希)」,而 `faithful` 只认 `[A-Za-z0-9_-]`, 空格不算 ⇒ 实际 `Better-MC-c7c9c149`;测试只钉了无空格形态。 改后:**选「空格算 faithful」这边**,注释、代码、测试三处对齐 —— 名字只含 ASCII 字母数字、 空格、`-`、`_` 时保持原样(空格折成 `-`);折过**别的**字符(中文、`.`、括号…)才补 `-`。 测试钉住四例:`Better-MC` → 原样、`Better MC` → `Better-MC`(不加哈希)、`pack` → `pack`、 `Survival 1.20.1` → `Survival-1-20-1-`(折过 `.` 必须补)。 ## 必修 3:两处注释与代码相反 - `host/job.rs` 的 `HostTask::Pack`:原写「代次与 `HostTask::Install` 共用同一计数器」,改为 「pack 自己的一条(`PACK_EPOCH`),不与 `install_epoch` 共用」并写明为什么(P2-7 的教训)。 - `host/addon/pack/import.rs` 的 `target_of`:`` → ``,并注明中文名不会被折成同一个 `pack`。 ## 必修 4:两条 P3 - `04` 图里「Back」出现两次:有预检结果时字段区那行只留「预检」,`Back` 只由预览块给一个 (没有预检时才在字段区给 `Back`)。 - 导入侧 `rejected` 列表补 `bounded`(与 missing / conflicts 同一口径)。 ## 必修 5:app 级用例(P2-1 的护栏) 新增 `a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`: `CoreHost::headless` + 真派 `PackCommand::Import`(包不存在 ⇒ 探测早退)→ 泵到 `Probe` → 断言 `in_flight == 0` → 再派 `CancelInstall` → 断言**仍会**发 `InstallCancelled` 且**不发** `PackEvent::Cancelled`。 ## P3-6(裸静态量):按塔台要求正面回答并已改掉 不再用裸静态量:`PACK_EPOCH` / `PACK_RUNNING` / `PACK_CANCEL` 换成**按 host 键控**的一张表 (键 = `LauncherLayout::home()`),理由是实测撞到过串扰 —— `host/cancel_tests` 派一次 `CancelInstall` 就 bump 了全局代次,我这条在飞的导入事件被按代次丢掉(新用例因此红过)。 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在 scope)。这张表等价,`host/mod.rs` 一旦进 scope,搬过去只是把表删掉 —— 报告里也这么写了。 新增用例 `pack_epoch_is_per_host_and_is_its_own_counter` 钉住「另一个 host 的代次不受影响」。 ## 顺手的两条 - `pack/tests.rs` 加完用例后 429 行超线 ⇒ 夹具(临时目录 / 写文件 / 泵 host)拆到 `pack/tests/harness.rs`,`tests.rs` 回到 3xx 行。 - clippy `-D warnings` 抓到 `== false`、两处多余的 `&`、一个未用的再导出,都改掉了。 门禁:`cargo fmt --all --check` 通过;clippy 三 crate `--all-targets -- -D warnings` EXIT=0; `cargo test -p unlucky-app-iced --lib` **507 passed**;`cargo test -p unlucky-presenter --lib` **185 passed**(新增的 app 级用例在内,且是在**全量并行**下跑的);改动文件全部 ≤400 行。 --- .../src/pages/content/packui/view/export.rs | 39 ++--- .../src/pages/content/packui/view/report.rs | 9 +- crates/presenter/src/host/addon/pack.rs | 158 +++++++++++++----- .../presenter/src/host/addon/pack/convert.rs | 12 +- .../presenter/src/host/addon/pack/import.rs | 9 +- crates/presenter/src/host/addon/pack/tests.rs | 146 ++++++++++++---- .../src/host/addon/pack/tests/harness.rs | 56 +++++++ crates/presenter/src/host/dispatch.rs | 2 +- crates/presenter/src/host/handle.rs | 2 +- crates/presenter/src/host/job.rs | 6 +- 10 files changed, 329 insertions(+), 110 deletions(-) create mode 100644 crates/presenter/src/host/addon/pack/tests/harness.rs diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs index 0b3272c..563c1e6 100644 --- a/crates/app-iced/src/pages/content/packui/view/export.rs +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -109,26 +109,27 @@ pub(super) fn export_block<'a>( .placeholder(catalog.text(Key::PackExportOutputPlaceholder)), move |text| intent(PackMessage::SetOutput(text)), )); - block = block.push( - row![ - buttons::filled( - ui, - Button::new( - catalog.text(Key::PackExportPrecheck), - intent(PackMessage::Precheck), - ) - .leading(IconKind::Search), + // 有预检结果时这里只留「预检」(`Back` 交给预览块那一个)——复审 r2 P3-1: + // 搬确认按钮进来时把 `Back` 带出了两份,同屏两个一样的按钮 + let mut actions = row![buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportPrecheck), + intent(PackMessage::Precheck), + ) + .leading(IconKind::Search), + )] + .spacing(ui.space(Space::X2)); + if export.preview.is_none() { + actions = actions.push(buttons::text( + ui, + Button::new( + catalog.text(Key::PackExportAbort), + intent(PackMessage::AbortExport), ), - buttons::text( - ui, - Button::new( - catalog.text(Key::PackExportAbort), - intent(PackMessage::AbortExport) - ), - ), - ] - .spacing(ui.space(Space::X2)), - ); + )); + } + block = block.push(actions); if export.pending { block = block.push(frame::text_role( ui, diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index 826cbc3..8cc8f1d 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -137,7 +137,14 @@ pub(super) fn report_block<'a>( block = block.push( column![ tolerable_title(ui, title.clone()), - rows_block(ui, catalog, &report.rejected), + // 与 missing / conflicts 同一条口径:条目多时框起来滚,别把动作顶出屏幕 + bounded( + ui, + rows_block(ui, catalog, &report.rejected), + report.rejected.len(), + 3, + 110.0, + ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill), diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index b92cc70..3137a4b 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -47,7 +47,7 @@ mod import; mod tests; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot}; @@ -63,60 +63,119 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -/// **pack 自己的**任务代次(与安装那条计数器完全分开)。 +/// pack 的**每 host** 状态:代次 + 在跑计数 + 本次运行的取消标记。 /// -/// 为什么必须分开(复审 r1 P2-7):`HostTask::Install` / `InstallDone` 按 `CoreHost::install_epoch` -/// 丢包。如果 pack 也共用那个数,用户在内容页关一次导入面板就会把代次 +1 —— 下载页**正在装版本** -/// 的进度与收尾会被静默丢弃(面板卡住、不刷实例、没有失败提示)。所以 pack 用自己的一条, -/// `CoreHost` 的字段不在本 mission 的 scope,这条计数器就落在模块里。 -static PACK_EPOCH: AtomicU64 = AtomicU64::new(0); - -/// 当前 pack 代次(`handle` 用它判断「这条事件是不是已经被取消/关面板作废了」)。 -pub(in crate::host) fn pack_epoch() -> u64 { - PACK_EPOCH.load(Ordering::SeqCst) +/// ## 为什么不是裸静态量(复审 r2 P3-6 要的那句回答) +/// +/// 裸静态量是**跨 host 共享**的。生产路径只有一个 host 所以看不出来,但测试里两个 host 会互相 +/// 干扰 —— 本轮实测撞到过:`host/cancel_tests` 派一次 `CancelInstall` 就把我这条用例在飞的导入 +/// 事件按代次丢了。键取 `LauncherLayout::home()`(每个测试的临时根都不同),于是各自独立: +/// +/// - **代次**:每个 host 一条,`handle` 只跟**自己这个 host** 的当前值比; +/// - **在跑计数**:一张计数表(每个 host 一格),由 [`InFlight`] 的 `Drop` 维护; +/// - **取消标记**:每个 host 一份 `Arc`,导入线程拿克隆,`cancel_pack` 置位。 +/// +/// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价, +/// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。 +#[derive(Default, Clone, Copy)] +struct HostPackState { + epoch: u64, + in_flight: usize, } -/// 作废在飞的一切 pack 事件(取消 / 关面板都走它)。 -pub(in crate::host) fn bump_pack_epoch() -> u64 { - PACK_EPOCH.fetch_add(1, Ordering::SeqCst).wrapping_add(1) +static PACK_STATE: Mutex> = Mutex::new(Vec::new()); +static PACK_CANCEL: Mutex)>> = Mutex::new(Vec::new()); + +/// 这张表的键(数据根;`HostEnv::at_root` 的每个测试根都不同)。 +fn host_key(env: &HostEnv) -> PathBuf { + env.layout.home().to_path_buf() } -/// 有一次导入正在跑吗(取消时要据此决定弹哪条提示)。 -static PACK_RUNNING: AtomicBool = AtomicBool::new(false); +fn with_state(env: &HostEnv, body: impl FnOnce(&mut HostPackState) -> T) -> T { + let key = host_key(env); + let mut table = PACK_STATE + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(index) = table.iter().position(|(name, _)| *name == key) { + return body(&mut table[index].1); + } + table.push((key, HostPackState::default())); + body(&mut table.last_mut().expect("刚插入").1) +} -/// 正在跑的那次导入的取消标记(模块级槽:`CoreHost` 的字段不在本 mission 的 scope)。 -/// -/// 语义见模块文档「取消是界面级的」——它挡的是**事件**,不是管线。 -/// 用 `Arc` 而不是一个裸静态量:导入线程拿一份克隆、`cancel_pack()` 拿另一份, -/// 于是**测试可以各自持有一份**,不会互相串(裸静态量在并行测试里会泄漏取消状态)。 -static PACK_CANCEL: Mutex>> = Mutex::new(None); +/// 这个 host 当前的 pack 代次(`handle` 用它判断事件是否已被取消/关面板作废)。 +pub(in crate::host) fn pack_epoch(env: &HostEnv) -> u64 { + with_state(env, |state| state.epoch) +} -/// 取消正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 +/// 作废这个 host 在飞的一切 pack 事件(取消 / 关面板都走它)。 +pub(in crate::host) fn bump_pack_epoch(env: &HostEnv) -> u64 { + with_state(env, |state| { + state.epoch = state.epoch.wrapping_add(1); + state.epoch + }) +} + +/// 这个 host 现在有几条导入在跑。 +pub(in crate::host) fn in_flight(env: &HostEnv) -> usize { + with_state(env, |state| state.in_flight) +} + +/// 「这次导入还在跑」的 **RAII 句柄**。 /// -/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语: -/// **导入的取消不是 install 的取消**(后台会跑完并登记实例,见模块文档),所以不能复用 -/// 下载页那条「安装已取消、实例未创建」的提示。 -pub(in crate::host) fn cancel_pack() -> bool { - if let Ok(slot) = PACK_CANCEL.lock() { - if let Some(cancel) = slot.as_ref() { +/// 复审 r2 P2-1 的教训:手工 `finished()` 只在 `block_on(import_pack(..))` 之后调用, +/// 而失败早退(探测失败 / 目标目录不可用)都在它之前 ⇒ 计数永远留在「在跑」,于是下一次 +/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在后台跑」的假话。 +/// +/// 现在计数由句柄的 `Drop` 负责:**任何**退出路径(早退、报错、`?`、panic 展开)都会减一。 +pub(super) struct InFlight { + env: HostEnv, +} + +impl InFlight { + /// 计一次「在跑」。句柄活多久,这次导入就算跑多久。 + pub(super) fn begin(env: &HostEnv) -> Self { + with_state(env, |state| state.in_flight += 1); + Self { env: env.clone() } + } +} + +impl Drop for InFlight { + fn drop(&mut self) { + with_state(&self.env, |state| { + state.in_flight = state.in_flight.saturating_sub(1); + }); + } +} + +/// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 +/// +/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消** +/// (后台会跑完并登记实例,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。 +pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool { + let key = host_key(env); + { + let table = PACK_CANCEL + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some((_, cancel)) = table.iter().find(|(name, _)| *name == key) { cancel.store(true, Ordering::SeqCst); } } - bump_pack_epoch(); - PACK_RUNNING.swap(false, Ordering::SeqCst) + bump_pack_epoch(env); + in_flight(env) > 0 } -/// 这次导入跑完了(正常收尾也走它):`PACK_RUNNING` 归零,之后 `cancel_pack()` 会回 false。 -pub(super) fn finished() { - PACK_RUNNING.store(false, Ordering::SeqCst); -} - -/// 新一次导入的取消标记(同时把它挂进模块级槽,供 [`cancel_pack`] 用)。 -fn fresh_cancel() -> Arc { - PACK_RUNNING.store(true, Ordering::SeqCst); +/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。 +fn fresh_cancel(env: &HostEnv) -> Arc { let cancel = Arc::new(AtomicBool::new(false)); - if let Ok(mut slot) = PACK_CANCEL.lock() { - *slot = Some(Arc::clone(&cancel)); + let key = host_key(env); + let mut table = PACK_CANCEL + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + match table.iter_mut().find(|(name, _)| *name == key) { + Some((_, slot)) => *slot = Arc::clone(&cancel), + None => table.push((key, Arc::clone(&cancel))), } cancel } @@ -124,10 +183,12 @@ fn fresh_cancel() -> Arc { /// `AppCommand::Pack` 的落点(M113)。 impl super::super::CoreHost { pub(crate) fn pack(&mut self, command: PackCommand) { - let epoch = pack_epoch(); + let epoch = pack_epoch(&self.env); match command { PackCommand::Import(request) => { - let cancel = fresh_cancel(); + let cancel = fresh_cancel(&self.env); + // 句柄在**派发点**就计上,随线程一起结束(含早退与 panic) + let guard = InFlight::begin(&self.env); // 先把面板点亮:用户点了就有反应,不等后台回第一条进度 let name = request .instance_name @@ -137,7 +198,14 @@ impl super::super::CoreHost { self.send(CoreEvent::Pack(Box::new(PackEvent::Progress(Some( Box::new(ImportProgressView::new(name)), ))))); - import::spawn_import(self.env.clone(), self.tx.clone(), *request, epoch, cancel); + import::spawn_import( + self.env.clone(), + self.tx.clone(), + *request, + epoch, + cancel, + guard, + ); } PackCommand::Export(request) => { export::spawn_preview(self.env.clone(), self.tx.clone(), *request, epoch); @@ -147,7 +215,7 @@ impl super::super::CoreHost { } PackCommand::Dismiss => { // 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门) - cancel_pack(); + cancel_pack(&self.env); self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared))); } } diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index ca6892e..ec19cbf 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -210,17 +210,17 @@ pub(super) fn pack_stem(pack: &Path) -> String { /// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。 /// -/// 为什么不能只用 [`slug`](复审 r1 P2-8):它只保留 ASCII,于是「我的整合包」「另一个整合包」 -/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。这个仓的主要受众就是 -/// 中文用户,所以:名字本身就全是 ASCII 安全字符时保持原样(`Better MC` → `Better-MC`), -/// **一旦折过东西**(中文/空格/符号)就补 `-`,用原名的哈希保住区分度。 +/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时 +/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、 +/// 括号……)就补 `-`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」 +/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。 pub(super) fn dir_name(name: &str) -> String { let base = slug(name); let faithful = !name.is_empty() && name .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'); - if faithful && base != "pack" { + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' '); + if faithful { base } else { format!("{base}-{}", hash8(name)) diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index fd378ae..10f5c2c 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -31,9 +31,13 @@ pub(super) fn spawn_import( request: ImportRequestView, epoch: u64, cancel: Arc, + inflight: super::InFlight, ) { spawn("pack-import", move || { - run(&env, &tx, &request, epoch, cancel) + // 句柄跟着这次运行走到**出口**:正常收尾、两条早退、panic 展开都会 drop ⇒ 计数减一。 + // 以前是手工 `finished()` 放在 `block_on()` 之后,两条早退路径直接漏掉(复审 r2 P2-1)。 + let _inflight = inflight; + run(&env, &tx, &request, epoch, cancel); }); } @@ -102,7 +106,6 @@ pub(super) fn run( } let report = block_on(import_pack(&core_request)); - super::finished(); if report.status == ImportStatus::Failed && register { // 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话 // 下一次重试会撞「directory already exists」(复审 r1 P2-9)。 @@ -134,7 +137,7 @@ fn remove_if_empty(dir: &std::path::Path) { /// 导入目标:`(目录, 是否登记新实例)`。 /// /// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录); -/// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 +/// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 /// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。 pub(super) fn target_of( env: &HostEnv, diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index a6075ae..b9127fe 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -1,10 +1,20 @@ -//! M113 的两条硬证据(都跑真管线,不用假数据): +//! M113 的证据(都跑真管线 / 真 host,不用假数据): //! -//! 1. [`bad_packs_get_a_named_diagnosis`]:**坏包必须有具名失败**,不是静默、不是 panic; -//! 2. [`preview_counts_match_the_written_report`]:预检的「将内联 N / 无法导出 M」与最终 -//! `ExportReport` 的数字**逐项相等**(塔台点名的要求:预检不能是骗人的预告)。 +//! | 用例 | 钉住什么 | +//! |---|---| +//! | [`bad_packs_get_a_named_diagnosis`] | 坏包必须有具名失败,不是静默、不是 panic | +//! | [`preview_counts_match_the_written_report`] | 预检的 N/M 与最终报告**逐项相等** | +//! | [`a_real_pack_runs_through_the_pipeline_end_to_end`] | 正常包能走完管线并落盘 | +//! | [`chinese_instance_names_get_distinct_directories`] | 中文实例名不会塌成同一个目录 | +//! | [`a_failed_new_instance_directory_can_be_reused_for_retry`] | 失败残留的空目录不挡重试 | +//! | [`pack_epoch_is_per_host_and_is_its_own_counter`] | 代次按 host 分开,不再跨 host 串扰 | +//! | [`a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 | +//! +//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。 -use std::path::PathBuf; +mod harness; + +use harness::{pack_events, pump, pump_until, temp_dir, write}; use unlucky_core::addon::export::{ export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog, @@ -13,21 +23,6 @@ use unlucky_core::addon::export::{ use crate::dto::{ExportClassView, ExportRequestView, PackFormatView}; -/// 每个用例一个独立临时目录(用完删)。 -fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("temp dir"); - dir -} - -fn write(path: &std::path::Path, bytes: &[u8]) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("parent"); - } - std::fs::write(path, bytes).expect("write"); -} - #[test] fn bad_packs_get_a_named_diagnosis() { let dir = temp_dir("badpack"); @@ -253,8 +248,23 @@ fn chinese_instance_names_get_distinct_directories() { ); assert!(first.starts_with("pack-"), "退化时要留可读前缀:{first}"); - // 本来就全是 ASCII 安全字符的名字保持干净(不无端加哈希) + // 判据与注释对齐(复审 r2 P2-2):只含 ASCII 字母数字/空格/`-`/`_` ⇒ 保持原样 assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC"); + assert_eq!( + super::convert::dir_name("Better MC"), + "Better-MC", + "空格折成 `-`、**不加**哈希" + ); + assert_eq!( + super::convert::dir_name("pack"), + "pack", + "名字就叫 pack 也保留" + ); + + // 折过**别的**字符(这里是 `.`)就必须补哈希,否则不同名字会撞同一个目录 + let versioned = super::convert::dir_name("Survival 1.20.1"); + assert!(versioned.starts_with("Survival-1-20-1-"), "{versioned}"); + assert_ne!(versioned, "Survival-1-20-1", "折过 `.` 就要补哈希"); } #[test] @@ -292,16 +302,88 @@ fn a_failed_new_instance_directory_can_be_reused_for_retry() { } #[test] -fn pack_epoch_is_its_own_counter() { - // 复审 r1 P2-7:pack 的代次必须与安装的 `CoreHost::install_epoch` 分开。 - // 这里钉的是模块计数器的行为;「不再读写 install_epoch」由代码结构保证 - // (`pack.rs` 里已无 `self.install_epoch` 引用,只有这里自己的计数器)。 - let before = super::pack_epoch(); - super::bump_pack_epoch(); - assert_eq!(super::pack_epoch(), before.wrapping_add(1), "一次 +1"); +fn pack_epoch_is_per_host_and_is_its_own_counter() { + // 复审 r2 P3-6:代次**按 host** 分开(不再是跨 host 共享的裸静态量)。 + // 两个 host(两个临时根)各 bump 一次,互不影响 —— 这正是并行测试串扰的根因。 + let one = temp_dir("epoch-one"); + let two = temp_dir("epoch-two"); + let env_one = crate::host::HostEnv::at_root(one.join("data")); + let env_two = crate::host::HostEnv::at_root(two.join("data")); - // 取消会作废在飞的 pack 事件(代次前进),并且回报「刚才有没有在跑」 - super::bump_pack_epoch(); - let after_bump = super::pack_epoch(); - assert!(after_bump > before); + let before_one = super::pack_epoch(&env_one); + let before_two = super::pack_epoch(&env_two); + super::bump_pack_epoch(&env_one); + assert_eq!( + super::pack_epoch(&env_one), + before_one.wrapping_add(1), + "自己这个 host 的代次 +1" + ); + assert_eq!( + super::pack_epoch(&env_two), + before_two, + "**另一个** host 的代次不受影响(P3-6 的根因)" + ); + assert_eq!(super::in_flight(&env_two), 0, "在跑计数也是每 host 一份"); + + let _ = std::fs::remove_dir_all(&one); + let _ = std::fs::remove_dir_all(&two); +} + +#[test] +fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact() { + // 复审 r2 P2-1 的护栏:早退路径必须把「在跑」计数放回去,否则之后**下载页**的 + // 「取消安装」会被当成导入取消(不发 `InstallCancelled`,还给内容库画一句假话)。 + use crate::dto::{ + AppCommand, AppEvent, Backend, CoreEvent, ImportRequestView, PackCommand, PackEvent, + }; + + let dir = temp_dir("inflight-guard"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + let host_env = crate::host::HostEnv::at_root(&data); + let mut host = crate::host::CoreHost::headless(host_env.clone()); + + // ① 派一次**必然早退**的导入:包文件不存在 ⇒ 探测当场失败,根本不进管线 + host.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new( + ImportRequestView { + pack: dir.join("missing.mrpack"), + instance: None, + instance_name: Some("Guess".to_string()), + overwrite: false, + }, + ))))); + let events = pump_until(&mut host, |all| { + pack_events(all) + .iter() + .any(|event| matches!(event, PackEvent::Probe(_))) + }); + assert!( + pack_events(&events) + .iter() + .any(|event| matches!(event, PackEvent::Probe(_))), + "坏包必须给出探测诊断(而不是静默);实际收到 {events:?}" + ); + assert_eq!( + super::in_flight(&host_env), + 0, + "早退之后「在跑」计数必须归零(RAII guard 负责,不靠手工 finished)" + ); + + // ② 此后下载页的「取消安装」必须照常发 `InstallCancelled`,且**不能**冒出 pack 的取消 + host.dispatch(AppCommand::CancelInstall); + let events = pump(&mut host); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallCancelled))), + "没有导入在跑时,取消安装必须照常给回执(否则用户点了取消毫无反应)" + ); + assert!( + !pack_events(&events) + .iter() + .any(|event| matches!(event, PackEvent::Cancelled)), + "此刻没有导入在跑,不该给内容库发「导入还在后台跑」" + ); + + let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/presenter/src/host/addon/pack/tests/harness.rs b/crates/presenter/src/host/addon/pack/tests/harness.rs new file mode 100644 index 0000000..4376f32 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests/harness.rs @@ -0,0 +1,56 @@ +//! pack 用例的共用夹具:临时目录、文件写入、host 泵。 +//! +//! 拆出去只为一件事:`tests.rs` 已经 429 行(超 400 硬线),而这些 helper 与任何一条断言无关。 +//! `tests.rs` 里各条用例的「抓得住什么」写在用例自己的注释里,这里只放机械动作。 + +use std::path::PathBuf; + +/// 每个用例一个独立临时目录(用完删)。 +pub(super) fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +pub(super) fn write(path: &std::path::Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("parent"); + } + std::fs::write(path, bytes).expect("write"); +} + +/// 泵一次 host:把后台任务收成事件并取走。 +pub(super) fn pump(host: &mut crate::host::CoreHost) -> Vec { + use crate::dto::Backend; + host.tick(std::time::Duration::from_millis(1)); + host.drain_events() +} + +/// 泵到 `done` 为真(最多 ~3 s),返回期间收到的全部事件。 +pub(super) fn pump_until( + host: &mut crate::host::CoreHost, + done: impl Fn(&[crate::dto::AppEvent]) -> bool, +) -> Vec { + let mut all = Vec::new(); + for _ in 0..600 { + all.extend(pump(host)); + if done(&all) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + all +} + +pub(super) fn pack_events(events: &[crate::dto::AppEvent]) -> Vec { + events + .iter() + .filter_map(|event| match event { + crate::dto::AppEvent::Core(crate::dto::CoreEvent::Pack(inner)) => { + Some((**inner).clone()) + } + _ => None, + }) + .collect() +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index ecc2038..276d1f6 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -73,7 +73,7 @@ impl CoreHost { // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次 // 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号 // (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。 - let pack_was_running = addon::cancel_pack(); + let pack_was_running = addon::cancel_pack(&self.env); if pack_was_running { self.send(CoreEvent::Pack(Box::new(PackEvent::Cancelled))); } diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index a9130d3..f225d3d 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -44,7 +44,7 @@ impl CoreHost { HostTask::Pack { event, epoch } => { // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 // 「关一次导入面板」把下载页正在跑的安装静默丢掉。 - if epoch != addon::pack_epoch() { + if epoch != addon::pack_epoch(&self.env) { return; } // 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1), diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 12046f6..bfe0631 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -32,8 +32,10 @@ pub enum HostTask { // M113: import/export /// 导入/导出(M113):一条事件 + 任务代次 /// - /// 代次与 [`HostTask::Install`] 共用同一计数器(`CoreHost::install_epoch`):取消之后回来的 - /// **旧**事件按代次丢掉,面板不会被重新点亮。 + /// 代次是 **pack 自己的一条**(`host::addon::pack` 的 `pack_epoch()`,按 host 分开),**不**与 + /// [`HostTask::Install`] 的 `CoreHost::install_epoch` 共用 —— 共用会让「关一次导入面板」 + /// 把下载页正在跑的安装静默丢掉(复审 r1 P2-7)。取消/关面板之后回来的旧事件按这条代次 + /// 丢掉,面板不会被重新点亮。 Pack { /// 事件载荷(进度 / 结果 / 探测诊断 / 预检 / 完成) event: Box,