Merge branch 'feat/m113-packui-modpack-import-and-export-su'

This commit is contained in:
AskaEth committed 2026-11-05 04:59:48 +08:00
commit e7b04c4afe
45 files changed
+5650 -21

No files matched your search

+14 -6
View File
@@ -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()
}
+177 -1
View File
@@ -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<ImportRequestView> {
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<ExportRequestView> {
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<Message> {
// 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿
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<Message> {
// 状态没变就不重画:winit 对同一批文件会连发几次 hover 事件
let changed = self.content.pack.hover != hover;
self.on_pack(pages::content::PackMessage::Hover(hover));
if changed {
self.refresh_view();
}
Task::none()
}
+4
View File
@@ -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;
@@ -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,
}
}
@@ -37,4 +37,6 @@ pub enum Message {
Retry,
/// 装进目标实例
Install,
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
Pack(super::packui::PackMessage),
}
@@ -73,6 +73,8 @@ pub struct Content {
pub versions: Vec<String>,
/// 关键词草稿
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,
}
}
@@ -18,6 +18,8 @@ pub struct PageState {
pub target: Option<String>,
/// 关键词输入框里的草稿(**未提交**;提交后才进 `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(),
}
}
}
@@ -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(),
};
// 没选中项目 ⇒ 不能装(也不该说「成功」)
@@ -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)
}
@@ -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<Target> {
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<InstanceId> = 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(),
],
}
}
@@ -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<InstanceId>),
/// 换新实例名(`None` 语义由 `SetInstance` 承担,这里只改名字)
SetInstanceName(String),
/// 换导出格式
SetFormat(PackFormatView),
/// 换导出包名
SetExportName(String),
/// 换导出包版本
SetExportVersion(String),
/// 换落盘路径(留空 = 让 host 给缺省)
SetOutput(String),
/// 跑预检(**不写盘**)
Precheck,
/// 确认写盘(预检已经有结果才可达)
Confirm,
/// 放弃这次导出(关掉预检面板,不写盘)
AbortExport,
/// 打开导出面板
OpenExport,
}
@@ -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<InstanceId>,
/// 新实例名
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<String>,
}
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<PackFormatView>,
/// 「看起来像 X,但缺 Y」(或「没有任何已知格式认领」)
pub lead: String,
/// 包里实际看到的标记(host 已截断,只给代表性几条)
pub saw: Vec<String>,
/// 兜底说明(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<PackTarget>,
/// 选中的目标实例
pub instance: Option<InstanceId>,
/// 新实例名
pub instance_name: String,
/// 正在导入(有它就画那块面板)
pub running: Option<Running>,
/// 探测失败诊断
pub probe: Option<Probe>,
/// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌),
/// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。
pub cancelled: bool,
/// 取消提示的正文
pub cancelled_note: &'static str,
/// 导入结果
pub report: Option<Report>,
/// 导出块
pub export: Export,
}
impl Pack {
/// presenter 快照 + 页面选择 → 视图模型(**唯一的真数据入口**)。
pub fn of(
import: &ImportState,
export: &ExportState,
page: &PackPageState,
targets: &[Target],
catalog: Catalog,
) -> Self {
let candidates: Vec<PackTarget> = 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;
@@ -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<Row>,
/// 内联文件样例
pub inline_sample: Vec<String>,
/// `还有 N 个文件…`
pub more_line: Option<String>,
/// core 的 `notes`(已知损失)
pub notes: Vec<String>,
}
/// 导出块。
#[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<PackTarget>,
/// 选中的实例
pub instance: Option<unlucky_presenter::dto::InstanceId>,
/// 包名
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<Preview>,
/// 写盘完成行
pub done: Option<String>,
/// 失败原因
pub failed: Option<String>,
}
/// 导出一块。
pub(super) fn export_of(
state: &ExportState,
page: &PackPageState,
targets: Vec<PackTarget>,
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<Row> = 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::<Vec<_>>();
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),
}
}
@@ -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<Download>,
}
/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)。
#[must_use]
pub fn stage_line(stage: ImportStageView, catalog: Catalog) -> String {
let total = ImportStageView::ALL.len();
let index = ImportStageView::ALL
.iter()
.position(|item| *item == stage)
.map_or(1, |at| at + 1);
catalog.render(
Key::PackStageLine,
&[
("index", &index.to_string()),
("total", &total.to_string()),
("stage", catalog.text(stage_key(stage))),
],
)
}
/// 阶段 → 文案 key。
#[must_use]
pub const fn stage_key(stage: ImportStageView) -> Key {
match stage {
ImportStageView::Probe => Key::PackStageProbe,
ImportStageView::Extract => Key::PackStageExtract,
ImportStageView::Fetch => Key::PackStageFetch,
ImportStageView::Commit => Key::PackStageCommit,
ImportStageView::Register => Key::PackStageRegister,
}
}
/// 导入阶段 → 安装面板的粗阶段(**唯一映射处**)。
///
/// `Extract` / `Commit` 都落到 `Installing`:对那块面板而言它们都是「在写盘」,区别由
/// [`stage_line`] 说给用户。
#[must_use]
pub const fn panel_stage(stage: ImportStageView) -> InstallStage {
match stage {
ImportStageView::Probe => InstallStage::Planning,
ImportStageView::Extract | ImportStageView::Commit => InstallStage::Installing,
ImportStageView::Fetch => InstallStage::Downloading,
ImportStageView::Register => InstallStage::Registering,
}
}
/// 导入进度 → 下载页面板的入参。
///
/// `version` / `instance` 都填包名:面板第一行显示的就是「这次在装什么」,
/// 而导入的对象是包,不是某个版本 id。
#[must_use]
pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
InstallProgressView {
version: progress.pack.clone(),
instance: progress.pack.clone(),
loader: String::new(),
stage: panel_stage(progress.stage),
files_done: u64::from(progress.done),
files_total: u64::from(progress.total),
bytes_done: 0,
bytes_total: 0,
speed_bps: 0,
eta_secs: 0,
message: progress.current.clone().unwrap_or_default(),
}
}
/// 进度事件 → 面板要的视图模型。
///
/// 面板入参走 `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),
}
}
@@ -0,0 +1,255 @@
//! 导入结果的视图模型:**三态 + 容忍域逐条 + 致命块**。
//!
//! ## 为什么容忍域与致命域连字段类型都不同
//!
//! 任务书:「失败原因逐条展示(能容忍的缺文件与致命错误必须视觉可区分)」。
//! 只在颜色上区分不够——颜色一关掉(截图/高对比/色弱)就没了。所以:
//!
//! | 域 | 类型 | 画法 |
//! |---|---|---|
//! | 容忍(缺文件 / 被拒 component / 注记) | [`Row`](路径 + 原因) | 中性容器 + `Info` 图标 + 逐条列表 |
//! | 致命(已回滚) | [`Fatal`](大类 + 原文 + 建议 + 稳定码) | `ErrorContainer` + `Error` 图标 + 标题换措辞 |
//!
//! 一个 `Option<Fatal>` 与一个 `Vec<Row>` 之间没有「误合并」的写法,这比任何样式约定都硬。
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<String>,
/// 磁盘 SHA-1(`None` = 文件不在)
pub disk_sha1: Option<String>,
/// 展开中(展开才画三方哈希那行)
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<String>,
}
/// 导入结果块。
#[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<Fatal>,
/// 缺文件标题
pub missing_title: Option<String>,
/// 缺文件逐条(容忍域)
pub missing: Vec<Row>,
/// 冲突标题
pub conflicts_title: Option<String>,
/// 冲突逐条
pub conflicts: Vec<ConflictRow>,
/// 被拒 component 的标题(带计数,与下面逐条行数一致)
pub rejected_title: Option<String>,
/// 被拒 component 逐条
pub rejected: Vec<Row>,
/// 注记逐条
pub warnings: Vec<String>,
/// 能不能给「以包为准重新导入」(有冲突、且实例可用时才有意义)
pub can_overwrite: bool,
/// 「两者都留改名」为何不支持(有冲突时如实写)
pub overwrite_note: Option<String>,
}
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<String> = 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()
}
@@ -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<String> = 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<ExportFileView>) -> 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(), "取消态不能被当成「什么都没有」");
}
@@ -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());
}
@@ -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<Element<'a, PageMessage>> = 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()
}
@@ -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::<Vec<_>>())
.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()
}
@@ -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>>,
) -> 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::<Vec<_>>(),
)
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
/// 逐条列表的**有界**容器:条目多时给一个固定高度的滚动区。
///
/// 为什么必须要有界:这一块长在内容库页正列的上方,而整页没有滚动(只有结果列表自己滚)⇒
/// 报告一长,「关闭 / 以包为准重新导入 / 确认写盘」这些动作就被顶出屏幕。出图时实测到了这一点
/// (冲突态与导出预检态的动作按钮落在 900px 以下)。
///
/// 少于 `rows_threshold` 条时直接铺开:给两行内容套一个 120px 的空框,比溢出更难看。
pub(super) fn bounded<'a>(
ui: &'a Ui,
content: impl Into<Element<'a, PageMessage>>,
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()
}
@@ -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)
}
@@ -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::<Vec<_>>()
)
.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,
}
}
+12 -3
View File
@@ -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` 存在)。
+181 -2
View File
@@ -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", "读不出来,已跳过"),
} }
};
}
+86 -1
View File
@@ -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", "读不出来,已跳过"),
];
+8 -2
View File
@@ -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<ContentResults>),
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
ContentFailed(String),
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -186,6 +189,9 @@ pub enum AppCommand {
SearchContent(ContentQuery),
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
InstallContent(Box<ContentInstallRequest>),
// M113: import/export
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
Pack(Box<PackCommand>),
}
impl AppCommand {
+10
View File
@@ -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};
+51
View File
@@ -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,
};
+71
View File
@@ -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<Self> {
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<Self> {
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<String>,
/// 磁盘现状的 SHA-1(文件不存在时 `None`)。
pub disk_sha1: Option<String>,
/// 包内声明的字节数。
pub pack_size: u64,
}
+128
View File
@@ -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<ImportRequestView>),
/// 跑**预检**(只扫描分类,**不写盘**)
Export(Box<ExportRequestView>),
/// 确认写盘。载荷是**预检时那一份请求**,不是页面当前字段——否则「预检 A、写 B」。
Confirm(Box<ExportRequestView>),
/// 关掉面板(导入结果 / 探测诊断 / 导出预检都收起来,回到入口态)
Dismiss,
}
/// host → app 的导入/导出事件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PackEvent {
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
Progress(Option<Box<ImportProgressView>>),
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
Report(Box<ImportReportView>),
/// 探测失败:这个包看起来像什么、缺哪个标记文件
Probe(Box<ProbeView>),
/// 导出预检结果
Preview(Box<ExportPreviewView>),
/// 导出写盘完成
Exported(Box<ExportDoneView>),
/// 导出/预检在 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;
}
}
+221
View File
@@ -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<Self> {
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<Self> {
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<ExportFileView>,
/// 已知的表达不了的差异 / 取舍(core 的 `notes`)。
pub notes: Vec<String>,
}
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<PathBuf>,
}
/// 导出完成(写盘之后的账)。
#[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<ExportPreviewView>,
/// **这次预检是对着哪份请求做的**(确认写盘时原样发回,防「预检 A、写 B」)。
pub request: Option<ExportRequestView>,
/// 预检在飞。
pub pending: bool,
/// 最近一次写盘的结果。
pub done: Option<ExportDoneView>,
/// 失败原因(预检或写盘)。
pub failed: Option<String>,
}
+47
View File
@@ -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<Self> {
match slug {
"curseforge" => Some(Self::Curseforge),
"modrinth" => Some(Self::Modrinth),
"multimc" => Some(Self::Multimc),
_ => None,
}
}
}
+341
View File
@@ -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> {
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<Self> {
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<Self> {
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<InstanceId>,
/// 新实例的展示名(`None` = 用包名)。
pub instance_name: Option<String>,
/// 冲突翻档:`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<String>,
/// 包名(面板标题那一行)。
pub pack: String,
}
impl ImportProgressView {
/// 空进度(刚派发,后端还没回第一条)。
#[must_use]
pub fn new(pack: impl Into<String>) -> 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<Self> {
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<String>,
/// 人读标识(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<String>,
}
/// 一次导入的完整结果。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportReportView {
/// 三态。
pub status: ImportStatusView,
/// 命中的格式(`None` = 探测都没过)。
pub format: Option<PackFormatView>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 加载器(人读串,如 `fabric-loader 0.15.11`)。
pub loader: Option<String>,
/// 实例绑定的版本 id。
pub version_id: Option<String>,
/// 目标实例目录。
pub target: PathBuf,
/// 落地的文件。
pub files: Vec<ImportedFileView>,
/// 冲突清单。
pub conflicts: Vec<ConflictFileView>,
/// 缺文件清单(容忍)。
pub missing: Vec<MissingFileView>,
/// MultiMC 不支持的 component(逐项)。
pub rejected: Vec<String>,
/// 注记。
pub warnings: Vec<String>,
/// 致命错误(`status == Failed` 时必有)。
pub error: Option<ImportErrorView>,
/// 登记的实例名。
pub registered: Option<String>,
/// 本次写入的字节数。
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<PackFormatView>,
/// 该格式**缺**的标记文件(逐条,按判据顺序)。
pub missing: Vec<String>,
/// 包里实际看到的标记(逐条)。
pub saw: Vec<String>,
/// 一句兜底说明(也是 core 的原文:试过哪些格式)。
pub detail: String,
}
/// 导入侧的宿主状态(`AppState::import` 的全部内容)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ImportState {
/// 正在跑的进度(`None` = 没在跑)。
pub progress: Option<ImportProgressView>,
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
pub cancelled: bool,
/// 最近一次的结果(`None` = 还没导过)。
pub report: Option<ImportReportView>,
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
pub probe: Option<ProbeView>,
}
+4
View File
@@ -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;
+290
View File
@@ -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<AtomicBool>`,导入线程拿克隆,`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<Vec<(PathBuf, HostPackState)>> = Mutex::new(Vec::new());
static PACK_CANCEL: Mutex<Vec<(PathBuf, Arc<AtomicBool>)>> = Mutex::new(Vec::new());
/// 这张表的键(数据根;`HostEnv::at_root` 的每个测试根都不同)。
fn host_key(env: &HostEnv) -> PathBuf {
env.layout.home().to_path_buf()
}
fn with_state<T>(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<AtomicBool> {
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<PathBuf, String> {
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<InstanceSnapshot> {
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())
}
}
}
/// 缺省落盘路径:`<data>/exports/<name>-<version>.<ext>`。
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}"))
}
@@ -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`);**一旦折过别的字符**(中文、`.`、
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
/// 都会得到 `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
}
}
@@ -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<ProbeView> {
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<String> = 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,
})
}
@@ -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<String> = DEFAULT_CONTENT_ROOTS
.iter()
.map(|entry| (*entry).to_string())
.collect();
let (files, skipped) = scan_instance(root, &roots);
let mut rows: Vec<ExportFileView> = 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}"))),
}
});
}
@@ -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<AtomicBool>,
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<AtomicBool>,
) {
// 探测先过一遍:失败就只给具名诊断(「看起来像 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);
}
}
/// 导入目标:`(目录, 是否登记新实例)`。
///
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
/// 注意 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))
}
}
}
@@ -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<String> = 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);
}
@@ -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<crate::dto::AppEvent> {
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<crate::dto::AppEvent> {
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<crate::dto::PackEvent> {
events
.iter()
.filter_map(|event| match event {
crate::dto::AppEvent::Core(crate::dto::CoreEvent::Pack(inner)) => {
Some((**inner).clone())
}
_ => None,
})
.collect()
}
+14 -2
View File
@@ -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));
+16 -2
View File
@@ -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)` 刚把面板收回,
// 旧任务的下一条「下载中」事件立刻又把它点亮。
+14 -1
View File
@@ -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<Result<ContentResults, String>>),
// M113: import/export
/// 导入/导出(M113):一条事件 + 任务代次
///
/// 代次是 **pack 自己的一条**(`host::addon::pack` 的 `pack_epoch()`,按 host 分开),**不**与
/// [`HostTask::Install`] 的 `CoreHost::install_epoch` 共用 —— 共用会让「关一次导入面板」
/// 把下载页正在跑的安装静默丢掉(复审 r1 P2-7)。取消/关面板之后回来的旧事件按这条代次
/// 丢掉,面板不会被重新点亮。
Pack {
/// 事件载荷(进度 / 结果 / 探测诊断 / 预检 / 完成)
event: Box<PackEvent>,
/// 任务代次
epoch: u64,
},
/// 安装进度(高频,host 只做聚合与转发)
Install {
/// 进度快照
+6 -1
View File
@@ -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<String>,
/// 内容库(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;