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:
1 parent
2129d8cee6
commit
5f09b8113a
23 files changed
+1336
-73
No files matched your search
@@ -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}"))
|
||||
}
|
||||
Reference in new issue
Block a user