feat(packui): M113 phase 2 —— 导入导出真正接线(三个共享枚举各只多一个变体)

搬到 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)。
This commit is contained in:
AskaEth committed 2026-11-05 04:06:39 +08:00
1 parent 2129d8cee6
commit 5f09b8113a
23 files changed
+1336 -73

No files matched your search

+90 -10
View File
@@ -16,7 +16,10 @@
//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use unlucky_presenter::dto::{AppCommand, ContentInstallRequest, ContentQuery, ContentSource};
use unlucky_presenter::dto::{
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
ImportRequestView, PackCommand,
};
use crate::pages::content::{Blocker, Content};
@@ -107,9 +110,8 @@ impl App {
///
/// 1. **页内选择改本地状态**(路径草稿 / 目标实例 / 格式 / 包名 / 展开的冲突)——
/// 它们不属于业务状态,不进 presenter(同 `QueryChanged` 的处理);
/// 2. **意图派指令**(导入 / 取消 / 预检 / 确认写盘)—— 本批只到「改本地状态」这一步,
/// presenter 侧的 `AppCommand::{ImportPack, ExportPack}` 随事件通道一起在下一批接上
/// (见 mission M113 的两批时序:枚举顺序要按 M109 rebase 后的新 main 写一次,不写两遍)。
/// 2. **意图只派指令,不做乐观回显**(导入 / 预检 / 确认写盘 / 关面板):进度、报告、预检结果
/// 全部由 `CoreEvent::Pack` 回来(`dto::pack::event` 的 `PackState::apply` 是唯一写入点)。
pub(super) fn on_pack(&mut self, message: pages::content::PackMessage) {
use pages::content::PackMessage;
match message {
@@ -172,21 +174,99 @@ impl App {
}
PackMessage::AbortExport => {
self.content.pack.export_open = false;
// 连预检结果一起清:留着会让用户以为「还能再点一次确认」
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Dismiss => {
// 报告与探测诊断都归 presenter(下一批清);这里先收起页内的展开状态
// 报告 / 探测诊断 / 预检都在 presenter 里:关面板 = 让 host 清掉它们
// (`PackEvent::Cleared`),于是界面回到入口态,浏览网格重新露出来
self.content.pack.expanded = None;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Dismiss)));
self.view_dirty = true;
}
PackMessage::Import
| PackMessage::Cancel
| PackMessage::Overwrite
| PackMessage::Precheck
| PackMessage::Confirm => {}
PackMessage::Import => {
if let Some(request) = self.pack_import_request(false) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Overwrite => {
// 「以包为准重新导入」:整次导入翻成 Overwrite 档(core 只有全局档)
if let Some(request) = self.pack_import_request(true) {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
request,
)))));
}
}
PackMessage::Cancel => {
// 与面板上那个「取消」同一个指令:host 会把导入的取消标记与代次一起处理
self.dispatch(AppCommand::CancelInstall);
}
PackMessage::Precheck => {
if let Some(request) = self.pack_export_request() {
// 先记下「这次预检对的是哪份请求」,确认写盘时原样发回
self.state.pack.set_export_request(request.clone());
self.view_dirty = true;
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Export(Box::new(
request,
)))));
}
}
PackMessage::Confirm => {
if let Some(request) = self.state.pack.export.request.clone() {
self.dispatch(AppCommand::Pack(Box::new(PackCommand::Confirm(Box::new(
request,
)))));
}
}
}
}
/// 组一份导入请求(路径草稿 → DTO)。草稿为空 ⇒ `None`(按钮不该发指令)。
///
/// `overwrite` 只有「以包为准重新导入」会给 `true`;默认路径永远是 `false`
/// (core 那边就是 `ConflictPolicy::KeepUser`,**绝不默认覆盖用户改过的文件**)。
fn pack_import_request(&self, overwrite: bool) -> Option<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`。包名/版本留空时给缺省值:
/// 包名 = 实例 id、版本 = 页面上的 `1.0.0`。
fn pack_export_request(&self) -> Option<ExportRequestView> {
let instance = self.content.pack.instance.clone()?;
let name = self.content.pack.export_name.trim();
let version = self.content.pack.export_version.trim();
Some(ExportRequestView {
instance: instance.clone(),
format: self.content.pack.format,
name: if name.is_empty() {
instance
} else {
name.to_string()
},
version: if version.is_empty() {
"1.0.0".to_string()
} else {
version.to_string()
},
target: None,
})
}
/// 拖入一个文件/目录(`app/update.rs` 的窗口事件转发到这里)。
pub(super) fn on_pack_dropped(&mut self, path: std::path::PathBuf) -> Task<Message> {
// 拖入是「现在就要看到结果」的动作:先把用户带到内容库页,再落草稿
+4 -5
View File
@@ -146,12 +146,11 @@ impl Content {
target.as_deref(),
);
// M113: import/export —— 进度与报告在 presenter(`AppState.import` / `.export`),
// 页内选择在 `page.pack`。两批落地:本批先接页内选择(入口可用),事件回流在下一批。
// `targets` 下面会被移动进结构体,所以这里先借用它建视图模型。
// M113: import/export —— 进度/报告/预检在 presenter(`AppState.pack`),
// 页内选择在 `page.pack`。`targets` 下面会被移动进结构体,所以这里先借用它建视图模型。
let pack = super::packui::Pack::of(
&unlucky_presenter::dto::ImportState::default(),
&unlucky_presenter::dto::ExportState::default(),
&state.pack.import,
&state.pack.export,
&page.pack,
&targets,
catalog,
@@ -21,7 +21,7 @@ use unlucky_presenter::dto::{
ImportProgressView, ImportStageView, InstallProgressView, InstallStage,
};
use crate::pages::download::{Download, InstallView};
use crate::pages::download::Download;
/// 抛进面板的进度行。
#[derive(Debug, Clone, PartialEq)]
@@ -30,8 +30,8 @@ pub struct Running {
pub pack: String,
/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)
pub stage_line: String,
/// 交给下载页那块面板的进度(**唯一**的进度形状来源)
pub panel: Download,
/// 交给下载页那块面板的进度(**唯一**的进度形状来源;`None` = 面板无话可说)
pub panel: Option<Download>,
}
/// 细档位说明(`步骤 2/5 · 解包覆盖内容`)。
@@ -100,33 +100,19 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
}
/// 进度事件 → 面板要的视图模型。
///
/// 面板入参走 `pages::download::install_panel_model`(M109 合流 M105 后提供的**唯一**入口):
/// 它把 `InstallProgressView` 转成面板要的 `Download`,终态(完成/取消)返回 `None`。
/// 导入的五个阶段都不是终态,所以正常路径下一定是 `Some`;`None` 只做防御(面板没了就只留步骤行)。
///
/// 面板自带那个粗阶段词是**安装**语义(`Resolving version` / `Downloading files` /
/// `Building the version` / `Registering instance`),所以细档位由 [`stage_line`] 说,
/// 二者是「粗/细」关系而不是两套说法——[`panel_stage`] 只挑语义最近的那一档。
pub(super) fn running_of(progress: &ImportProgressView, catalog: Catalog) -> Running {
let panel = panel_progress(progress);
Running {
pack: progress.pack.clone(),
stage_line: stage_line(progress.stage, catalog),
panel: Download {
install: Some(InstallView {
version: panel.version.clone(),
// 只用于 `running()` / `failed()` 的判定;**不**用它给用户看(见模块文档第 3 点)
stage: panel.stage,
stage_label: String::new(),
detail: if progress.is_known() {
catalog.render(
Key::DownloadProgress,
&[
("done", &progress.done.to_string()),
("total", &progress.total.to_string()),
],
)
} else {
catalog.text(Key::DownloadProgressUnknown).to_owned()
},
rate: None,
ratio: progress.ratio(),
known: progress.is_known(),
}),
..Download::loading()
},
panel: crate::pages::download::install_panel_model(&panel, catalog),
}
}
@@ -34,7 +34,10 @@ fn importing_shows_the_shared_install_panel() {
let running = model.running.expect("导入中要有进度");
// 进度形状来自下载页那块面板(`Download.install`),不是本区另造的
assert!(
running.panel.install.is_some(),
running
.panel
.as_ref()
.is_some_and(|panel| panel.install.is_some()),
"进度必须走下载页的安装面板视图模型"
);
assert!(
@@ -27,20 +27,22 @@ pub(super) fn running_block<'a>(
catalog: Catalog,
running: &'a Running,
) -> Element<'a, PageMessage> {
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
false,
running.stage_line.as_str(),
ui.color(Role::OnSurfaceVariant),
),
// 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。
download::install_panel(ui, catalog, &running.panel),
]
let mut block = column![frame::text_role(
ui,
TypeRole::LabelMedium,
false,
running.stage_line.as_str(),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
.width(Length::Fill);
// 同一块面板:进度条、计数行、取消按钮都归它,这里不另画一条。
// `None` 只会在「阶段已收尾」时出现(导入的五个阶段都不是终态),那时只剩步骤行。
if let Some(panel) = &running.panel {
block = block.push(download::install_panel(ui, catalog, panel));
}
block.into()
}
/// 探测失败:把「缺哪个标记」摊开。
+11 -8
View File
@@ -72,8 +72,8 @@ macro_rules! content_entries {
),
ContentIncompatibleLoader => ("Not built for {loader}", "不支持 {loader}"),
ContentModpackNotReady => (
"Installing modpacks is not available yet",
"整合包安装尚未开放"
"Installing modpacks from the library into an instance is not available yet",
"从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"
),
ContentSourceUnavailable => (
"CurseForge needs an API key: add one in Settings",
@@ -92,8 +92,8 @@ macro_rules! content_entries {
),
PackDropActive => ("Release to probe the pack", "松手开始探测"),
PackNoDialog => (
"This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).",
"本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"
"This build has no system file picker: drag the file in, or paste its path below.",
"本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"
),
PackPathLabel => ("Pack path", "整合包路径"),
PackPathPlaceholder => (
@@ -146,8 +146,8 @@ macro_rules! content_entries {
),
PackMissingOffline => ("offline import", "离线导入"),
PackMissingNoSource => (
"no download source (CurseForge file API not wired)",
"拿不到下载地址(CF 文件端点未接通)"
"no download source available yet",
"暂时拿不到下载地址"
),
PackMissingFetchFailed => ("download failed: {detail}", "下载失败:{detail}"),
PackFatalTitle => (
@@ -225,9 +225,12 @@ macro_rules! content_entries {
"only Modrinth knows it, the CF manifest cannot use it, inlined",
"只有 Modrinth 认识,CF 清单用不上,已内联"
),
PackReasonMissingSha512 => ("match has no sha512, inlined", "命中缺 sha512,已内联"),
PackReasonMissingSha512 => (
"the catalogue entry has no sha512 hash, inlined",
"命中缺 sha512,已内联"
),
PackReasonMissingCurseIds => (
"match has no projectID/fileID, inlined",
"the catalogue entry has no CurseForge ids, inlined",
"命中缺 projectID/fileID,已内联"
),
PackReasonDisabled => (
+5 -5
View File
@@ -216,14 +216,14 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("ContentNoInstances", "No instance to install into", "没有可装入的实例"),
("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"),
("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"),
("ContentModpackNotReady", "Installing modpacks is not available yet", "整合包安装尚未开放"),
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
("PackImportTitle", "Import a modpack", "导入整合包"),
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
("PackDropActive", "Release to probe the pack", "松手开始探测"),
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below (a picker needs a dialog dependency that is not in the tree).", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径(文件框需要引入对话框依赖,当前依赖树里没有)。"),
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"),
("PackPathLabel", "Pack path", "整合包路径"),
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
("PackImportAction", "Import", "导入"),
@@ -253,7 +253,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: the import pipeline has one global conflict policy and no per-file decision channel.", "「两者都留(改名)」暂不支持:导入管线只有一套全局冲突策略,没有逐文件决策通道。"),
("PackMissingOffline", "offline import", "离线导入"),
("PackMissingNoSource", "no download source (CurseForge file API not wired)", "拿不到下载地址(CF 文件端点未接通)"),
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"),
("PackFatalHint", "What to try next", "下一步可以试"),
@@ -294,8 +294,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"),
("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"),
("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"),
("PackReasonMissingSha512", "match has no sha512, inlined", "命中缺 sha512,已内联"),
("PackReasonMissingCurseIds", "match has no projectID/fileID, inlined", "命中缺 projectID/fileID,已内联"),
("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"),
("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"),
("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"),
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
+8 -2
View File
@@ -11,8 +11,8 @@ use crate::dto::CreateRequest;
use crate::dto::{
AccountView, AgentEvent, ContentInstallRequest, ContentQuery, ContentResults, DeviceLoginView,
DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, InstanceSummary,
JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, SessionId,
SettingGroupView, VersionCategory, VersionSummary,
JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, PackCommand, PackEvent,
SessionId, SettingGroupView, VersionCategory, VersionSummary,
};
use crate::toast::Toast;
@@ -98,6 +98,9 @@ pub enum CoreEvent {
ContentResults(Box<ContentResults>),
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
ContentFailed(String),
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
@@ -186,6 +189,9 @@ pub enum AppCommand {
SearchContent(ContentQuery),
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
InstallContent(Box<ContentInstallRequest>),
// M113: import/export
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
Pack(Box<PackCommand>),
}
impl AppCommand {
+1 -1
View File
@@ -49,7 +49,7 @@ pub use pack::{
ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState,
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
MissingReasonView, PackFormatView, ProbeView,
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
+2
View File
@@ -32,11 +32,13 @@
//! **绝不默认覆盖用户改过的文件**。
mod conflict;
mod event;
mod export;
mod format;
mod import;
pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView};
pub use event::{PackCommand, PackEvent, PackState};
pub use export::{
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
ExportRequestView, ExportState,
+121
View File
@@ -0,0 +1,121 @@
//! 导入/导出的**指令、事件与面板状态**(M113):`AppCommand::Pack` / `CoreEvent::Pack` 的载荷。
//!
//! ## 为什么整条流程只占一个枚举变体
//!
//! `AppCommand` / `CoreEvent` / `HostTask` 是这条装配链上最拥挤的三个枚举(M106/M107/M108/M109/M114
//! 都在往里加变体)。所以导入导出**不各占一个变体**,而是各自一个 [`PackCommand`] / [`PackEvent`]
//! 子枚举、外面只多一层包装:共享枚举里只多一行,rebase 时不用替别人定序。
//!
//! ## 取消是**界面级**的,不是管线级的(如实写)
//!
//! core 的 `import_pack` 没有取消令牌(`ImportRequest` 里没有 cancel 字段,`progress` 回调是
//! 纯通知、拿不到控制流)。所以「取消导入」在本轮的含义是:
//!
//! 1. 立刻把面板收回([`PackEvent::Progress`] 传 `None`);
//! 2. **丢弃**这一次运行之后回来的事件(代次比较,与 M105 的安装取消同一套做法);
//! 3. 后台那次导入会继续跑到结束——**它不会因为点了取消而停手**。
//!
//! 这条必须在报告里如实登记:把它写成「真的停下来了」就是撒谎。
//!
//! ## 事件 → 状态只有一条路
//!
//! [`PackState::apply`] 是**唯一**的写入点(与 `AppState::apply` 同一条纪律):页面读到的进度、
//! 报告、预检都只能从事件来,页面自己不做乐观回显。
use serde::{Deserialize, Serialize};
use crate::dto::pack::{
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
ImportReportView, ImportRequestView, ImportState, ProbeView,
};
/// app → host 的导入/导出意图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PackCommand {
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
Import(Box<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),
/// 关掉所有面板
Cleared,
}
/// 导入/导出两个面板的状态(`AppState::pack` 的全部内容)。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct PackState {
/// 导入侧(进度 / 结果 / 探测诊断)
pub import: ImportState,
/// 导出侧(预检 / 完成 / 失败)
pub export: ExportState,
}
impl PackState {
/// 事件 → 状态(**唯一**写入点)。
pub fn apply(&mut self, event: PackEvent) {
match event {
PackEvent::Progress(progress) => {
self.import.progress = progress.map(|progress| *progress);
}
PackEvent::Report(report) => {
// 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了)
self.import.progress = None;
self.import.probe = None;
self.import.report = Some(*report);
}
PackEvent::Probe(probe) => {
self.import.progress = None;
self.import.report = None;
self.import.probe = Some(*probe);
}
PackEvent::Preview(preview) => {
self.export.pending = false;
self.export.failed = None;
self.export.done = None;
self.export.preview = Some(*preview);
}
PackEvent::Exported(done) => {
self.export.pending = false;
self.export.failed = None;
// 写完之后预检就过期了:留着会让用户以为「还能再点一次确认」
self.export.preview = None;
self.export.done = Some(*done);
}
PackEvent::ExportFailed(reason) => {
self.export.pending = false;
self.export.failed = Some(reason);
}
PackEvent::Cleared => {
self.import = ImportState::default();
self.export = ExportState::default();
}
}
}
/// 记下「这次预检是对着哪份请求做的」(确认写盘时原样发回)。
pub fn set_export_request(&mut self, request: ExportRequestView) {
self.export.request = Some(request);
self.export.pending = true;
self.export.failed = None;
}
}
+2
View File
@@ -210,6 +210,8 @@ pub struct ExportDoneView {
pub struct ExportState {
/// 预检结果(`None` = 还没预检)。
pub preview: Option<ExportPreviewView>,
/// **这次预检是对着哪份请求做的**(确认写盘时原样发回,防「预检 A、写 B」)。
pub request: Option<ExportRequestView>,
/// 预检在飞。
pub pending: bool,
/// 最近一次写盘的结果。
+4
View File
@@ -34,8 +34,12 @@
mod http;
mod install;
// M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod)
mod pack;
mod sources;
pub(super) use pack::cancel_pack;
#[cfg(test)]
mod tests;
+186
View File
@@ -0,0 +1,186 @@
//! 整合包**导入 / 导出**(M113 `PACKUI`):把 M111/M112 的管线接到内容库页上。
//!
//! ## 这里只做「解析目标 + 翻译 + 转发」
//!
//! 真正的活全在 core:`addon::import::import_pack`(三种格式 → 实例目录)与
//! `addon::export::{scan_instance, export_instance}`(实例目录 → `.mrpack` / CF 清单)。
//! 本模块不重新判定任何东西——三态、逐项原因、三分类全部**逐字段照搬** core 的报告
//! ([`convert`]),于是「UI 说的」与「管线干的」是同一份事实。
//!
//! ## 文件分工(守 400 行)
//!
//! | 文件 | 职责 |
//! |---|---|
//! | 本文件 | `CoreHost::pack` 落点 + 取消标记 + 三条 spawn 共用的目标解析 |
//! | [`import`] | 后台导入(探测 → 目标目录 → 管线 → 报告) |
//! | [`export`] | 后台预检(不写盘)与后台写盘 |
//! | [`convert`] | core 报告 → DTO(逐字段照搬)+ 小工具 |
//! | [`diagnose`] | 探测诊断:「看起来像 X,但缺 Y」 |
//!
//! ## 导出预检与写盘用同一套判据
//!
//! core 的 `export_instance` 没有 dry-run 入口(已报 finding)。预检用 `scan_instance` +
//! 「离线目录 ⇒ 一律内联」这条事实拼出来:`RemoteCatalog` 目前只有 `OfflineCatalog`,
//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。
//! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。
//!
//! ## 取消是**界面级**的
//!
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1:
//!
//! 1. 面板立刻收回(不是等后台回一句话);
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到
//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给
//! core 加 cancel token(已登记 finding,报告里同样划出来)。
mod convert;
mod diagnose;
mod export;
mod import;
#[cfg(test)]
mod tests;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot};
use unlucky_core::addon::pack::PackFormat;
use unlucky_core::game::install::instance::read_instance_config;
use unlucky_core::game::isolation::derive_game_dir;
use crate::dto::{
CoreEvent, ExportRequestView, ImportProgressView, InstanceId, PackCommand, PackEvent,
PackFormatView,
};
use super::super::env::HostEnv;
use super::super::job::{HostTask, JobSender};
/// 正在跑的那次导入的取消标记(模块级槽:`CoreHost` 的字段不在本 mission 的 scope)。
///
/// 语义见模块文档「取消是界面级的」——它挡的是**事件**,不是管线。
/// 用 `Arc<AtomicBool>` 而不是一个裸静态量:导入线程拿一份克隆、`cancel_pack()` 拿另一份,
/// 于是**测试可以各自持有一份**,不会互相串(裸静态量在并行测试里会泄漏取消状态)。
static PACK_CANCEL: Mutex<Option<Arc<AtomicBool>>> = Mutex::new(None);
/// 取消正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。
pub(in crate::host) fn cancel_pack() {
if let Ok(slot) = PACK_CANCEL.lock() {
if let Some(cancel) = slot.as_ref() {
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
}
/// 新一次导入的取消标记(同时把它挂进模块级槽,供 [`cancel_pack`] 用)。
fn fresh_cancel() -> Arc<AtomicBool> {
let cancel = Arc::new(AtomicBool::new(false));
if let Ok(mut slot) = PACK_CANCEL.lock() {
*slot = Some(Arc::clone(&cancel));
}
cancel
}
/// `AppCommand::Pack` 的落点(M113)。
impl super::super::CoreHost {
pub(crate) fn pack(&mut self, command: PackCommand) {
let epoch = self.install_epoch;
match command {
PackCommand::Import(request) => {
let cancel = fresh_cancel();
// 先把面板点亮:用户点了就有反应,不等后台回第一条进度
let name = request
.instance_name
.clone()
.or_else(|| request.instance.clone())
.unwrap_or_default();
self.send(CoreEvent::Pack(Box::new(PackEvent::Progress(Some(
Box::new(ImportProgressView::new(name)),
)))));
import::spawn_import(self.env.clone(), self.tx.clone(), *request, epoch, cancel);
}
PackCommand::Export(request) => {
export::spawn_preview(self.env.clone(), self.tx.clone(), *request, epoch);
}
PackCommand::Confirm(request) => {
export::spawn_write(self.env.clone(), self.tx.clone(), *request, epoch);
}
PackCommand::Dismiss => {
cancel_pack();
// 代次 +1:这次之后回来的旧事件一律作废
self.install_epoch = self.install_epoch.wrapping_add(1);
self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared)));
}
}
}
}
/// 后台任务 → host 的一跳(三个 spawn 共用)。
pub(super) fn send(tx: &JobSender, epoch: u64, event: PackEvent) {
let _ = tx.send(HostTask::Pack {
event: Box::new(event),
epoch,
});
}
/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 全局档)。
pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<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,225 @@
//! core 报告 → DTO(M113):**逐字段照搬,不重算任何结论**。
//!
//! 这一层的唯一职责是翻译:三态、五阶段、四种处置、三种冲突成因、十一种导出原因,
//! 全部一一对应。任何一个「顺手换算一下」都会让 UI 说的和管线干的不是一回事,
//! 所以这里没有一处 re-derive(唯一例外是把 `&'static str` 的稳定码换成人读串)。
//!
//! 翻译用的是 core 的稳定 slug(`as_str()`)+ DTO 的 `from_slug()`,
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
use std::path::{Path, PathBuf};
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
use unlucky_core::addon::import::{
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress,
ImportReport, LoaderTarget, MissingReason,
};
use crate::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
PackFormatView,
};
/// 一次导入结果 → DTO。
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
ImportReportView {
status: ImportStatusView::from_slug(report.status.as_str())
.unwrap_or(ImportStatusView::Failed),
format: report.format.as_deref().and_then(PackFormatView::from_slug),
pack_name: report.pack_name.clone(),
pack_version: report.pack_version.clone(),
game_version: report.game_version.clone(),
loader: report.loader.as_ref().map(loader_label),
version_id: report.version_id.clone(),
target: target.to_path_buf(),
files: report
.files
.iter()
.map(|file| ImportedFileView {
path: file.path.clone(),
action: action_view(file.action),
bytes: file.bytes,
})
.collect(),
conflicts: report.conflicts.iter().map(conflict_view).collect(),
missing: report
.missing
.iter()
.map(|file| MissingFileView {
path: file.path.clone(),
display: file.display.clone(),
reason: missing_view(file.reason),
detail: file.detail.clone(),
})
.collect(),
rejected: report
.rejected_components
.iter()
.map(|component| match &component.version {
Some(version) => format!("{} {version}", component.uid),
None => component.uid.clone(),
})
.collect(),
warnings: report
.warnings
.iter()
.map(|issue| issue.message.clone())
.collect(),
error: report.error.as_ref().map(error_view),
registered: report
.registered
.as_ref()
.map(|registered| registered.name.clone()),
bytes_written: report.bytes_written,
rolled_back: report.rolled_back,
}
}
/// 一次导出结果 → DTO。
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
ExportDoneView {
path: report.path.clone(),
bytes: report.bytes,
remote: u32::try_from(report.remote_count()).unwrap_or(u32::MAX),
inline: u32::try_from(report.inline_count()).unwrap_or(u32::MAX),
skipped: u32::try_from(report.skipped_count()).unwrap_or(u32::MAX),
}
}
/// 一条导入进度 → DTO。
pub(super) fn progress_view(progress: &ImportProgress, pack: &str) -> ImportProgressView {
ImportProgressView {
stage: ImportStageView::from_slug(progress.stage.as_str())
.unwrap_or(ImportStageView::Probe),
done: u32::try_from(progress.done).unwrap_or(u32::MAX),
total: u32::try_from(progress.total).unwrap_or(u32::MAX),
current: progress.current.clone(),
pack: pack.to_string(),
}
}
/// 致命失败 → DTO(稳定码原样带走,日志与 `--json` 用的是同一串)。
fn error_view(error: &ImportError) -> ImportErrorView {
ImportErrorView {
code: error.kind().as_str().to_string(),
label: error.kind().label().to_string(),
message: error.message().to_string(),
hint: error.hint().map(str::to_string),
}
}
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
ConflictFileView {
path: file.path.clone(),
reason: match file.reason {
ConflictReason::UserModified => ConflictReasonView::UserModified,
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
},
choice: match file.choice {
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
},
pack_sha1: file.pack_sha1.clone(),
base_sha1: file.base_sha1.clone(),
disk_sha1: file.disk_sha1.clone(),
pack_size: file.pack_size,
}
}
const fn action_view(action: FileAction) -> FileActionView {
match action {
FileAction::Installed => FileActionView::Installed,
FileAction::Replaced => FileActionView::Replaced,
FileAction::SkippedIdentical => FileActionView::SkippedIdentical,
FileAction::KeptUser => FileActionView::KeptUser,
}
}
const fn missing_view(reason: MissingReason) -> MissingReasonView {
match reason {
MissingReason::Offline => MissingReasonView::Offline,
MissingReason::NoSource => MissingReasonView::NoSource,
MissingReason::FetchFailed => MissingReasonView::FetchFailed,
}
}
pub(super) fn reason_view(reason: FileReason) -> ExportReasonView {
ExportReasonView::from_slug(reason.as_str()).unwrap_or(ExportReasonView::Unreadable)
}
pub(super) fn class_view(class: FileClass) -> ExportClassView {
match class {
FileClass::Remote => ExportClassView::Remote,
FileClass::Inline => ExportClassView::Inline,
FileClass::Skipped => ExportClassView::Skipped,
}
}
/// 加载器 → 人读串(`fabric-loader 0.15.11` / `vanilla 1.20.1`)。
fn loader_label(target: &LoaderTarget) -> String {
format!("{} {}", target.kind.as_str(), target.loader_version)
}
/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。
///
/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。
pub(super) fn failed_report(
pack: PathBuf,
code: &str,
label: &str,
message: &str,
) -> ImportReportView {
ImportReportView {
status: ImportStatusView::Failed,
format: None,
pack_name: Some(pack_stem(&pack)),
pack_version: None,
game_version: None,
loader: None,
version_id: None,
target: pack,
files: Vec::new(),
conflicts: Vec::new(),
missing: Vec::new(),
rejected: Vec::new(),
warnings: Vec::new(),
error: Some(ImportErrorView {
code: code.to_string(),
label: label.to_string(),
message: message.to_string(),
hint: None,
}),
registered: None,
bytes_written: 0,
rolled_back: true,
}
}
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
pub(super) fn pack_stem(pack: &Path) -> String {
pack.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "imported-pack".to_string())
}
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
pub(super) fn slug(text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"pack".to_string()
} else {
trimmed
}
}
@@ -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,140 @@
//! 后台导入(M113):探测 → 目标目录 → core 的 `import_pack` → 报告。
//!
//! 三条纪律:
//!
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
//! 2. **默认档永远是 `KeepUser`**:只有 `request.overwrite == true` 才换成 `Overwrite`;
//! 3. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::import::{
import_pack, ConflictPolicy, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
ImportRequest,
};
use unlucky_core::download::blocking::block_on;
use crate::dto::{ImportRequestView, PackEvent};
use super::super::super::env::HostEnv;
use super::super::super::job::{spawn, JobSender};
use super::convert;
use super::send;
/// 起一个后台导入线程。
pub(super) fn spawn_import(
env: HostEnv,
tx: JobSender,
request: ImportRequestView,
epoch: u64,
cancel: Arc<AtomicBool>,
) {
spawn("pack-import", move || {
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(
request.pack.clone(),
"import_host",
"Target instance is not usable",
&reason,
))),
);
return;
}
};
let pack_name = request
.instance_name
.clone()
.unwrap_or_else(|| convert::pack_stem(&request.pack));
let progress_tx = tx.clone();
let progress_name = pack_name.clone();
let cancel_flag = Arc::clone(&cancel);
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
if cancel_flag.load(Ordering::SeqCst) {
return;
}
send(
&progress_tx,
epoch,
PackEvent::Progress(Some(Box::new(convert::progress_view(
progress,
&progress_name,
)))),
);
});
let engine = super::super::super::engine::engine(env);
let fetcher = DownloadEngineFetcher::new(&engine);
let mut core_request = ImportRequest::new(&request.pack, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
if request.overwrite {
core_request = core_request.with_conflict_policy(ConflictPolicy::Overwrite);
}
if register {
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
}
let report = block_on(import_pack(&core_request));
if cancel.load(Ordering::SeqCst) {
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)
return;
}
send(
tx,
epoch,
PackEvent::Report(Box::new(convert::report_view(&report, &target))),
);
}
/// 导入目标:`(目录, 是否登记新实例)`。
///
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
/// - 没选实例 ⇒ `<data>/instances/<slug>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。
fn target_of(env: &HostEnv, request: &ImportRequestView) -> Result<(PathBuf, bool), String> {
match &request.instance {
Some(id) => super::instance_root(env, id).map(|root| (root, false)),
None => {
let name = request
.instance_name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| convert::pack_stem(&request.pack));
let target = env
.layout
.data()
.join("instances")
.join(convert::slug(&name));
if target.exists() {
return Err(format!("directory already exists: {}", target.display()));
}
std::fs::create_dir_all(&target)
.map_err(|error| format!("create dir failed: {error}"))?;
Ok((target, true))
}
}
}
@@ -0,0 +1,241 @@
//! M113 的两条硬证据(都跑真管线,不用假数据):
//!
//! 1. [`bad_packs_get_a_named_diagnosis`]:**坏包必须有具名失败**,不是静默、不是 panic;
//! 2. [`preview_counts_match_the_written_report`]:预检的「将内联 N / 无法导出 M」与最终
//! `ExportReport` 的数字**逐项相等**(塔台点名的要求:预检不能是骗人的预告)。
use std::path::PathBuf;
use unlucky_core::addon::export::{
export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog,
DEFAULT_CONTENT_ROOTS,
};
use crate::dto::{ExportClassView, ExportRequestView, PackFormatView};
/// 每个用例一个独立临时目录(用完删)。
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
fn write(path: &std::path::Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent");
}
std::fs::write(path, bytes).expect("write");
}
#[test]
fn bad_packs_get_a_named_diagnosis() {
let dir = temp_dir("badpack");
// ① 把文本文件改名成 .mrpack:连 zip 都不是
let fake = dir.join("fake.mrpack");
write(&fake, b"this is not a zip");
let probe = super::diagnose::of(&fake).expect("打不开的包必须有诊断");
assert!(
!probe.detail.is_empty(),
"诊断要给一句具体原因(不是空字符串)"
);
assert!(
probe.detail.to_lowercase().contains("archive") || probe.detail.contains("zip"),
"诊断要说清是哪一步失败:{}",
probe.detail
);
// ② 目录形态:有 overrides/、没有 manifest.json ⇒ 「像 CF,缺 manifest.json」
let dir_pack = dir.join("half");
write(&dir_pack.join("overrides").join("options.txt"), b"fov=90\n");
if let Some(probe) = super::diagnose::of(&dir_pack) {
assert!(
!probe.saw.is_empty(),
"看到了 overrides/ 就该说出来(saw 不能为空)"
);
if probe.looks_like.is_some() {
assert!(
!probe.missing.is_empty(),
"说「像某个格式」就必须说出缺哪个标记文件"
);
}
}
}
#[test]
fn preview_counts_match_the_written_report() {
let dir = temp_dir("export-eq");
let root = dir.join("instance");
write(&root.join("mods").join("sodium.jar"), &[0x11; 4096]);
write(&root.join("mods").join("lithium.jar"), &[0x22; 2048]);
write(&root.join("mods").join("old.jar.disabled"), &[0x33; 512]);
write(&root.join("config").join("options.txt"), b"fov=90\n");
// 白名单之外的目录不该被扫(否则「将内联 N」会虚高)
write(&root.join("logs").join("latest.log"), b"noise\n");
let request = ExportRequestView {
instance: "survival".to_string(),
format: PackFormatView::Modrinth,
name: "Survival".to_string(),
version: "1.0.0".to_string(),
target: Some(dir.join("out.mrpack")),
};
let env_root = dir.join("data");
std::fs::create_dir_all(&env_root).expect("env");
let env = crate::host::HostEnv::at_root(&env_root);
// 预检(不写盘)
let preview = super::export::preview_of(&root, &env, &request);
// 真写盘:用同一套判据(离线目录 + 默认内容目录 + 直接给的快照)
let snapshot = InstanceSnapshot {
game_version: "1.20.1".to_string(),
loaders: Vec::new(),
};
let target = dir.join("out.mrpack");
let core_request = ExportRequest::new(
&root,
&target,
&unlucky_core::addon::pack::modrinth::MODRINTH,
)
.with_name("Survival")
.with_version("1.0.0")
.with_catalog(&OfflineCatalog)
.with_snapshot(&snapshot);
let report = export_instance(&core_request).expect("导出应当成功");
// 三个计数逐项相等(塔台要求:不是「大致对」)
assert_eq!(
preview.inline_count(),
report.inline_count(),
"内联条数不一致:预检 {} vs 报告 {}",
preview.inline_count(),
report.inline_count()
);
assert_eq!(
preview.skipped_count(),
report.skipped_count(),
"无法导出条数不一致"
);
assert_eq!(
preview.remote_count(),
report.remote_count(),
"远程引用条数不一致"
);
// 逐条:同一个路径在两边必须是同一个类
for file in &preview.files {
let written = report
.files
.iter()
.find(|candidate| candidate.path == file.path)
.unwrap_or_else(|| panic!("预检多出一条:{}", file.path));
let expected = match file.class {
ExportClassView::Remote => unlucky_core::addon::export::FileClass::Remote,
ExportClassView::Inline => unlucky_core::addon::export::FileClass::Inline,
ExportClassView::Skipped => unlucky_core::addon::export::FileClass::Skipped,
};
assert_eq!(written.class, expected, "{} 的归类不一致", file.path);
}
// 白名单外的 logs/ 不许进包(防「扫太宽」把预检数字弄虚)
assert!(
!report
.files
.iter()
.any(|file| file.path.starts_with("logs/")),
"logs/ 不该被扫进整合包"
);
// 同时钉一条:`scan_instance` 的口径就是预览用的那一套
let roots: Vec<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);
}
+4
View File
@@ -70,6 +70,8 @@ impl CoreHost {
if let Some(cancel) = &self.install_cancel {
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
}
// M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 代次 +1
addon::cancel_pack();
self.install_epoch = self.install_epoch.wrapping_add(1);
self.install = None;
self.send(CoreEvent::InstallProgress(None));
@@ -176,6 +178,8 @@ impl CoreHost {
}
// M109:把某个内容项目装进指定实例。取消标记与安装面板共用同一条路
// (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。
// M113: import/export —— 导入 / 预检 / 确认写盘 / 关面板
AppCommand::Pack(command) => self.pack(*command),
AppCommand::InstallContent(request) => {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
self.install_cancel = Some(std::sync::Arc::clone(&cancel));
+7
View File
@@ -40,6 +40,13 @@ impl CoreHost {
Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))),
Err(reason) => self.send(CoreEvent::ContentFailed(reason)),
},
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
HostTask::Pack { event, epoch } => {
if epoch != self.install_epoch {
return;
}
self.send(CoreEvent::Pack(event));
}
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
// 旧任务的下一条「下载中」事件立刻又把它点亮。
+12 -1
View File
@@ -9,7 +9,7 @@ use std::sync::mpsc::{Receiver, Sender};
use crate::dto::{
AccountView, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId,
InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LoaderKind, LogLine, LogOutcome,
LogSession, NoticeView, SessionId, VersionSummary,
LogSession, NoticeView, PackEvent, SessionId, VersionSummary,
};
use crate::toast::Toast;
@@ -29,6 +29,17 @@ pub enum HostTask {
},
/// 内容库检索(M109;`Err` = 一句人读原因)
Content(Box<Result<ContentResults, String>>),
// M113: import/export
/// 导入/导出(M113):一条事件 + 任务代次
///
/// 代次与 [`HostTask::Install`] 共用同一计数器(`CoreHost::install_epoch`):取消之后回来的
/// **旧**事件按代次丢掉,面板不会被重新点亮。
Pack {
/// 事件载荷(进度 / 结果 / 探测诊断 / 预检 / 完成)
event: Box<PackEvent>,
/// 任务代次
epoch: u64,
},
/// 安装进度(高频,host 只做聚合与转发)
Install {
/// 进度快照
+6 -1
View File
@@ -8,7 +8,7 @@ use crate::dialogs::DialogQueue;
use crate::dto::{
AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport,
InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine,
LogSession, NoticeView, Progress, SettingGroupView, VersionSummary,
LogSession, NoticeView, PackState, Progress, SettingGroupView, VersionSummary,
};
use crate::prefs::UiPrefs;
// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。
@@ -110,6 +110,8 @@ pub struct AppState {
pub live_session: Option<String>,
/// 内容库(M109):条件 / 结果 / 请求态
pub content: ContentState,
/// 整合包导入/导出:进度 / 结果 / 探测诊断 / 导出预检(M113)
pub pack: PackState,
}
impl Default for AppState {
@@ -148,6 +150,7 @@ impl AppState {
wizard: WizardState::new(),
live_session: None,
content: ContentState::default(),
pack: PackState::default(),
}
}
@@ -271,6 +274,8 @@ impl AppState {
.insert(mc_version, result);
}
CoreEvent::NoticesChanged(notices) => self.notices = notices,
// M113: import/export
CoreEvent::Pack(event) => self.pack.apply(*event),
CoreEvent::ContentResults(results) => self.apply_content(*results),
CoreEvent::ContentFailed(reason) => {
self.content.pending = false;