Merge branch 'feat/m113-packui-modpack-import-and-export-su'
This commit is contained in:
commit
e7b04c4afe
45 files changed
+5650
-21
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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` 存在)。
|
||||
|
||||
@@ -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", "读不出来,已跳过"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", "读不出来,已跳过"),
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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)` 刚把面板收回,
|
||||
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
||||
|
||||
@@ -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 {
|
||||
/// 进度快照
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user