CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
341 lines
15 KiB
Rust
341 lines
15 KiB
Rust
//! 整合包**导入 / 导出**(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` 用真管线钉住这个等式。
|
||
//!
|
||
//! ## 取消是**管线级**的(M195 起)
|
||
//!
|
||
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1,而**同一个标记**
|
||
//! 从 M195 起就是 core 的取消令牌(`ImportRequest::with_cancel`):
|
||
//!
|
||
//! 1. 面板立刻收回(不是等后台回一句话);
|
||
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
|
||
//! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、
|
||
//! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的
|
||
//! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表;
|
||
//!
|
||
//! **收尾永远把真实结局送到界面**(M195 r3):取消可能落在最后一个检查点之后 —— 那时管线
|
||
//! 已经跑完(用户的同名文件被覆盖、实例已登记),也可能回滚没做完。这两种结果以前被
|
||
//! `cancel` 的早退 + 代次过滤一起丢掉,界面只剩一句无条件的"已停手并回滚"。现在
|
||
//! `import::finish` 无条件发 `PackEvent::Report`,`HostPackState::terminal_epochs` 给
|
||
//! "刚被取消的那一次"留一条**只放行 Report** 的一次性通道([`take_terminal_epoch`]),
|
||
//! DTO 的 `ImportReportView::cancel` 三档把区别带到界面;
|
||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`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)]
|
||
struct HostPackState {
|
||
epoch: u64,
|
||
in_flight: usize,
|
||
/// 「刚被取消、还欠界面一份终态报告」的那几个代次(M195 r3)。
|
||
///
|
||
/// `cancel_pack` 会把代次 +1,于是被取消那次运行交上来的 `PackEvent::Report` 会被 `handle`
|
||
/// 按代次丢掉——可那份报告才是**真实结局**(停手并回滚 / 回滚没做完 / 取消太晚其实已装完)。
|
||
/// 记在这里的代次允许**一条** `Report` 穿透代次过滤,取走即销;新一轮导入(`fresh_cancel`)
|
||
/// 清空整表,旧运行的报告不会污染新面板。
|
||
terminal_epochs: Vec<u64>,
|
||
}
|
||
|
||
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 的取消**
|
||
/// (M195 起 core 真的停手并回滚,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。
|
||
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);
|
||
}
|
||
}
|
||
let running = in_flight(env) > 0;
|
||
if running {
|
||
// 记下**马上要被作废的那个代次**:那次运行随后交上来的报告是它的真实结局,必须能穿透
|
||
// 代次过滤(否则"取消太晚、其实已经装完"在界面上永远不成立)。重复取消只记一次。
|
||
let stale = pack_epoch(env);
|
||
with_state(env, |state| {
|
||
if !state.terminal_epochs.contains(&stale) {
|
||
state.terminal_epochs.push(stale);
|
||
}
|
||
});
|
||
}
|
||
bump_pack_epoch(env);
|
||
running
|
||
}
|
||
|
||
/// 这个代次是不是「刚被取消、还欠一份终态报告」的那一次(**取走即销**:只放行一条)。
|
||
///
|
||
/// 只给 `PackEvent::Report` 用:取消之后的**进度**事件仍旧一律按代次丢掉(面板早就收回了,
|
||
/// 再点亮它就是「点了取消没反应」的旧病)。
|
||
pub(in crate::host) fn take_terminal_epoch(env: &HostEnv, epoch: u64) -> bool {
|
||
with_state(env, |state| {
|
||
match state.terminal_epochs.iter().position(|seen| *seen == epoch) {
|
||
Some(index) => {
|
||
state.terminal_epochs.remove(index);
|
||
true
|
||
}
|
||
None => false,
|
||
}
|
||
})
|
||
}
|
||
|
||
/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。
|
||
///
|
||
/// 顺手清掉「还欠终态报告」的代次:新一次运行占住面板,上一次的结局不该再冒出来。
|
||
fn fresh_cancel(env: &HostEnv) -> Arc<AtomicBool> {
|
||
with_state(env, |state| state.terminal_epochs.clear());
|
||
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::Scan(request) => {
|
||
// 预检只读:不点亮导入面板(那会让用户以为已经在装了),也不计"在跑导入"。
|
||
let cancel = fresh_cancel(&self.env);
|
||
import::spawn_scan(self.env.clone(), self.tx.clone(), *request, epoch, cancel);
|
||
}
|
||
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}"))
|
||
}
|