diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index a2f1bd0..1b583ca 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -92,12 +92,20 @@ 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 —— 拖入整合包(本版唯一的选文件通道;窗口级事件, + // 所以入口收起时也照样接得住。细节在 `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 910501a..230f229 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}; @@ -95,6 +98,179 @@ 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. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果 + /// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。 + 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.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss))); + self.view_dirty = true; + } + PackMessage::Dismiss => { + // 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们 + // (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来 + self.content.pack.expanded = None; + self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss))); + self.view_dirty = true; + } + 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`。**「Save to」必须真的进请求** + /// (复审 r1 P1-1:以前这里硬编码 `target: None`,那个输入框是死的)。 + fn pack_export_request(&self) -> Option { + let instance = self.content.pack.instance.clone()?; + Some(pages::content::request_export( + &self.content.pack, + &instance, + )) + } + + /// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。 + 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) -> 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.rs b/crates/app-iced/src/pages/content.rs index 27bec5b..736ee55 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::{request_export, 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..f9f9199 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,16 @@ impl Content { target.as_deref(), ); + // M113: import/export —— 进度/报告/预检在 presenter(`AppState.pack`), + // 页内选择在 `page.pack`。`targets` 下面会被移动进结构体,所以这里先借用它建视图模型。 + let pack = super::packui::Pack::of( + &state.pack.import, + &state.pack.export, + &page.pack, + &targets, + catalog, + ); + Self { kind: page.kind, query: ContentQueryView { @@ -170,6 +182,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..c734909 --- /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::{request_export, 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..47ad0ea --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/demo.rs @@ -0,0 +1,245 @@ +//! 出图夹具(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` / `failed` / `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, ImportErrorView, + 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()); + } + // 致命态:整包回滚 + 容忍域仍在(展示两类失败的视觉分野) + "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); + } + // 取消态:面板收回了,但「后台还在跑」这句话要留在屏上 + "cancelled" => import.cancelled = true, + "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::Installed, 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/` was not unpacked (client import)".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, + }); + } + // 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/bad:name.jar".to_string(), + class: ExportClassView::Skipped, + reason: ExportReasonView::UnsafePath, + 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..46bfa9d --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -0,0 +1,227 @@ +//! 导入/导出区的视图模型:**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, request_of as request_export, 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, + /// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌), + /// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。 + pub cancelled: bool, + /// 取消提示的正文 + pub cancelled_note: &'static str, + /// 导入结果 + 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)), + cancelled: import.cancelled, + cancelled_note: catalog.text(Key::PackCancelled), + 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.cancelled + && !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..d4e3f37 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/export.rs @@ -0,0 +1,254 @@ +//! 导出:格式选择 + **预检**(不写盘)。 +//! +//! ## 预检与写盘是两次调用,同一套判据 +//! +//! [`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, + 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; + +/// 预检块(**还没写盘**)。 +#[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..646056f --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/progress.rs @@ -0,0 +1,121 @@ +//! 阶段与进度:**把导入进度映射成下载页那块面板的入参**(不另画一套进度 UI)。 +//! +//! ## 任务书第 5 条怎么落 +//! +//! 「沿用既有安装面板组件与状态机,禁止再写第二套进度 UI」—— +//! +//! 1. 导入的 `ImportStageView` / 计数先映射成面板的入参形状([`panel_progress`] → +//! `InstallProgressView` → [`InstallView`]); +//! 2. 画法交给 `pages::download::install_panel`(同一个函数、同一套几何,进度条 / 计数 / +//! 取消按钮都归它); +//! 3. 任务书要的「解包 / overrides / 补文件」三档由**一行步骤说明**([`stage_line`])承载。 +//! +//! **已知重叠(如实写,别再说成修好了)**:面板自带的阶段词是**安装**语义 +//! (`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::{ + 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, + /// 交给下载页那块面板的进度(**唯一**的进度形状来源;`None` = 面板无话可说) + pub panel: Option, +} + +/// 细档位说明(`步骤 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(), + } +} + +/// 进度事件 → 面板要的视图模型。 +/// +/// 面板入参走 `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: crate::pages::download::install_panel_model(&panel, catalog), + } +} 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..c056317 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/report.rs @@ -0,0 +1,255 @@ +//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。 +//! +//! ## 为什么容忍域与致命域连字段类型都不同 +//! +//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。 +//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以: +//! +//! | 域 | 类型 | 画法 | +//! |---|---|---| +//! | 容忍(缺文件 / 被拒 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_title: Option, + /// 被拒 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_title: (!report.rejected.is_empty()).then(|| { + catalog.render( + Key::PackReportRejected, + &[("count", &report.rejected.len().to_string())], + ) + }), + rejected: report + .rejected + .iter() + .map(|component| Row { + path: component.clone(), + note: catalog.text(Key::PackReportRejectedNote).to_owned(), + }) + .collect(), + warnings: report.warnings.clone(), + // 「以包为准重新导入」只在**导入到已有实例**时有意义(复审 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()), + } +} + +/// 字节数的人读串。 +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..991fcbd --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -0,0 +1,394 @@ +//! 导入/导出区视图模型的守门(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, + }; + // 「以包为准」只在导入到**已有实例**时给(复审 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, "默认收起"); + 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()), + ..page.clone() + }; + 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()); +} + +#[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/tests.rs b/crates/app-iced/src/pages/content/packui/tests.rs new file mode 100644 index 0000000..f0d0d5a --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/tests.rs @@ -0,0 +1,107 @@ +//! 导入/导出区的守门(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 + .as_ref() + .is_some_and(|panel| 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..7e41d0a --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view.rs @@ -0,0 +1,257 @@ +//! 导入/导出区的画法:入口(拖拽 + 路径)→ 进度(**下载页那块面板**)→ 结果 → 导出预检。 +//! +//! ## 版面 +//! +//! ```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![].spacing(ui.space(Space::X3)).width(Length::Fill); + + // **一次只摊开一件事**:入口(拖什么/装到哪)、进度、结果、导出预检各占一态。 + // 全都堆在一起时,内容库页正列没有整页滚动 ⇒ 动作按钮被顶出屏幕(出图实测:冲突态与 + // 导出预检态的「确认写盘 / 关闭」都落在 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)); + } 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 { + // 探测失败要和路径输入框在一起:用户下一步就是改路径 + block = block.push(probe_block(ui, catalog, probe)); + } + 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..563c1e6 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -0,0 +1,323 @@ +//! 导出块与预检块:格式选择 → **预检**(不写盘)→ 确认写盘。 + +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::{bounded, 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)) + .placeholder(catalog.text(Key::PackExportOutputPlaceholder)), + move |text| intent(PackMessage::SetOutput(text)), + )); + // 有预检结果时这里只留「预检」(`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), + ), + )); + } + block = block.push(actions); + 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 { + // 动作在 `preview_block` 里紧跟摘要(明细再长也不会把「确认写盘」顶出屏幕) + block = block.push(preview_block(ui, catalog, preview)); + } + } + + 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); + + // **动作紧跟摘要**:明细(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 个(这里不展开)」说的是**内联样例**,两句挨着容易被读成矛盾 + block = block.push(tolerable_title( + ui, + catalog.render( + Key::PackPreviewSkippedTitle, + &[("count", &preview.skipped.len().to_string())], + ), + )); + // 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, + )); + } + + // 样例 + 已知损失一起套有界滚动:预检是「摊开给你看」的块,条目多时它会把 + // 「确认写盘」顶出屏幕(出图时实测到了) + 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, + TypeRole::BodySmall, + false, + path.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if let Some(more) = &preview.more_line { + extras = extras.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + more.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + if !preview.notes.is_empty() { + extras = extras.push(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::PackPreviewNotes), + ui.color(Role::OnSurfaceVariant), + )); + for note in &preview.notes { + extras = extras.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + note.as_str(), + ui.color(Role::OnSurfaceVariant), + )); + } + } + let extras_rows = preview.inline_sample.len() + preview.notes.len(); + block = block.push(bounded(ui, extras, extras_rows, 3, 120.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 new file mode 100644 index 0000000..a0e5156 --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/parts.rs @@ -0,0 +1,128 @@ +//! 共用小件:带底色的容器、容忍域的标题、逐条列表。 +//! +//! 三个都在两个以上地方用到,所以单独一个文件——不是为了行数好看,是因为 +//! 「容忍域长什么样」只有一处定义,改颜色/图标才不用找第二个副本。 + +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() +} + +/// 容忍域的标题(`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_owned( + 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() +} + +/// 逐条列表的**有界**容器:条目多时给一个固定高度的滚动区。 +/// +/// 为什么必须要有界:这一块长在内容库页正列的上方,而整页没有滚动(只有结果列表自己滚)⇒ +/// 报告一长,「关闭 / 以包为准重新导入 / 确认写盘」这些动作就被顶出屏幕。出图时实测到了这一点 +/// (冲突态与导出预检态的动作按钮落在 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/progress.rs b/crates/app-iced/src/pages/content/packui/view/progress.rs new file mode 100644 index 0000000..2afb9cf --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/progress.rs @@ -0,0 +1,97 @@ +//! 进度块(**整块复用下载页的安装面板**)与探测失败块。 + +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> { + 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); + + // 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。 + // `None` 只会在「阶段已收尾」时出现(导入的五个阶段都不是终态),那时只剩步骤行。 + if let Some(panel) = &running.panel { + block = block.push(download::install_panel(ui, catalog, panel)); + } + block.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..8cc8f1d --- /dev/null +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -0,0 +1,296 @@ +//! 结果块:三态标题 + 容忍域逐条 + 冲突逐条 + 致命块。 + +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::{bounded, 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, title.clone()), + bounded( + ui, + rows_block(ui, catalog, &report.missing), + report.missing.len(), + 3, + 120.0, + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + } + 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()), + // 与 missing / conflicts 同一条口径:条目多时框起来滚,别把动作顶出屏幕 + bounded( + ui, + rows_block(ui, catalog, &report.rejected), + report.rejected.len(), + 3, + 110.0, + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill), + ); + } + + if let Some(title) = &report.conflicts_title { + block = block.push( + column![ + tolerable_title(ui, title.clone()), + 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), + ); + } + + 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..0f492a8 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", @@ -83,6 +83,185 @@ 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.", + "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。" + ), + PackPathLabel => ("Pack path", "整合包路径"), + PackPathPlaceholder => ( + "D:\\packs\\Better MC.mrpack", + "D:\\整合包\\更好的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 => ("Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), + PackReportMissing => ( + "Could not fetch (tolerated): {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", "离线导入"), + PackMissingNoSource => ( + "no download source available yet", + "暂时拿不到下载地址" + ), + 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", "保存到"), + PackExportOutputPlaceholder => ( + "empty = suggested path", + "留空 = 自动给一个路径" + ), + 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 · cannot export: {skipped}", + "将内联 {inline} 个文件 · 无法导出 {skipped} 项" + ), + PackPreviewRemote => ( + "{count} files can be referenced remotely", + "{count} 个文件可走远程引用" + ), + PackPreviewInlineBytes => ( + "{bytes} copied into overrides/", + "{bytes} 会原样拷进 overrides/" + ), + 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 (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)"), + 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 => ( + "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", + "路径过不了包内规整,已跳过" + ), + PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index b95f360..013644f 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -216,7 +216,92 @@ 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.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"), + ("PackPathLabel", "Pack path", "整合包路径"), + ("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的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", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"), + ("PackReportMissing", "Could not fetch (tolerated): {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", "离线导入"), + ("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"), + ("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", "保存到"), + ("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"), + ("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 · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"), + ("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"), + ("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"), + ("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 (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)"), + ("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", "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", "路径过不了包内规整,已跳过"), + ("PackReasonUnreadable", "cannot read it, 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 810e0ca..8c1529d 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, 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 new file mode 100644 index 0000000..7fbd1fb --- /dev/null +++ b/crates/presenter/src/dto/pack.rs @@ -0,0 +1,51 @@ +//! 整合包导入/导出 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 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, +}; +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/event.rs b/crates/presenter/src/dto/pack/event.rs new file mode 100644 index 0000000..61b0038 --- /dev/null +++ b/crates/presenter/src/dto/pack/event.rs @@ -0,0 +1,128 @@ +//! 导入/导出的**指令、事件与面板状态**(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), + /// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌), + /// 所以留一位给界面说清「还在跑、结果作废」。 + Cancelled, + /// 关掉所有面板 + 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::Cancelled => { + self.import.progress = None; + self.import.cancelled = true; + } + 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 new file mode 100644 index 0000000..3ec6941 --- /dev/null +++ b/crates/presenter/src/dto/pack/export.rs @@ -0,0 +1,221 @@ +//! 导出侧 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, + /// **这次预检是对着哪份请求做的**(确认写盘时原样发回,防「预检 A、写 B」)。 + pub request: 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..2109558 --- /dev/null +++ b/crates/presenter/src/dto/pack/import.rs @@ -0,0 +1,341 @@ +//! 导入侧 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, + /// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上—— + /// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。 + pub cancelled: bool, + /// 最近一次的结果(`None` = 还没导过)。 + pub report: Option, + /// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。 + pub probe: Option, +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 2734124..37e3b58 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, pack_epoch}; + #[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..3137a4b --- /dev/null +++ b/crates/presenter/src/host/addon/pack.rs @@ -0,0 +1,290 @@ +//! 整合包**导入 / 导出**(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,报告里同样划出来); +//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」, +//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」, +//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成 +//! 「导入会在后台继续跑完 —— 本次结果作废」这句。 + +mod convert; +mod diagnose; +mod export; +mod import; + +#[cfg(test)] +mod tests; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +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}; + +/// pack 的**每 host** 状态:代次 + 在跑计数 + 本次运行的取消标记。 +/// +/// ## 为什么不是裸静态量(复审 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, +} + +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() +} + +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) +} + +/// 这个 host 当前的 pack 代次(`handle` 用它判断事件是否已被取消/关面板作废)。 +pub(in crate::host) fn pack_epoch(env: &HostEnv) -> u64 { + with_state(env, |state| state.epoch) +} + +/// 作废这个 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 句柄**。 +/// +/// 复审 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(env); + in_flight(env) > 0 +} + +/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。 +fn fresh_cancel(env: &HostEnv) -> Arc { + let cancel = Arc::new(AtomicBool::new(false)); + 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 +} + +/// `AppCommand::Pack` 的落点(M113)。 +impl super::super::CoreHost { + pub(crate) fn pack(&mut self, command: PackCommand) { + let epoch = pack_epoch(&self.env); + match command { + PackCommand::Import(request) => { + let cancel = fresh_cancel(&self.env); + // 句柄在**派发点**就计上,随线程一起结束(含早退与 panic) + let guard = InFlight::begin(&self.env); + // 先把面板点亮:用户点了就有反应,不等后台回第一条进度 + 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, + guard, + ); + } + 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 => { + // 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门) + cancel_pack(&self.env); + 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..ec19cbf --- /dev/null +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -0,0 +1,256 @@ +//! 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`:界面只有一种失败形状,用户不用学第二套。 +/// `target` 是**目标实例目录**(不知道就给空路径),不是包路径(复审 r1 P2-9 顺手纠的 DTO 语义)。 +pub(super) fn failed_report( + target: PathBuf, + pack: &Path, + 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, + 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()) +} + +/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。 +/// +/// 判据(复审 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 == '_' || ch == ' '); + if faithful { + 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(); + 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..10f5c2c --- /dev/null +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -0,0 +1,176 @@ +//! 后台导入(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 unlucky_core::addon::import::ImportStatus; + +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, + inflight: super::InFlight, +) { + spawn("pack-import", move || { + // 句柄跟着这次运行走到**出口**:正常收尾、两条早退、panic 展开都会 drop ⇒ 计数减一。 + // 以前是手工 `finished()` 放在 `block_on()` 之后,两条早退路径直接漏掉(复审 r2 P2-1)。 + let _inflight = inflight; + 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( + // 目标目录不可用 ⇒ 如实给空路径(不把包路径冒充成实例目录) + PathBuf::new(), + &request.pack, + "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 report.status == ImportStatus::Failed && register { + // 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话 + // 下一次重试会撞「directory already exists」(复审 r1 P2-9)。 + remove_if_empty(&target); + } + if cancel.load(Ordering::SeqCst) { + // 取消之后不再打扰界面(面板已经收回,代次也已经作废)。 + // 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 —— + // 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。 + return; + } + send( + tx, + epoch, + PackEvent::Report(Box::new(convert::report_view(&report, &target))), + ); +} + +/// 目录里没有东西就删掉(失败回滚后的收尾;非空一律不动)。 +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` 还要另外装。 +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 => { + 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::dir_name(&name)); + if target.exists() { + // 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」 + 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}"))?; + } + 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..b9127fe --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -0,0 +1,389 @@ +//! M113 的证据(都跑真管线 / 真 host,不用假数据): +//! +//! | 用例 | 钉住什么 | +//! |---|---| +//! | [`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`]。 + +mod harness; + +use harness::{pack_events, pump, pump_until, temp_dir, write}; + +use unlucky_core::addon::export::{ + export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog, + DEFAULT_CONTENT_ROOTS, +}; + +use crate::dto::{ExportClassView, ExportRequestView, PackFormatView}; + +#[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); +} + +#[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}"); + + // 判据与注释对齐(复审 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] +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_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")); + + 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 09af427..276d1f6 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,12 +70,22 @@ impl CoreHost { if let Some(cancel) = &self.install_cancel { cancel.store(true, std::sync::atomic::Ordering::SeqCst); } + // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次 + // 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号 + // (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。 + let pack_was_running = addon::cancel_pack(&self.env); + 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) { @@ -176,6 +186,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..f225d3d 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 { /// 后台消息 → 事件。 @@ -40,6 +40,20 @@ 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 } => { + // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 + // 「关一次导入面板」把下载页正在跑的安装静默丢掉。 + if epoch != addon::pack_epoch(&self.env) { + return; + } + // 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1), + // 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。 + if matches!(*event, PackEvent::Report(_)) { + self.refresh_instances(); + } + 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..bfe0631 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,19 @@ pub enum HostTask { }, /// 内容库检索(M109;`Err` = 一句人读原因) Content(Box>), + // M113: import/export + /// 导入/导出(M113):一条事件 + 任务代次 + /// + /// 代次是 **pack 自己的一条**(`host::addon::pack` 的 `pack_epoch()`,按 host 分开),**不**与 + /// [`HostTask::Install`] 的 `CoreHost::install_epoch` 共用 —— 共用会让「关一次导入面板」 + /// 把下载页正在跑的安装静默丢掉(复审 r1 P2-7)。取消/关面板之后回来的旧事件按这条代次 + /// 丢掉,面板不会被重新点亮。 + 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;