Files
Unlucky-Launcher/crates/presenter/src/host/addon/pack.rs
T
AskaEth 0522a41a52
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
release: v0.2.5
2026-11-12 12:06:59 +08:00

341 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 整合包**导入 / 导出**(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}"))
}