搬到 M109 rebase 后的新 main(26e764e)之后按**新 main 的形状**写一次,不写两遍。
## 接线
- `dto/pack/event.rs`(新):`PackCommand` / `PackEvent` / `PackState::apply`。
整条流程在三个最挤的共享枚举里各只占**一个**变体(`AppCommand::Pack` / `CoreEvent::Pack` /
`HostTask::Pack{event, epoch}`),子枚举承载细节 —— rebase 时不用替别人定序。
- `events.rs` / `job.rs` / `handle.rs` / `dispatch.rs` / `state.rs`:变体与分支,全部带
`// M113: import/export` 锚点。`state.rs` 只多 1 个字段 + 1 条 apply 分支(399 行)。
- `host/addon/**`(新目录,父文件只加 `mod pack;` + 再导出):
- `pack.rs`:`CoreHost::pack` 落点 + 取消标记 + 实例目录/快照/格式/缺省路径解析
- `pack/import.rs`:探测 → 目标目录 → `import_pack` → 报告(默认 KeepUser,显式才 Overwrite)
- `pack/export.rs`:预检(`scan_instance`,**不写盘**)与写盘(`export_instance`)
- `pack/convert.rs`:core 报告 → DTO,逐字段照搬(三态/五阶段/四处置/三成因/十一原因)
- `pack/diagnose.rs`:探测诊断「像 X 缺 Y」(按标记文件的有无,不看文件名)
- `app/update/content.rs`:导入 / 以包为准 / 预检 / 确认写盘 / 关面板真的派指令;
预检时先记下那份请求,确认时**原样发回**(防「预检 A、写 B」)。
- `pages/content/model.rs`:读 `state.pack`(不再是默认空态)。
- 面板接口按新形状改:`install_panel_model(&InstallProgressView, catalog) -> Option<Download>`
(`InstallView` 不再从 `pages/download` 导出)。
## 取消(如实)
`CancelInstall` 现在同时置导入的取消标记 + 代次 +1:面板立刻收回、旧事件按代次丢弃。
但 core 的 `import_pack` 没有取消令牌、进度回调也拿不到控制流 ⇒ **后台那次导入会跑完**。
所以「取消」的真实含义是「不再显示 + 结果作废」,已在模块文档与报告里写清,并单开 finding。
取消标记用 `Arc<AtomicBool>` 而不是裸静态量:裸静态量会在并行测试间泄漏取消状态(实测到过)。
## 图看出来的三处(塔台要求)
1. `no download source (CurseForge file API not wired)` → `no download source available yet`,
API 名只留在 DTO 的 detail 与日志里;顺带把 `sha512` / `projectID/fileID` 也改成用户话。
2. 右栏 `Installing modpacks is not available yet` → `Installing modpacks from the library into
an instance is not available yet`(那句在 i18n 词表里,不在 M109 页面代码里,所以我自己改)。
3. 结果面板接线后可以关:`Dismiss` / `AbortExport` 发 `PackCommand::Dismiss` → host 回
`PackEvent::Cleared` → 回到入口/浏览态,不再常驻。
## 证据(三条都是真跑,不是假数据)
- `bad_packs_get_a_named_diagnosis`:把文本改名成 .mrpack → 有具名诊断(不是静默、不 panic);
目录形态有 overrides/ 无 manifest.json → 说得出「缺哪个标记」。
- `a_real_pack_runs_through_the_pipeline_end_to_end`:造一个真 MultiMC 实例目录当包,
走 `import::run` 真路径,断言事件流里有进度 + 结果、格式=MutliMC、报告有写盘文件、文件真落地、
代次原样带回。
- `preview_counts_match_the_written_report`:同一实例上跑预检与 `export_instance`,
inline / skipped / remote **三项计数逐项相等**,且逐条路径的归类一致。
门禁:cargo check ✓ / clippy -D warnings(三 crate)EXIT=0 /
cargo test(app-iced 504 + 6 守卫,i18n 24,presenter 181)全绿 / rustfmt --check ✓ /
所有改动文件 ≤400 行(`state.rs` 399、`addon.rs` 106,pack 子目录最大 338)。
222 lines
7.3 KiB
Rust
222 lines
7.3 KiB
Rust
//! 导出侧 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>,
|
|
}
|