feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停
用户实跑反馈两条,本轮各自定位到根因并修掉:
1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
`AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
`AppCommand::CreateInstance`。
- 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
`AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
- 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
- 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。
2. 「点取消没反应」= 真 bug,两跳各断一半:
- 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
`InstallDone` 带代次回来,代次不符一律丢。
- 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
返回新的 `InstallErrorKind::Cancelled`。
- 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
「哪些留着、哪些一定没发生」说清(D21)。
实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。
文案全部进 i18n(中英,9 条新 key + 金表同步)。
This commit is contained in:
1 parent
d690d09b84
commit
30b1ff6f48
39 files changed
+2550
-518
No files matched your search
@@ -14,31 +14,32 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::{
|
||||
LoaderInstallPlan, LoaderKind as CoreLoaderKind, LoaderPlanInput, LoaderSource,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::{
|
||||
execute, InstallError, InstallPlan, InstallReport, PlanInput, RemoteSource,
|
||||
};
|
||||
use unlucky_core::game::install::{InstallError, InstallReport};
|
||||
|
||||
use crate::dto::{CreateRequest, InstallStage, LoaderKind};
|
||||
use crate::toast::{Toast, ToastKind};
|
||||
|
||||
use super::env::HostEnv;
|
||||
use super::install_journal::Journal;
|
||||
use super::install_processors::{self, StageInput};
|
||||
use super::install_processors;
|
||||
use super::job::{spawn, HostTask, JobSender};
|
||||
use super::meter::{sink_for, Meter};
|
||||
use super::meter::Meter;
|
||||
|
||||
mod routes;
|
||||
|
||||
use routes::{install_loader, install_vanilla};
|
||||
|
||||
/// 文件级并发上限(与 CLI 同值:16 路压满带宽又不至于打爆镜像)。
|
||||
pub const CONCURRENCY: usize = 16;
|
||||
|
||||
/// 用户取消的稳定文案(阶段边界生效)。
|
||||
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
|
||||
/// 用户取消的稳定文案。
|
||||
///
|
||||
/// 三件事一句话说清(D21 的口径:半成品不许冒充成品,断点材料不留在半截状态):
|
||||
/// ① 怎么停的(`execute_with_cancel` 不再起新文件,在飞的那个就地掐断);
|
||||
/// ② 什么被留着(**已经下完**的文件,重装续用);
|
||||
/// ③ 什么一定没发生(实例未登记、版本清单没落盘;在飞文件的 `.ullpart` 由 `PartFile::Drop` 清掉)。
|
||||
pub const CANCELLED: &str =
|
||||
"已取消:剩下的文件没有开始下载,已下好的文件保留(重装续用),实例未登记";
|
||||
|
||||
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
|
||||
struct Job<'a> {
|
||||
@@ -93,9 +94,18 @@ pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||
}
|
||||
|
||||
/// 后台安装(向导提交 / 下载页行内安装共用)。
|
||||
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
|
||||
///
|
||||
/// `epoch` 是本次任务的代次([`super::CoreHost`] 的 `install_epoch`):它随每条进度回流,
|
||||
/// 让 host 丢掉「已被取消 / 被下一轮顶掉」的旧任务的后续事件。
|
||||
pub fn spawn_install(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: CreateRequest,
|
||||
cancel: Arc<AtomicBool>,
|
||||
epoch: u64,
|
||||
) {
|
||||
spawn("install", move || {
|
||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
|
||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request, epoch)));
|
||||
let journal = Journal::open(&env);
|
||||
journal.line(&format!(
|
||||
"—— 开始安装 {}({} {})——",
|
||||
@@ -114,7 +124,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
||||
let mut progress = meter
|
||||
.lock()
|
||||
.map(|guard| guard.progress.clone())
|
||||
.unwrap_or_else(|_| Meter::new(&request).progress);
|
||||
.unwrap_or_else(|_| Meter::new(&request, epoch).progress);
|
||||
match result {
|
||||
Ok(name) => {
|
||||
journal.line(&format!("完成:实例 {name} 已登记"));
|
||||
@@ -124,6 +134,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Ok(name),
|
||||
epoch,
|
||||
});
|
||||
let _ = tx.send(HostTask::Toast(Box::new(Toast {
|
||||
id: 0,
|
||||
@@ -142,6 +153,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
||||
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
|
||||
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
|
||||
let text = if cancelled {
|
||||
journal.line(CANCELLED);
|
||||
reason
|
||||
} else {
|
||||
journal.line(&format!("安装未完成:{reason}"));
|
||||
@@ -151,6 +163,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(text),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -171,11 +184,14 @@ fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
||||
let _ = job
|
||||
.tx
|
||||
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||
let _ = job.tx.send(HostTask::Install {
|
||||
progress: Box::new(guard.progress.clone()),
|
||||
epoch: guard.epoch,
|
||||
});
|
||||
}
|
||||
|
||||
// 段边界检查(M105):`execute_with_cancel` 自己会掐断在飞的文件,这里管的是**段与段之间**
|
||||
// ——原版段与加载器段、文件段与登记之间,取消都必须在下一段开工前生效。
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
@@ -185,12 +201,22 @@ fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||
install_loader(job, &engine, request)?
|
||||
};
|
||||
|
||||
// **取消之后绝不登记实例**(M105):文件段已经在跑了,下完那一刻用户可能早已点了取消;
|
||||
// 这里不拦的话,取消会留下一个「用户以为没装、其实已经装好并登记」的实例——
|
||||
// 那正是「半成品污染」里最难解释的一种(列表里凭空多一条)。
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
job.journal
|
||||
.line("已取消:文件段跑完但用户已取消,不登记实例(版本清单是否落盘见上)");
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(InstallStage::Registering, "正在登记实例…");
|
||||
let _ = job
|
||||
.tx
|
||||
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||
let _ = job.tx.send(HostTask::Install {
|
||||
progress: Box::new(guard.progress.clone()),
|
||||
epoch: guard.epoch,
|
||||
});
|
||||
}
|
||||
let created_at =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||
@@ -204,152 +230,16 @@ fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
||||
fn install_vanilla(
|
||||
job: &Job<'_>,
|
||||
engine: &DownloadEngine,
|
||||
request: &CreateRequest,
|
||||
) -> Result<String, String> {
|
||||
let env = job.env;
|
||||
let version = request.version.trim();
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let now = now_ms();
|
||||
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||
block_on(async {
|
||||
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
||||
let resolved = source.resolve(version).await.map_err(install_error)?;
|
||||
let fetched = match resolved
|
||||
.asset_index
|
||||
.as_ref()
|
||||
.filter(|index| index.url.is_some())
|
||||
{
|
||||
Some(reference) => {
|
||||
let (index, bytes) = source.asset_index(reference).await.map_err(install_error)?;
|
||||
Some((index, env.game.asset_index(&reference.id), bytes))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut plan = InstallPlan::build(PlanInput {
|
||||
layout: &env.game,
|
||||
version_id: version,
|
||||
resolved: &resolved,
|
||||
asset_index: fetched.as_ref().map(|(index, _, _)| index),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some((_, dest, bytes)) = fetched {
|
||||
plan.prefetch(dest, bytes);
|
||||
}
|
||||
if !plan.is_installable() {
|
||||
return Err(format!(
|
||||
"版本 {version} 装不全:{}",
|
||||
plan.warnings.join(";")
|
||||
));
|
||||
}
|
||||
let (bytes_total, _) = plan.total_bytes();
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.progress.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
|
||||
guard.progress.bytes_total = bytes_total;
|
||||
guard.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("开始下载 {} 个文件…", plan.file_count()),
|
||||
);
|
||||
}
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
}
|
||||
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
|
||||
.await
|
||||
.map_err(install_error)?;
|
||||
ensure_complete(&report)?;
|
||||
Ok::<String, String>(version.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
|
||||
fn install_loader(
|
||||
job: &Job<'_>,
|
||||
engine: &DownloadEngine,
|
||||
request: &CreateRequest,
|
||||
) -> Result<String, String> {
|
||||
let env = job.env;
|
||||
let version = request.version.trim();
|
||||
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
||||
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
||||
let meta_cache = env.layout.meta_cache();
|
||||
let loader_version = request.loader_version.trim().to_string();
|
||||
let explicit = (!loader_version.is_empty()).then_some(loader_version.as_str());
|
||||
let release = block_on(async {
|
||||
LoaderSource::new(engine, &meta_cache)
|
||||
.resolve(kind, version, explicit)
|
||||
.await
|
||||
})
|
||||
.map_err(|error| error.to_string())?;
|
||||
job.journal.line(&format!(
|
||||
"加载器解析:{} {} → {}",
|
||||
release.kind.label(),
|
||||
release.loader_version,
|
||||
release.version_id
|
||||
));
|
||||
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &env.game,
|
||||
release: &release,
|
||||
environment: &Environment::host(),
|
||||
vanilla_installed: env.game.is_version_installed(version),
|
||||
});
|
||||
if plan.vanilla_install_needed {
|
||||
let mut vanilla = request.clone();
|
||||
vanilla.loader = LoaderKind::Vanilla;
|
||||
install_vanilla(job, engine, &vanilla)?;
|
||||
}
|
||||
// 两段(加载器库 + 处理器依赖库)在动手前一次预记账:进度条只走一条单调曲线,
|
||||
// 处理器依赖库下完不会先满格、再在加载器库开始时回落到一半。
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
let (bytes, _) = plan.install.total_bytes();
|
||||
guard.expect(plan.files().len(), bytes);
|
||||
}
|
||||
// Forge/NeoForge:installer processors 必须先跑完,`execute` 才允许写版本 JSON。
|
||||
if release.requires_installer() {
|
||||
let input = StageInput {
|
||||
kind: release.kind,
|
||||
mc_version: &release.mc_version,
|
||||
version_id: &release.version_id,
|
||||
profile: release.install_profile.as_ref(),
|
||||
installer: release.installer.as_deref(),
|
||||
installed: env.game.is_version_installed(&release.version_id),
|
||||
};
|
||||
// `None` = 这一版不需要跑处理器(版本 JSON 已在盘上 / profile 没有 client 侧步骤),
|
||||
// 进度与日志都由处理器段自己写全,这里只消费返回值。
|
||||
let _ = install_processors::stage(
|
||||
env,
|
||||
engine,
|
||||
&input,
|
||||
&install_processors::CORE,
|
||||
&job.reporter(),
|
||||
job.cancel,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||
guard.stage(
|
||||
InstallStage::Downloading,
|
||||
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
||||
);
|
||||
}
|
||||
/// 安装错误 → 用户可读文案(**取消不算失败**:给那句稳定文案,不给「失败 + 日志路径」)。
|
||||
///
|
||||
/// 判据用 job 自己的取消标志而不是错误大类:core 的 `execute_with_cancel` 只在阶段边界与
|
||||
/// 每个文件开工前看标志,取消与「最后一个文件刚好失败」可能同时成立——那种情况下用户点了取消,
|
||||
/// 文案就该说取消(否则他会以为取消导致了失败)。
|
||||
fn job_error(job: &Job<'_>, error: InstallError) -> String {
|
||||
if job.cancel.load(Ordering::SeqCst) {
|
||||
return Err(CANCELLED.to_string());
|
||||
return CANCELLED.to_string();
|
||||
}
|
||||
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||
let report = block_on(async {
|
||||
execute(engine, &plan.install, CONCURRENCY, Some(sink))
|
||||
.await
|
||||
.map_err(install_error)
|
||||
})?;
|
||||
ensure_complete(&report)?;
|
||||
Ok(release.version_id)
|
||||
install_error(error)
|
||||
}
|
||||
|
||||
/// 安装错误 → 用户可读文案。
|
||||
|
||||
Reference in new issue
Block a user