Files
Unlucky-Launcher/crates/presenter/src/host/addon/pack/import.rs
T
AskaEth d616d65ea1
CI / runtime smoke (macos arm64, probe) (push) Skipped
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.2.3
2026-11-08 00:15:58 +00:00

264 lines
10 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/M160):探测 → **预检(冲突清单)** → 目标目录 → core 的 `import_pack` → 报告。
//!
//! 四条纪律:
//!
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
//! 2. **先预检再导入**(M160):预检只读,把"会盖掉用户哪几个文件"算清楚。没有冲突就直接
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
//! 路径在 core 侧落到"保留";
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::import::{
import_pack, scan_pack_online, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
ImportRequest,
};
use unlucky_core::download::blocking::block_on;
use crate::dto::{ImportRequestView, PackEvent};
use unlucky_core::addon::import::ImportStatus;
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>,
inflight: super::InFlight,
) {
spawn("pack-import", move || {
// 句柄跟着这次运行走到**出口**:正常收尾、两条早退、panic 展开都会 drop ⇒ 计数减一。
// 以前是手工 `finished()` 放在 `block_on()` 之后,两条早退路径直接漏掉(复审 r2 P2-1)。
let _inflight = inflight;
run(&env, &tx, &request, epoch, cancel);
});
}
/// 起一个后台**预检**线程(M160:只读,不写盘)。
pub(super) fn spawn_scan(
env: HostEnv,
tx: JobSender,
request: ImportRequestView,
epoch: u64,
cancel: Arc<AtomicBool>,
) {
spawn("pack-scan", move || {
// 探测先过一遍:失败就只给具名诊断,不空跑一遍预检。
if let Some(probe) = super::diagnose::of(&request.pack) {
send(&tx, epoch, PackEvent::Probe(Box::new(probe)));
return;
}
let target = match target_of(&env, &request) {
Ok((target, _)) => target,
Err(reason) => {
// 目标实例不可用:与导入路径同一形状的失败报告(界面不用学第二套)。
send(
&tx,
epoch,
PackEvent::Report(Box::new(convert::failed_report(
PathBuf::new(),
&request.pack,
"import_host",
"Target instance is not usable",
&reason,
))),
);
return;
}
};
// 预检本身只读(不写盘),但 M162 起会**先补全再判**:缺 `hashes.sha1` 的 mrpack 条目
// 按 sha512 反查 Modrinth。补不出仍按 M160 的口径计数,所以冷启动没网也不影响判定。
let engine = super::super::super::engine::engine(&env);
let fetcher = DownloadEngineFetcher::new(&engine);
let core_request = ImportRequest::new(&request.pack, &target).with_fetcher(&fetcher);
let scan = block_on(scan_pack_online(&core_request));
if cancel.load(Ordering::SeqCst) {
return;
}
// 先发预检结果(`error` 也照原样带出去:调用方按"有没有冲突"分流要用它)。
send(
&tx,
epoch,
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
);
// **预检自己没算出来 ⇒ 必须再发一条结果事件**(复审 r1 P2-1)。
//
// 只发 `Scan(error)` 的话,页面那边 `pick` 为空(它只由 `conflicts` 生成)、进度与报告
// 又被 `Scan` 清空 ⇒ 落回入口态,用户点完「导入」屏幕上一个字都不变(静默空转)。
// 走 `Report` 就回到 M160 之前那条既有形状:结果块的「致命 —— 已回滚」把原因画出来,
// UI 一行不用改;`code`/`label`/`message`/`hint` 全部从 `scan.error` 搬。
if let Some(error) = &scan.error {
send(
&tx,
epoch,
PackEvent::Report(Box::new(convert::failed_report_of(
target,
&request.pack,
error,
))),
);
}
});
}
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(
// 目标目录不可用 ⇒ 如实给空路径(不把包路径冒充成实例目录)
PathBuf::new(),
&request.pack,
"import_host",
"Target instance is not usable",
&reason,
))),
);
return;
}
};
if register {
if let Err(reason) = prepare_target(&target) {
send(
tx,
epoch,
PackEvent::Report(Box::new(convert::failed_report(
target,
&request.pack,
"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)
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
.with_decisions(convert::decisions_of(&request.decisions));
if register {
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
}
let report = block_on(import_pack(&core_request));
if report.status == ImportStatus::Failed && register {
// 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话
// 下一次重试会撞「directory already exists」(复审 r1 P2-9)。
remove_if_empty(&target);
}
if cancel.load(Ordering::SeqCst) {
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
// 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 ——
// 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。
return;
}
send(
tx,
epoch,
PackEvent::Report(Box::new(convert::report_view(&report, &target))),
);
}
/// 目录里没有东西就删掉(失败回滚后的收尾;非空一律不动)。
fn remove_if_empty(dir: &std::path::Path) {
let empty = std::fs::read_dir(dir)
.map(|mut entries| entries.next().is_none())
.unwrap_or(false);
if empty {
let _ = std::fs::remove_dir(dir);
}
}
/// 导入目标:`(目录, 是否登记新实例)` —— **只算路径,不建目录**(预检要能只读地用它)。
///
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。
pub(super) 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::dir_name(&name));
if target.exists() {
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
let mut entries = std::fs::read_dir(&target)
.map_err(|error| format!("read instance dir failed: {error}"))?;
if entries.next().is_some() {
return Err(format!(
"directory already exists and is not empty: {}",
target.display()
));
}
}
Ok((target, true))
}
}
}
/// 真要写盘之前建目录(**预检不调它**:预检是只读的,不建任何东西)。
pub(super) fn prepare_target(target: &std::path::Path) -> Result<(), String> {
std::fs::create_dir_all(target).map_err(|error| format!("create dir failed: {error}"))
}