Compare commits
4
Commits
858c227900
...
92d8a705e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92d8a705e8 | ||
|
|
d95c9664c6 | ||
|
|
3d9372029e | ||
|
|
b0f747074f |
No files matched your search
@@ -31,7 +31,7 @@ impl InstallStage {
|
|||||||
match self {
|
match self {
|
||||||
Self::Planning => "解析版本",
|
Self::Planning => "解析版本",
|
||||||
Self::Downloading => "下载文件",
|
Self::Downloading => "下载文件",
|
||||||
Self::Installing => "写入版本",
|
Self::Installing => "生成版本",
|
||||||
Self::Registering => "登记实例",
|
Self::Registering => "登记实例",
|
||||||
Self::Done => "已完成",
|
Self::Done => "已完成",
|
||||||
Self::Failed => "失败",
|
Self::Failed => "失败",
|
||||||
|
|||||||
+127
-47
@@ -1,12 +1,14 @@
|
|||||||
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线(原版 + Fabric/Quilt)→ 登记实例。
|
//! 安装:新建实例向导 / 下载页「安装」→ core 安装管线 → 登记实例。
|
||||||
//!
|
//!
|
||||||
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
|
//! 与 CLI `ull install` 同序:`RemoteSource::resolve → InstallPlan::build → execute → register`。
|
||||||
//! 差异:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
//! Forge/NeoForge 多一段 installer processors([`super::install_processors`]):
|
||||||
|
//! `ProcessorPlan::build → 依赖库下载 → 选 Java → 串行 java -cp`,**跑完才写版本 JSON**。
|
||||||
|
//! 差异只在线程模型:进度经 [`super::job::HostTask::Install`] 回流(文件计数 + 字节 + 速度/ETA),
|
||||||
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
//! 且整段跑在后台线程里(`block_on` 不占 UI 线程)。
|
||||||
//!
|
//!
|
||||||
//! 加载器范围:原版 + Fabric/Quilt(纯库层)。Forge/NeoForge 要外调 java 跑 installer
|
//! 加载器范围:原版 + Fabric/Quilt/Forge/NeoForge(四家,与 CLI 对齐)。OptiFine/LiteLoader
|
||||||
//! processors(M35 的执行器在 CLI 侧),GUI 这一轮**显式拒装并说清去哪装**,
|
//! 两家 core 侧都还没有安装管线,仍在 GUI 里显式拒装并说清去哪装。
|
||||||
//! 而不是装出一个启动必崩的骨架。
|
//! 失败一律落 `.ULL/logs/install.log`([`super::install_journal`]),文案里带日志路径。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -27,6 +29,8 @@ use crate::dto::{CreateRequest, InstallStage, LoaderKind};
|
|||||||
use crate::toast::{Toast, ToastKind};
|
use crate::toast::{Toast, ToastKind};
|
||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
use super::install_journal::Journal;
|
||||||
|
use super::install_processors::{self, StageInput};
|
||||||
use super::job::{spawn, HostTask, JobSender};
|
use super::job::{spawn, HostTask, JobSender};
|
||||||
use super::meter::{sink_for, Meter};
|
use super::meter::{sink_for, Meter};
|
||||||
|
|
||||||
@@ -36,6 +40,26 @@ pub const CONCURRENCY: usize = 16;
|
|||||||
/// 用户取消的稳定文案(阶段边界生效)。
|
/// 用户取消的稳定文案(阶段边界生效)。
|
||||||
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
|
pub const CANCELLED: &str = "已取消(阶段边界停下;已下载的文件保留,重装会续用)";
|
||||||
|
|
||||||
|
/// 一次安装任务的运行上下文(后台线程内一路传下去,免得每个函数排七八个参数)。
|
||||||
|
struct Job<'a> {
|
||||||
|
env: &'a HostEnv,
|
||||||
|
tx: &'a JobSender,
|
||||||
|
meter: &'a Arc<std::sync::Mutex<Meter>>,
|
||||||
|
cancel: &'a Arc<AtomicBool>,
|
||||||
|
journal: &'a Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job<'_> {
|
||||||
|
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||||
|
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||||
|
install_processors::Reporter {
|
||||||
|
tx: self.tx,
|
||||||
|
meter: self.meter,
|
||||||
|
journal: self.journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
|
/// 加载器徽标(`原版` / `Fabric 0.16.5`)。
|
||||||
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
||||||
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
|
if kind == LoaderKind::Vanilla || version.trim().is_empty() {
|
||||||
@@ -45,20 +69,26 @@ pub fn loader_label(kind: LoaderKind, version: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GUI 侧支持的加载器(Forge/NeoForge 需要 installer processors)。
|
/// GUI 侧支持的加载器(五选:原版 + 四家加载器,与 CLI 的 `--loader` 词表一致)。
|
||||||
|
///
|
||||||
|
/// 写成穷举 `matches!` 而不是 `true`:将来 core 加了第六种加载器,这里默认拒绝,
|
||||||
|
/// 由 [`unsupported_reason`] 给出明确说法,而不是让它走进不属于它的安装分支。
|
||||||
pub fn supported(kind: LoaderKind) -> bool {
|
pub fn supported(kind: LoaderKind) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
kind,
|
kind,
|
||||||
LoaderKind::Vanilla | LoaderKind::Fabric | LoaderKind::Quilt
|
LoaderKind::Vanilla
|
||||||
|
| LoaderKind::Fabric
|
||||||
|
| LoaderKind::Quilt
|
||||||
|
| LoaderKind::Forge
|
||||||
|
| LoaderKind::NeoForge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不支持的加载器 → 给用户的可执行建议。
|
/// 不支持的加载器 → 给用户的可执行建议。
|
||||||
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
pub fn unsupported_reason(kind: LoaderKind) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{} 需要 installer processors(外调 java 改 jar),GUI 这一轮还没接入;请先在终端跑 `ull install <版本> --loader {}`,装完回实例页刷新即可启动。",
|
"{} 还没有接入安装管线(目前支持原版 + Fabric/Quilt/Forge/NeoForge);请先在实例页导入已有版本,或换成受支持的加载器。",
|
||||||
kind.label(),
|
kind.label()
|
||||||
kind.as_str()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +96,28 @@ pub fn unsupported_reason(kind: LoaderKind) -> String {
|
|||||||
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
|
pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel: Arc<AtomicBool>) {
|
||||||
spawn("install", move || {
|
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)));
|
||||||
let result = run(&env, &tx, &request, &meter, &cancel);
|
let journal = Journal::open(&env);
|
||||||
|
journal.line(&format!(
|
||||||
|
"—— 开始安装 {}({} {})——",
|
||||||
|
request.instance_name(),
|
||||||
|
loader_label(request.loader, ""),
|
||||||
|
request.version
|
||||||
|
));
|
||||||
|
let job = Job {
|
||||||
|
env: &env,
|
||||||
|
tx: &tx,
|
||||||
|
meter: &meter,
|
||||||
|
cancel: &cancel,
|
||||||
|
journal: &journal,
|
||||||
|
};
|
||||||
|
let result = run(&job, &request);
|
||||||
let mut progress = meter
|
let mut progress = meter
|
||||||
.lock()
|
.lock()
|
||||||
.map(|guard| guard.progress.clone())
|
.map(|guard| guard.progress.clone())
|
||||||
.unwrap_or_else(|_| Meter::new(&request).progress);
|
.unwrap_or_else(|_| Meter::new(&request).progress);
|
||||||
match result {
|
match result {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
|
journal.line(&format!("完成:实例 {name} 已登记"));
|
||||||
progress.stage = InstallStage::Done;
|
progress.stage = InstallStage::Done;
|
||||||
progress.files_done = progress.files_total;
|
progress.files_done = progress.files_total;
|
||||||
progress.message = format!("已安装 {name}({})", progress.loader);
|
progress.message = format!("已安装 {name}({})", progress.loader);
|
||||||
@@ -88,15 +133,24 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
progress.stage = if reason == CANCELLED {
|
let cancelled = reason == CANCELLED;
|
||||||
|
progress.stage = if cancelled {
|
||||||
InstallStage::Cancelled
|
InstallStage::Cancelled
|
||||||
} else {
|
} else {
|
||||||
InstallStage::Failed
|
InstallStage::Failed
|
||||||
};
|
};
|
||||||
progress.message.clone_from(&reason);
|
// 失败文案带上日志路径:面板只有一行、Toast 4 秒就消失,
|
||||||
|
// 装日志是用户事后能自己回看的唯一渠道(进度板之外)。
|
||||||
|
let text = if cancelled {
|
||||||
|
reason
|
||||||
|
} else {
|
||||||
|
journal.line(&format!("安装未完成:{reason}"));
|
||||||
|
format!("{reason}(日志:{})", journal.path().display())
|
||||||
|
};
|
||||||
|
progress.message.clone_from(&text);
|
||||||
let _ = tx.send(HostTask::InstallDone {
|
let _ = tx.send(HostTask::InstallDone {
|
||||||
progress: Box::new(progress),
|
progress: Box::new(progress),
|
||||||
result: Err(reason),
|
result: Err(text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,13 +158,7 @@ pub fn spawn_install(env: HostEnv, tx: JobSender, request: CreateRequest, cancel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 真安装(同步跑完,调用方在后台线程里)。
|
/// 真安装(同步跑完,调用方在后台线程里)。
|
||||||
fn run(
|
fn run(job: &Job<'_>, request: &CreateRequest) -> Result<String, String> {
|
||||||
env: &HostEnv,
|
|
||||||
tx: &JobSender,
|
|
||||||
request: &CreateRequest,
|
|
||||||
meter: &Arc<std::sync::Mutex<Meter>>,
|
|
||||||
cancel: &Arc<AtomicBool>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
if request.version.trim().is_empty() {
|
if request.version.trim().is_empty() {
|
||||||
return Err("没有选择版本".to_string());
|
return Err("没有选择版本".to_string());
|
||||||
}
|
}
|
||||||
@@ -118,26 +166,31 @@ fn run(
|
|||||||
return Err(unsupported_reason(request.loader));
|
return Err(unsupported_reason(request.loader));
|
||||||
}
|
}
|
||||||
let name = request.instance_name();
|
let name = request.instance_name();
|
||||||
|
let env = job.env;
|
||||||
let engine = super::engine::engine(env);
|
let engine = super::engine::engine(env);
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
guard.stage(InstallStage::Planning, "正在解析版本清单…");
|
||||||
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
let _ = job
|
||||||
|
.tx
|
||||||
|
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
}
|
}
|
||||||
|
|
||||||
if cancel.load(Ordering::SeqCst) {
|
if job.cancel.load(Ordering::SeqCst) {
|
||||||
return Err(CANCELLED.to_string());
|
return Err(CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
let version_id = if request.loader == LoaderKind::Vanilla {
|
let version_id = if request.loader == LoaderKind::Vanilla {
|
||||||
install_vanilla(env, &engine, request, tx, meter, cancel)?
|
install_vanilla(job, &engine, request)?
|
||||||
} else {
|
} else {
|
||||||
install_loader(env, &engine, request, tx, meter, cancel)?
|
install_loader(job, &engine, request)?
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.stage(InstallStage::Registering, "正在登记实例…");
|
guard.stage(InstallStage::Registering, "正在登记实例…");
|
||||||
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
let _ = job
|
||||||
|
.tx
|
||||||
|
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
}
|
}
|
||||||
let created_at =
|
let created_at =
|
||||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||||
@@ -153,17 +206,15 @@ fn run(
|
|||||||
|
|
||||||
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
/// 原版安装(`resolve → plan → [prefetch 资源索引] → execute`)。
|
||||||
fn install_vanilla(
|
fn install_vanilla(
|
||||||
env: &HostEnv,
|
job: &Job<'_>,
|
||||||
engine: &DownloadEngine,
|
engine: &DownloadEngine,
|
||||||
request: &CreateRequest,
|
request: &CreateRequest,
|
||||||
tx: &JobSender,
|
|
||||||
meter: &Arc<std::sync::Mutex<Meter>>,
|
|
||||||
cancel: &Arc<AtomicBool>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
let env = job.env;
|
||||||
let version = request.version.trim();
|
let version = request.version.trim();
|
||||||
let meta_cache = env.layout.meta_cache();
|
let meta_cache = env.layout.meta_cache();
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let sink = sink_for(tx.clone(), Arc::clone(meter));
|
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||||
block_on(async {
|
block_on(async {
|
||||||
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
let source = RemoteSource::new(engine, &env.game, &meta_cache, now);
|
||||||
let resolved = source.resolve(version).await.map_err(install_error)?;
|
let resolved = source.resolve(version).await.map_err(install_error)?;
|
||||||
@@ -197,7 +248,7 @@ fn install_vanilla(
|
|||||||
}
|
}
|
||||||
let (bytes_total, _) = plan.total_bytes();
|
let (bytes_total, _) = plan.total_bytes();
|
||||||
{
|
{
|
||||||
let mut guard = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
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.files_total = u64::try_from(plan.file_count()).unwrap_or(u64::MAX);
|
||||||
guard.progress.bytes_total = bytes_total;
|
guard.progress.bytes_total = bytes_total;
|
||||||
guard.stage(
|
guard.stage(
|
||||||
@@ -205,7 +256,7 @@ fn install_vanilla(
|
|||||||
&format!("开始下载 {} 个文件…", plan.file_count()),
|
&format!("开始下载 {} 个文件…", plan.file_count()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if cancel.load(Ordering::SeqCst) {
|
if job.cancel.load(Ordering::SeqCst) {
|
||||||
return Err(CANCELLED.to_string());
|
return Err(CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
|
let report = execute(engine, &plan, CONCURRENCY, Some(sink))
|
||||||
@@ -216,15 +267,13 @@ fn install_vanilla(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载器安装(原版前置 → 加载器库 → 版本 JSON 落盘)。
|
/// 加载器安装(原版前置 → installer processors → 加载器库 → 版本 JSON 落盘)。
|
||||||
fn install_loader(
|
fn install_loader(
|
||||||
env: &HostEnv,
|
job: &Job<'_>,
|
||||||
engine: &DownloadEngine,
|
engine: &DownloadEngine,
|
||||||
request: &CreateRequest,
|
request: &CreateRequest,
|
||||||
tx: &JobSender,
|
|
||||||
meter: &Arc<std::sync::Mutex<Meter>>,
|
|
||||||
cancel: &Arc<AtomicBool>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
let env = job.env;
|
||||||
let version = request.version.trim();
|
let version = request.version.trim();
|
||||||
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
let kind = CoreLoaderKind::parse(request.loader.as_str())
|
||||||
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
.ok_or_else(|| format!("未知加载器 `{}`", request.loader.as_str()))?;
|
||||||
@@ -237,6 +286,12 @@ fn install_loader(
|
|||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
job.journal.line(&format!(
|
||||||
|
"加载器解析:{} {} → {}",
|
||||||
|
release.kind.label(),
|
||||||
|
release.loader_version,
|
||||||
|
release.version_id
|
||||||
|
));
|
||||||
|
|
||||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||||
layout: &env.game,
|
layout: &env.game,
|
||||||
@@ -247,22 +302,47 @@ fn install_loader(
|
|||||||
if plan.vanilla_install_needed {
|
if plan.vanilla_install_needed {
|
||||||
let mut vanilla = request.clone();
|
let mut vanilla = request.clone();
|
||||||
vanilla.loader = LoaderKind::Vanilla;
|
vanilla.loader = LoaderKind::Vanilla;
|
||||||
install_vanilla(env, engine, &vanilla, tx, meter, cancel)?;
|
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 = meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
let mut guard = job.meter.lock().map_err(|_| "进度锁失效".to_string())?;
|
||||||
guard.progress.files_total += u64::try_from(plan.files().len()).unwrap_or(u64::MAX);
|
|
||||||
let (bytes, _) = plan.install.total_bytes();
|
|
||||||
guard.progress.bytes_total = guard.progress.bytes_total.max(bytes);
|
|
||||||
guard.stage(
|
guard.stage(
|
||||||
InstallStage::Downloading,
|
InstallStage::Downloading,
|
||||||
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
&format!("开始下载加载器库({} 个文件)…", plan.files().len()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if cancel.load(Ordering::SeqCst) {
|
if job.cancel.load(Ordering::SeqCst) {
|
||||||
return Err(CANCELLED.to_string());
|
return Err(CANCELLED.to_string());
|
||||||
}
|
}
|
||||||
let sink = sink_for(tx.clone(), Arc::clone(meter));
|
let sink = sink_for(job.tx.clone(), Arc::clone(job.meter));
|
||||||
let report = block_on(async {
|
let report = block_on(async {
|
||||||
execute(engine, &plan.install, CONCURRENCY, Some(sink))
|
execute(engine, &plan.install, CONCURRENCY, Some(sink))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! 安装过程的可回溯日志(`.ULL/logs/install.log`,一轮一行)。
|
||||||
|
//!
|
||||||
|
//! 起因(M45):Forge/NeoForge 装失败时,GUI 只有一行 Toast,processor 的 stderr 尾部
|
||||||
|
//! 与「哪一步挂了」在面板一关就没了。这里把安装过程按行追加到 `.ULL/logs/`
|
||||||
|
//! (与 `auth.jsonl` 同一分区),失败文案里带上日志路径,用户能自己回看。
|
||||||
|
//!
|
||||||
|
//! 三条纪律(与 auth 日志同源):
|
||||||
|
//! 1. **只追加**,不轮转、不截断(体积由「一行一条、只记诊断字段」控制);
|
||||||
|
//! 2. **只记诊断字段**:阶段、processor 坐标、退出码、已脱敏的 stderr 尾部(core 侧脱敏);
|
||||||
|
//! 3. **写盘尽力而为**:开不了文件就静默跳过,绝不让日志失败连累安装。
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use unlucky_core::launch::time;
|
||||||
|
|
||||||
|
use super::env::HostEnv;
|
||||||
|
|
||||||
|
/// 日志文件名(在 `.ULL/logs/` 下)。
|
||||||
|
pub const INSTALL_LOG_FILE: &str = "install.log";
|
||||||
|
|
||||||
|
/// 一轮安装的日志句柄(`Clone` 很便宜:只有一个路径)。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Journal {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Journal {
|
||||||
|
/// 按数据根打开(`logs/` 不存在就补建;建不出来只是没有日志)。
|
||||||
|
pub fn open(env: &HostEnv) -> Self {
|
||||||
|
let dir = env.layout.logs();
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
Self {
|
||||||
|
path: dir.join(INSTALL_LOG_FILE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志路径(失败文案里给用户看)。
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加一行(自动补 RFC3339 时间戳;写失败静默)。
|
||||||
|
pub fn line(&self, text: &str) {
|
||||||
|
let Ok(mut file) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&self.path)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = writeln!(
|
||||||
|
file,
|
||||||
|
"[{}] {text}",
|
||||||
|
time::format_rfc3339_millis(time::now())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lines_are_appended_with_a_timestamp() {
|
||||||
|
let root = std::env::temp_dir().join(format!("ull-install-journal-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let env = HostEnv::at_root(&root);
|
||||||
|
let journal = Journal::open(&env);
|
||||||
|
journal.line("第一行");
|
||||||
|
journal.line("第二行");
|
||||||
|
let text = std::fs::read_to_string(journal.path()).expect("日志存在");
|
||||||
|
assert!(text.contains("第一行") && text.contains("第二行"), "{text}");
|
||||||
|
assert_eq!(text.lines().count(), 2);
|
||||||
|
assert!(text.starts_with("[2"), "带时间戳:{text}");
|
||||||
|
assert_eq!(journal.path(), env.layout.logs().join(INSTALL_LOG_FILE));
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
//! Forge / NeoForge 的 installer processors 段(M45:GUI 与 CLI 行为对齐)。
|
||||||
|
//!
|
||||||
|
//! 顺序与 CLI `ull install --loader forge` 完全一致(`crates/cli/src/commands/install/
|
||||||
|
//! loader_processors.rs`),只是把进度接到 GUI 的回流通道上:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ProcessorPlan::build(纯计算:变量表 + 每步 java -cp + 依赖库清单)
|
||||||
|
//! → download_libraries(M27 引擎;进度回流 HostTask::Install)
|
||||||
|
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
|
||||||
|
//! → run(串行 java -cp;每步一条面板文案 + 一行装日志)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 四条硬约束(与 CLI 同源,GUI 侧一条都不放宽):
|
||||||
|
//!
|
||||||
|
//! 1. **processors 跑完才允许写版本 JSON**:版本清单存在 = 这版装完了,抢在 processors
|
||||||
|
//! 前写会留下启动必崩却被报成"已安装"的实例(顺序由 `install_loader` 保证);
|
||||||
|
//! 2. **幂等**:版本 JSON 已在盘上 = 上一轮跑完了,整段跳过;
|
||||||
|
//! 3. **取消在阶段边界生效**:处理器阶段是原子段(串行外调 java,进程内没有取消钩子),
|
||||||
|
//! 进入前与退出后各查一次 `cancel`;
|
||||||
|
//! 4. **失败不静默**:结构化文案(带 `processor_*` 大类码)+ 装日志一行一条,由调用方
|
||||||
|
//! 再补 Toast(`HostTask::InstallDone` 的既有通路)。
|
||||||
|
//!
|
||||||
|
//! 执行后端走 [`Backend`]:真实实现 [`CoreBackend`] 直接转调 core,单测注入 fake,
|
||||||
|
//! 因此「计划怎么组、Java 找不到怎么说、依赖库下漏怎么报」都能在没有网络/没有 JDK
|
||||||
|
//! 的机器上断言。
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::forge::InstallProfile;
|
||||||
|
use unlucky_core::addon::loader::processors::{
|
||||||
|
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
|
||||||
|
StepStatus,
|
||||||
|
};
|
||||||
|
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||||
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
use unlucky_core::download::DownloadEngine;
|
||||||
|
use unlucky_core::game::arguments::Environment;
|
||||||
|
use unlucky_core::game::install::{InstallReport, PlanFile, ProgressSink};
|
||||||
|
use unlucky_core::game::java::{JavaRequirement, JavaRuntime};
|
||||||
|
|
||||||
|
use crate::dto::InstallStage;
|
||||||
|
|
||||||
|
use super::env::HostEnv;
|
||||||
|
use super::install::{CANCELLED, CONCURRENCY};
|
||||||
|
use super::install_journal::Journal;
|
||||||
|
use super::job::{HostTask, JobSender};
|
||||||
|
use super::meter::{sink_for, Meter};
|
||||||
|
|
||||||
|
/// 真实后端(无状态,可以放静态里复用)。
|
||||||
|
pub(super) const CORE: CoreBackend = CoreBackend;
|
||||||
|
|
||||||
|
/// processors 段需要的输入(从 `LoaderRelease` 摘出来,便于单测构造)。
|
||||||
|
pub(super) struct StageInput<'a> {
|
||||||
|
/// 加载器种类(补库时 maven 基址的兜底来源)。
|
||||||
|
pub kind: CoreLoaderKind,
|
||||||
|
/// 目标原版版本。
|
||||||
|
pub mc_version: &'a str,
|
||||||
|
/// loader 版本 id(依赖库落点 + 临时目录名)。
|
||||||
|
pub version_id: &'a str,
|
||||||
|
/// installer 里读出来的 install profile(`requires_installer` 为真时必须有)。
|
||||||
|
pub profile: Option<&'a InstallProfile>,
|
||||||
|
/// installer jar 的磁盘路径。
|
||||||
|
pub installer: Option<&'a Path>,
|
||||||
|
/// 版本 JSON 已在盘上 = 上一轮跑完了,整段跳过(幂等)。
|
||||||
|
pub installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// processors 段的执行后端(真实实现转调 core;单测注入 fake)。
|
||||||
|
pub(super) trait Backend {
|
||||||
|
/// 下载 processor 依赖库(M27 引擎:镜像/健康度/退避/校验)。
|
||||||
|
fn download_libraries(
|
||||||
|
&self,
|
||||||
|
engine: &DownloadEngine,
|
||||||
|
files: &[PlanFile],
|
||||||
|
sink: Option<ProgressSink>,
|
||||||
|
) -> InstallReport;
|
||||||
|
/// 按 M33 的解析链挑一个能跑 installer 的 Java。
|
||||||
|
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime>;
|
||||||
|
/// 串行跑 processors。
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
plan: &ProcessorPlan,
|
||||||
|
java: &JavaRuntime,
|
||||||
|
sink: Option<StepSink>,
|
||||||
|
) -> Result<ProcessorRun, ProcessorError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真实后端:三件事全部转调 core 的同一个执行器(CLI 用的就是它)。
|
||||||
|
pub(super) struct CoreBackend;
|
||||||
|
|
||||||
|
impl Backend for CoreBackend {
|
||||||
|
fn download_libraries(
|
||||||
|
&self,
|
||||||
|
engine: &DownloadEngine,
|
||||||
|
files: &[PlanFile],
|
||||||
|
sink: Option<ProgressSink>,
|
||||||
|
) -> InstallReport {
|
||||||
|
block_on(processors::download_libraries(
|
||||||
|
engine,
|
||||||
|
files,
|
||||||
|
CONCURRENCY,
|
||||||
|
sink,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_java(&self, managed: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||||
|
processors::select_java(managed, requirement)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
plan: &ProcessorPlan,
|
||||||
|
java: &JavaRuntime,
|
||||||
|
sink: Option<StepSink>,
|
||||||
|
) -> Result<ProcessorRun, ProcessorError> {
|
||||||
|
processors::run(plan, java, sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 段内的进度出口:进度面板(`HostTask::Install`)+ 装日志。
|
||||||
|
pub(super) struct Reporter<'a> {
|
||||||
|
/// 后台线程 → host 的通道。
|
||||||
|
pub tx: &'a JobSender,
|
||||||
|
/// 进度记账器。
|
||||||
|
pub meter: &'a Arc<Mutex<Meter>>,
|
||||||
|
/// 装日志。
|
||||||
|
pub journal: &'a Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter<'_> {
|
||||||
|
/// 换阶段并推一条进度。
|
||||||
|
fn stage(&self, stage: InstallStage, message: &str) {
|
||||||
|
let Ok(mut guard) = self.meter.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
guard.stage(stage, message);
|
||||||
|
let _ = self
|
||||||
|
.tx
|
||||||
|
.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 先把待下的库记账(文件数 + 已知字节),下载回调再逐个累加。
|
||||||
|
fn expect(&self, files: usize, bytes: u64) {
|
||||||
|
let Ok(mut guard) = self.meter.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
guard.expect(files, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败留痕(文案由调用方决定怎么呈现;这里只保证日志里有一条)。
|
||||||
|
fn fail(&self, reason: String) -> String {
|
||||||
|
self.journal.line(&format!("失败:{reason}"));
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑 Forge / NeoForge 的 installer processors(`Ok(None)` = 这一轮不需要跑)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 元数据缺 profile/installer、依赖库没下全、找不到可用 Java、processor 非零退出、
|
||||||
|
/// 产物没落盘、用户取消时返回给 UI 的文案(已写进装日志)。
|
||||||
|
pub(super) fn stage(
|
||||||
|
env: &HostEnv,
|
||||||
|
engine: &DownloadEngine,
|
||||||
|
input: &StageInput<'_>,
|
||||||
|
backend: &dyn Backend,
|
||||||
|
reporter: &Reporter<'_>,
|
||||||
|
cancel: &Arc<AtomicBool>,
|
||||||
|
) -> Result<Option<ProcessorRun>, String> {
|
||||||
|
if input.installed {
|
||||||
|
reporter.journal.line(&format!(
|
||||||
|
"{} 的版本 JSON 已在盘上,跳过 installer processors",
|
||||||
|
input.version_id
|
||||||
|
));
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (Some(profile), Some(installer)) = (input.profile, input.installer) else {
|
||||||
|
return Err(reporter.fail(metadata_missing(input)));
|
||||||
|
};
|
||||||
|
reporter.stage(InstallStage::Installing, "正在组 installer 处理器计划…");
|
||||||
|
let scratch = env
|
||||||
|
.layout
|
||||||
|
.cache()
|
||||||
|
.join("processors")
|
||||||
|
.join(safe_name(input.version_id));
|
||||||
|
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||||
|
kind: input.kind,
|
||||||
|
mc_version: input.mc_version,
|
||||||
|
version_id: input.version_id,
|
||||||
|
profile,
|
||||||
|
layout: &env.game,
|
||||||
|
installer,
|
||||||
|
scratch: &scratch,
|
||||||
|
environment: &Environment::host(),
|
||||||
|
})
|
||||||
|
.map_err(|error| reporter.fail(diagnostic(&error)))?;
|
||||||
|
for warning in &plan.warnings {
|
||||||
|
reporter.journal.line(&format!("规划告警:{warning}"));
|
||||||
|
}
|
||||||
|
if plan.is_empty() {
|
||||||
|
reporter
|
||||||
|
.journal
|
||||||
|
.line("install profile 没有 client 侧 processor,跳过这一段");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = plan.step_count();
|
||||||
|
let bytes = plan.libraries.iter().filter_map(|file| file.size).sum();
|
||||||
|
reporter.expect(plan.libraries.len(), bytes);
|
||||||
|
reporter.stage(
|
||||||
|
InstallStage::Downloading,
|
||||||
|
&format!("下载 installer 依赖库({} 个文件)…", plan.libraries.len()),
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
let sink = sink_for(reporter.tx.clone(), Arc::clone(reporter.meter));
|
||||||
|
let libraries = backend.download_libraries(engine, &plan.libraries, Some(sink));
|
||||||
|
if !libraries.is_complete() {
|
||||||
|
return Err(reporter.fail(libraries_failed(&libraries)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let managed = env.layout.java_managed();
|
||||||
|
let Some(java) = backend.select_java(&managed, &plan.java_requirement) else {
|
||||||
|
return Err(reporter.fail(java_missing(&plan.java_requirement, &managed)));
|
||||||
|
};
|
||||||
|
reporter.journal.line(&format!(
|
||||||
|
"用 {}({})跑 {total} 步 processor",
|
||||||
|
java.binary.display(),
|
||||||
|
java_label(&java)
|
||||||
|
));
|
||||||
|
reporter.stage(
|
||||||
|
InstallStage::Installing,
|
||||||
|
&format!("运行 installer 处理器 0/{total}({})…", java_label(&java)),
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let steps = step_sink(
|
||||||
|
reporter.tx.clone(),
|
||||||
|
Arc::clone(reporter.meter),
|
||||||
|
reporter.journal.clone(),
|
||||||
|
total,
|
||||||
|
);
|
||||||
|
let run = backend
|
||||||
|
.run(&plan, &java, Some(steps))
|
||||||
|
.map_err(|error| reporter.fail(diagnostic(&error)))?;
|
||||||
|
for warning in &run.warnings {
|
||||||
|
reporter.journal.line(&format!("执行告警:{warning}"));
|
||||||
|
}
|
||||||
|
reporter.journal.line(&format!(
|
||||||
|
"processors 完成:执行 {} 步、跳过 {} 步",
|
||||||
|
run.executed(),
|
||||||
|
run.skipped()
|
||||||
|
));
|
||||||
|
reporter.stage(
|
||||||
|
InstallStage::Installing,
|
||||||
|
&format!(
|
||||||
|
"installer 处理器完成(执行 {} 步、跳过 {} 步),正在写入版本清单…",
|
||||||
|
run.executed(),
|
||||||
|
run.skipped()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
return Err(CANCELLED.to_string());
|
||||||
|
}
|
||||||
|
Ok(Some(run))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 每步完成的回调:面板一行 + 装日志一行。
|
||||||
|
///
|
||||||
|
/// `StepSink` 要求 `'static`,所以这里全持所有权(通道/记账器/日志都是克隆)。
|
||||||
|
fn step_sink(tx: JobSender, meter: Arc<Mutex<Meter>>, journal: Journal, total: usize) -> StepSink {
|
||||||
|
Arc::new(move |outcome: &StepOutcome| {
|
||||||
|
let line = step_line(outcome, total);
|
||||||
|
journal.line(&line);
|
||||||
|
let Ok(mut guard) = meter.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
guard.stage(InstallStage::Installing, &line);
|
||||||
|
let _ = tx.send(HostTask::Install(Box::new(guard.progress.clone())));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一步的进度/日志文案(纯函数,单测直接断言)。
|
||||||
|
pub(super) fn step_line(outcome: &StepOutcome, total: usize) -> String {
|
||||||
|
let suffix = match outcome.status {
|
||||||
|
StepStatus::Skipped => "(产物已就绪,跳过)".to_string(),
|
||||||
|
StepStatus::Executed => format!("({} ms)", outcome.elapsed_ms),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"installer 处理器 {}/{}:{}{suffix}",
|
||||||
|
outcome.index, total, outcome.label
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// processor 错误 → 用户可读文案(保留 `processor_*` 大类码,便于按码搜日志)。
|
||||||
|
pub(super) fn diagnostic(error: &ProcessorError) -> String {
|
||||||
|
let mut text = format!("{}({})", error.message(), error.kind().as_str());
|
||||||
|
if let Some(hint) = error.hint() {
|
||||||
|
text.push_str(";建议:");
|
||||||
|
text.push_str(hint);
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缺 Java 运行时的专用文案(比通用错误多给一条「去哪装」)。
|
||||||
|
pub(super) fn java_missing(requirement: &JavaRequirement, managed: &Path) -> String {
|
||||||
|
format!(
|
||||||
|
"跑 installer 需要 {},但一个可用的 Java 运行时都没找到;请到「Java」页安装托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||||
|
requirement.describe(),
|
||||||
|
managed.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 依赖库没下全 → 列前三条失败明细。
|
||||||
|
pub(super) fn libraries_failed(report: &InstallReport) -> String {
|
||||||
|
let details: Vec<String> = report
|
||||||
|
.failures(3)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|outcome| {
|
||||||
|
outcome
|
||||||
|
.failure
|
||||||
|
.as_ref()
|
||||||
|
.map(|failure| format!("{}:{}", outcome.name, failure.message))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"installer 依赖库没下全({} 个失败,共 {} 个):{}",
|
||||||
|
report.failed(),
|
||||||
|
report.files.len(),
|
||||||
|
details.join(";")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 需要 installer 但元数据不全(缓存半截 / 换线路重解析过)。
|
||||||
|
fn metadata_missing(input: &StageInput<'_>) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径;重跑一次即可(元数据缓存可能不完整)",
|
||||||
|
input.kind.label(),
|
||||||
|
input.version_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Java 的一行短标识(`Java 21 · managed`)。
|
||||||
|
fn java_label(java: &JavaRuntime) -> String {
|
||||||
|
match java.major {
|
||||||
|
Some(major) => format!("Java {major} · {}", java.origin.as_str()),
|
||||||
|
None => format!("Java 主版本未知 · {}", java.origin.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉,与 CLI 同规则)。
|
||||||
|
fn safe_name(raw: &str) -> String {
|
||||||
|
raw.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "install_processors_fixture.rs"]
|
||||||
|
mod fixture;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "install_processors_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! installer processors 段的单测夹具(拆文件守 400 行上限)。
|
||||||
|
//!
|
||||||
|
//! 不需要网络、不需要 JDK、不真的起 java:`Harness` 给一个临时数据根 + 进度通道,
|
||||||
|
//! `Fake` 回放预置的下载/Java/执行结果并记录被调顺序与组出来的命令行。
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::forge::{InstallProfile, Processor};
|
||||||
|
use unlucky_core::addon::loader::processors::{
|
||||||
|
ProcessorError, ProcessorPlan, ProcessorRun, StepOutcome, StepSink, StepStatus,
|
||||||
|
};
|
||||||
|
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||||
|
use unlucky_core::download::{DownloadEngine, DownloadErrorKind};
|
||||||
|
use unlucky_core::game::install::{
|
||||||
|
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
|
||||||
|
};
|
||||||
|
use unlucky_core::game::java::{JavaOrigin, JavaRequirement, JavaRuntime};
|
||||||
|
|
||||||
|
use super::{Backend, Reporter, StageInput};
|
||||||
|
use crate::dto::{CreateRequest, LoaderKind};
|
||||||
|
use crate::host::env::HostEnv;
|
||||||
|
use crate::host::install_journal::Journal;
|
||||||
|
use crate::host::job::{channel, HostTask, JobSender};
|
||||||
|
use crate::host::meter::Meter;
|
||||||
|
|
||||||
|
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// 一步的计划视图(序号 / 坐标 / 参数 / classpath 条数)。
|
||||||
|
pub(super) type StepView = (usize, String, Vec<String>, usize);
|
||||||
|
|
||||||
|
/// 一段 cases 的公共环境:临时数据根 + 引擎 + 进度通道。
|
||||||
|
pub(super) struct Harness {
|
||||||
|
dir: PathBuf,
|
||||||
|
pub(super) env: HostEnv,
|
||||||
|
pub(super) engine: DownloadEngine,
|
||||||
|
tx: JobSender,
|
||||||
|
rx: Receiver<HostTask>,
|
||||||
|
pub(super) meter: Arc<Mutex<Meter>>,
|
||||||
|
pub(super) journal: Journal,
|
||||||
|
pub(super) cancel: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("ull-proc-{}-{seq}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
let engine = crate::host::engine::engine(&env);
|
||||||
|
let request = CreateRequest {
|
||||||
|
version: "1.20.1".to_string(),
|
||||||
|
loader: LoaderKind::Forge,
|
||||||
|
loader_version: "47.2.0".to_string(),
|
||||||
|
name: "proc".to_string(),
|
||||||
|
};
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
Self {
|
||||||
|
journal: Journal::open(&env),
|
||||||
|
meter: Arc::new(Mutex::new(Meter::new(&request))),
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
dir,
|
||||||
|
env,
|
||||||
|
engine,
|
||||||
|
tx,
|
||||||
|
rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn reporter(&self) -> Reporter<'_> {
|
||||||
|
Reporter {
|
||||||
|
tx: &self.tx,
|
||||||
|
meter: &self.meter,
|
||||||
|
journal: &self.journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 进度面板收到的所有文案。
|
||||||
|
pub(super) fn messages(&self) -> Vec<String> {
|
||||||
|
self.rx
|
||||||
|
.try_iter()
|
||||||
|
.filter_map(|task| match task {
|
||||||
|
HostTask::Install(progress) => Some(progress.message.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 装日志全文。
|
||||||
|
pub(super) fn log(&self) -> String {
|
||||||
|
std::fs::read_to_string(self.journal.path()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Harness {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_dir_all(&self.dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入用的假后端:回放预置结果 + 记录调用顺序与被组出来的命令行。
|
||||||
|
pub(super) struct Fake {
|
||||||
|
pub(super) libraries_ok: bool,
|
||||||
|
pub(super) java: Option<JavaRuntime>,
|
||||||
|
pub(super) run_error: Option<ProcessorError>,
|
||||||
|
calls: RefCell<Vec<&'static str>>,
|
||||||
|
steps: RefCell<Vec<StepView>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fake {
|
||||||
|
pub(super) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
libraries_ok: true,
|
||||||
|
java: Some(java()),
|
||||||
|
run_error: None,
|
||||||
|
calls: RefCell::new(Vec::new()),
|
||||||
|
steps: RefCell::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn calls(&self) -> Vec<&'static str> {
|
||||||
|
self.calls.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被组出来的每步(序号 / 坐标 / 参数 / classpath 条数)。
|
||||||
|
pub(super) fn steps(&self) -> Vec<StepView> {
|
||||||
|
self.steps.borrow().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend for Fake {
|
||||||
|
fn download_libraries(
|
||||||
|
&self,
|
||||||
|
_engine: &DownloadEngine,
|
||||||
|
files: &[PlanFile],
|
||||||
|
_sink: Option<ProgressSink>,
|
||||||
|
) -> InstallReport {
|
||||||
|
self.calls.borrow_mut().push("libraries");
|
||||||
|
let ok = self.libraries_ok;
|
||||||
|
let outcomes = files
|
||||||
|
.iter()
|
||||||
|
.map(|file| FileOutcome {
|
||||||
|
role: file.role,
|
||||||
|
name: file.name.clone(),
|
||||||
|
dest: file.dest.clone(),
|
||||||
|
status: if ok {
|
||||||
|
FileStatus::Skipped
|
||||||
|
} else {
|
||||||
|
FileStatus::Failed
|
||||||
|
},
|
||||||
|
bytes: 0,
|
||||||
|
source: None,
|
||||||
|
failure: (!ok).then(|| FileFailure {
|
||||||
|
kind: DownloadErrorKind::Network,
|
||||||
|
message: "连接超时".to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
InstallReport {
|
||||||
|
files: outcomes,
|
||||||
|
version_json: None,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_java(&self, _managed: &Path, _requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||||
|
self.calls.borrow_mut().push("java");
|
||||||
|
self.java.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
&self,
|
||||||
|
plan: &ProcessorPlan,
|
||||||
|
java: &JavaRuntime,
|
||||||
|
sink: Option<StepSink>,
|
||||||
|
) -> Result<ProcessorRun, ProcessorError> {
|
||||||
|
self.calls.borrow_mut().push("run");
|
||||||
|
*self.steps.borrow_mut() = plan
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|step| {
|
||||||
|
(
|
||||||
|
step.index,
|
||||||
|
step.label.clone(),
|
||||||
|
step.args.clone(),
|
||||||
|
step.classpath.len(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(error) = &self.run_error {
|
||||||
|
return Err(error.clone());
|
||||||
|
}
|
||||||
|
let outcomes: Vec<StepOutcome> = plan
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|step| StepOutcome {
|
||||||
|
index: step.index,
|
||||||
|
label: step.label.clone(),
|
||||||
|
status: StepStatus::Executed,
|
||||||
|
outputs: step.outputs.len(),
|
||||||
|
elapsed_ms: 12,
|
||||||
|
stdout_tail: None,
|
||||||
|
stderr_tail: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(sink) = &sink {
|
||||||
|
for outcome in &outcomes {
|
||||||
|
sink(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ProcessorRun {
|
||||||
|
java: java.clone(),
|
||||||
|
steps: outcomes,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个主版本已知的假 Java。
|
||||||
|
pub(super) fn java() -> JavaRuntime {
|
||||||
|
JavaRuntime {
|
||||||
|
binary: PathBuf::from("java"),
|
||||||
|
origin: JavaOrigin::Managed,
|
||||||
|
version: Some("21.0.4".to_string()),
|
||||||
|
major: Some(21),
|
||||||
|
bits: 64,
|
||||||
|
vendor: Some("temurin".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个最小可用的 install profile:一个 client 侧 processor、两个字面量参数。
|
||||||
|
pub(super) fn profile() -> InstallProfile {
|
||||||
|
let mut profile = InstallProfile::default();
|
||||||
|
profile.processors.push(Processor {
|
||||||
|
sides: vec!["client".to_string()],
|
||||||
|
jar: Some("net.minecraftforge:installertools:1.3.0".to_string()),
|
||||||
|
classpath: vec!["net.minecraftforge:jarsplitter:1.1.4".to_string()],
|
||||||
|
args: vec!["--task".to_string(), "'MCP_DATA'".to_string()],
|
||||||
|
..Processor::default()
|
||||||
|
});
|
||||||
|
profile
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn input<'a>(profile: &'a InstallProfile, installer: &'a Path) -> StageInput<'a> {
|
||||||
|
StageInput {
|
||||||
|
kind: CoreLoaderKind::Forge,
|
||||||
|
mc_version: "1.20.1",
|
||||||
|
version_id: "1.20.1-forge-47.2.0",
|
||||||
|
profile: Some(profile),
|
||||||
|
installer: Some(installer),
|
||||||
|
installed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn installer() -> PathBuf {
|
||||||
|
PathBuf::from("cache/meta/forge-installer.jar")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn outcome(status: StepStatus) -> StepOutcome {
|
||||||
|
StepOutcome {
|
||||||
|
index: 3,
|
||||||
|
label: "net.minecraftforge:installertools:1.3.0".to_string(),
|
||||||
|
status,
|
||||||
|
outputs: 2,
|
||||||
|
elapsed_ms: 1234,
|
||||||
|
stdout_tail: None,
|
||||||
|
stderr_tail: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! host/install_processors.rs 的单测(拆文件守 400 行上限)。
|
||||||
|
//!
|
||||||
|
//! 夹具在 [`super::fixture`]:这里只断言「计划怎么组」「找不到 Java 怎么说」
|
||||||
|
//! 「哪一步挂了怎么报」「进度文案长什么样」。
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use unlucky_core::addon::loader::forge::InstallProfile;
|
||||||
|
use unlucky_core::addon::loader::processors::{ProcessorError, StepStatus};
|
||||||
|
use unlucky_core::addon::loader::LoaderKind as CoreLoaderKind;
|
||||||
|
use unlucky_core::download::DownloadErrorKind;
|
||||||
|
use unlucky_core::game::install::{FileFailure, FileOutcome, FileRole, FileStatus, InstallReport};
|
||||||
|
use unlucky_core::game::java::JavaRequirement;
|
||||||
|
|
||||||
|
use super::fixture::{input, installer, outcome, profile, Fake, Harness};
|
||||||
|
use super::{diagnostic, java_missing, libraries_failed, stage, step_line, StageInput};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn step_line_names_the_step_and_marks_the_outcome() {
|
||||||
|
let executed = step_line(&outcome(StepStatus::Executed), 9);
|
||||||
|
assert_eq!(
|
||||||
|
executed,
|
||||||
|
"installer 处理器 3/9:net.minecraftforge:installertools:1.3.0(1234 ms)"
|
||||||
|
);
|
||||||
|
let skipped = step_line(&outcome(StepStatus::Skipped), 9);
|
||||||
|
assert!(skipped.contains("产物已就绪"), "{skipped}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagnostic_keeps_the_stable_code_and_the_hint() {
|
||||||
|
let failed = ProcessorError::failed("net.minecraftforge:installertools:1.3.0", "1", "boom")
|
||||||
|
.with_hint("确认 Java 版本与磁盘权限");
|
||||||
|
let text = diagnostic(&failed);
|
||||||
|
assert!(text.contains("processor_failed"), "{text}");
|
||||||
|
assert!(text.contains("退出码 1"), "{text}");
|
||||||
|
assert!(text.contains("boom"), "stderr 尾部要留:{text}");
|
||||||
|
assert!(text.contains("建议:确认 Java 版本"), "{text}");
|
||||||
|
|
||||||
|
let plan = diagnostic(&ProcessorError::plan("install profile 缺 `jar`"));
|
||||||
|
assert!(plan.contains("processor_plan"), "{plan}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn java_missing_points_at_the_java_page() {
|
||||||
|
let managed = PathBuf::from("/tmp/.ULL/java");
|
||||||
|
let text = java_missing(&JavaRequirement::table(21, None), &managed);
|
||||||
|
assert!(text.contains("Java 21+"), "{text}");
|
||||||
|
assert!(text.contains("Java」页"), "{text}");
|
||||||
|
assert!(text.contains("JAVA_HOME"), "{text}");
|
||||||
|
assert!(text.contains(&managed.display().to_string()), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn libraries_failed_lists_at_most_three() {
|
||||||
|
let files: Vec<FileOutcome> = (0..4)
|
||||||
|
.map(|index| FileOutcome {
|
||||||
|
role: FileRole::Library,
|
||||||
|
name: format!("lib{index}.jar"),
|
||||||
|
dest: PathBuf::from(format!("lib{index}.jar")),
|
||||||
|
status: FileStatus::Failed,
|
||||||
|
bytes: 0,
|
||||||
|
source: None,
|
||||||
|
failure: Some(FileFailure {
|
||||||
|
kind: DownloadErrorKind::Network,
|
||||||
|
message: "连接超时".to_string(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let report = InstallReport {
|
||||||
|
files,
|
||||||
|
version_json: None,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
let text = libraries_failed(&report);
|
||||||
|
assert!(text.contains("4 个失败,共 4 个"), "{text}");
|
||||||
|
assert!(text.contains("lib0.jar"), "{text}");
|
||||||
|
assert!(!text.contains("lib3.jar"), "最多列三条:{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_skips_when_the_version_json_is_already_there() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let mut input = input(&profile, &installer);
|
||||||
|
input.installed = true;
|
||||||
|
let fake = Fake::new();
|
||||||
|
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||||
|
assert!(matches!(result, Ok(None)));
|
||||||
|
assert!(fake.calls().is_empty(), "已装完就不该再动网络/Java");
|
||||||
|
assert!(h.log().contains("跳过 installer processors"), "{}", h.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_skips_an_empty_processor_list() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = InstallProfile::default();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let fake = Fake::new();
|
||||||
|
let result = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel);
|
||||||
|
assert!(matches!(result, Ok(None)), "没有 processor 就没这段");
|
||||||
|
assert!(fake.calls().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_reports_missing_installer_metadata() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let input = StageInput {
|
||||||
|
kind: CoreLoaderKind::Forge,
|
||||||
|
mc_version: "1.20.1",
|
||||||
|
version_id: "1.20.1-forge-47.2.0",
|
||||||
|
profile: None,
|
||||||
|
installer: None,
|
||||||
|
installed: false,
|
||||||
|
};
|
||||||
|
let fake = Fake::new();
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("install profile"), "{err}");
|
||||||
|
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_builds_the_command_line_from_the_install_profile() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let fake = Fake::new();
|
||||||
|
let run = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel)
|
||||||
|
.expect("stage 成功")
|
||||||
|
.expect("应当真的跑了 processors");
|
||||||
|
assert_eq!(run.executed(), 1);
|
||||||
|
let steps = fake.steps();
|
||||||
|
assert_eq!(steps.len(), 1);
|
||||||
|
let (index, label, args, classpath) = &steps[0];
|
||||||
|
assert_eq!(*index, 1);
|
||||||
|
assert_eq!(label, "net.minecraftforge:installertools:1.3.0");
|
||||||
|
assert_eq!(args, &vec!["--task".to_string(), "MCP_DATA".to_string()]);
|
||||||
|
assert_eq!(*classpath, 1, "jar 自己不在 -cp 里(拼命令行时才补)");
|
||||||
|
assert_eq!(fake.calls(), vec!["libraries", "java", "run"]);
|
||||||
|
let messages = h.messages();
|
||||||
|
assert!(
|
||||||
|
messages.iter().any(|m| m.contains("installer 处理器 1/1")),
|
||||||
|
"面板要看到当前处理器:{messages:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
messages
|
||||||
|
.last()
|
||||||
|
.is_some_and(|m| m.contains("正在写入版本清单")),
|
||||||
|
"{messages:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
h.log().contains("processors 完成:执行 1 步"),
|
||||||
|
"{}",
|
||||||
|
h.log()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_fails_loudly_without_a_usable_java() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let mut fake = Fake::new();
|
||||||
|
fake.java = None;
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("Java"), "{err}");
|
||||||
|
assert!(err.contains("托管目录"), "{err}");
|
||||||
|
assert_eq!(
|
||||||
|
fake.calls(),
|
||||||
|
vec!["libraries", "java"],
|
||||||
|
"没 Java 就不该起进程"
|
||||||
|
);
|
||||||
|
assert!(h.log().contains("失败:"), "{}", h.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_fails_loudly_when_a_processor_exits_nonzero() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let mut fake = Fake::new();
|
||||||
|
fake.run_error = Some(ProcessorError::failed(
|
||||||
|
"net.minecraftforge:installertools:1.3.0",
|
||||||
|
"1",
|
||||||
|
"java.lang.NoSuchMethodError",
|
||||||
|
));
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("processor_failed"), "{err}");
|
||||||
|
assert!(err.contains("NoSuchMethodError"), "{err}");
|
||||||
|
let log = h.log();
|
||||||
|
assert!(log.contains("失败:"), "{log}");
|
||||||
|
assert!(log.contains("NoSuchMethodError"), "日志要能回溯:{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_reports_incomplete_library_downloads() {
|
||||||
|
let h = Harness::new();
|
||||||
|
let installer = installer();
|
||||||
|
let profile = profile();
|
||||||
|
let input = input(&profile, &installer);
|
||||||
|
let mut fake = Fake::new();
|
||||||
|
fake.libraries_ok = false;
|
||||||
|
let err = stage(&h.env, &h.engine, &input, &fake, &h.reporter(), &h.cancel).unwrap_err();
|
||||||
|
assert!(err.contains("依赖库没下全"), "{err}");
|
||||||
|
assert!(err.contains("连接超时"), "{err}");
|
||||||
|
assert_eq!(fake.calls(), vec!["libraries"], "库没下全就别去选 Java");
|
||||||
|
let progress = h.meter.lock().expect("进度锁").progress.clone();
|
||||||
|
assert!(progress.files_total >= 2, "依赖库要计入文件总数");
|
||||||
|
}
|
||||||
@@ -1,24 +1,170 @@
|
|||||||
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
//! host/install.rs 的单测(拆文件守 400 行上限)。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
use unlucky_core::game::install::{FileOutcome, FileRole, FileStatus};
|
||||||
|
|
||||||
use super::{run, supported, unsupported_reason};
|
use super::{loader_label, run, supported, unsupported_reason, Job};
|
||||||
use crate::dto::{CreateRequest, LoaderKind};
|
use crate::dto::{CreateRequest, LoaderKind};
|
||||||
use crate::host::env::HostEnv;
|
use crate::host::env::HostEnv;
|
||||||
|
use crate::host::install_journal::Journal;
|
||||||
|
use crate::host::job::{channel, JobSender};
|
||||||
use crate::host::meter::Meter;
|
use crate::host::meter::Meter;
|
||||||
|
|
||||||
|
static SEQ: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
/// 一个装在临时根里的任务上下文(只走「联网前就返回」的用例)。
|
||||||
|
struct Fixture {
|
||||||
|
dir: PathBuf,
|
||||||
|
env: HostEnv,
|
||||||
|
tx: JobSender,
|
||||||
|
meter: Arc<Mutex<Meter>>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
journal: Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new(request: &CreateRequest) -> Self {
|
||||||
|
let seq = SEQ.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ull-host-install-{}-{seq}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
let journal = Journal::open(&env);
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
env,
|
||||||
|
tx: channel().0,
|
||||||
|
meter: Arc::new(Mutex::new(Meter::new(request))),
|
||||||
|
cancel: Arc::new(AtomicBool::new(false)),
|
||||||
|
journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job(&self) -> Job<'_> {
|
||||||
|
Job {
|
||||||
|
env: &self.env,
|
||||||
|
tx: &self.tx,
|
||||||
|
meter: &self.meter,
|
||||||
|
cancel: &self.cancel,
|
||||||
|
journal: &self.journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_dir_all(&self.dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(loader: LoaderKind, version: &str) -> CreateRequest {
|
||||||
|
CreateRequest {
|
||||||
|
version: version.to_string(),
|
||||||
|
loader,
|
||||||
|
loader_version: String::new(),
|
||||||
|
name: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个「刚下完」的文件结局(记账用)。
|
||||||
|
fn downloaded(bytes: u64) -> FileOutcome {
|
||||||
|
FileOutcome {
|
||||||
|
role: FileRole::Library,
|
||||||
|
name: "lib.jar".to_string(),
|
||||||
|
dest: PathBuf::from("lib.jar"),
|
||||||
|
status: FileStatus::Downloaded,
|
||||||
|
bytes,
|
||||||
|
source: None,
|
||||||
|
failure: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_is_pre_accounted_across_segments_so_the_bar_never_snaps_to_full() {
|
||||||
|
// M45 r1 P2-2 的回归:处理器依赖库下完那一刻,面板曾显示 36/36 满格 + 3.4MB/10MB,
|
||||||
|
// 之后加载器库的 29 个文件才被计入 → 进度条从 100% 回落到 55% 再涨回去。
|
||||||
|
let request = request(LoaderKind::Forge, "1.20.1");
|
||||||
|
let mut meter = Meter::new(&request);
|
||||||
|
|
||||||
|
// 原版段:30 个文件全下完(install_vanilla 对总量是赋值,不是累加)
|
||||||
|
meter.progress.files_total = 30;
|
||||||
|
meter.progress.bytes_total = 3_000_000;
|
||||||
|
for _ in 0..30 {
|
||||||
|
meter.note(&downloaded(100_000));
|
||||||
|
}
|
||||||
|
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||||
|
|
||||||
|
// install_loader 的顺序:加载器库与处理器依赖库都在动手前一次预记账
|
||||||
|
meter.expect(29, 10_000_000);
|
||||||
|
meter.expect(36, 4_000_000);
|
||||||
|
assert_eq!(meter.progress.files_total, 95, "两段之和一次到位");
|
||||||
|
assert_eq!(
|
||||||
|
meter.progress.bytes_total, 17_000_000,
|
||||||
|
"字节是相加不是取最大"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meter.progress.ratio() < 0.7,
|
||||||
|
"预记账后不能还停在满格:{}",
|
||||||
|
meter.progress.ratio()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理器依赖库 36 个下完 → 仍没满格(加载器库还在后面)
|
||||||
|
for _ in 0..36 {
|
||||||
|
meter.note(&downloaded(110_000));
|
||||||
|
}
|
||||||
|
let ratio = meter.progress.ratio();
|
||||||
|
assert!(
|
||||||
|
(0.6..1.0).contains(&ratio),
|
||||||
|
"回落到中间态而不是满格:{ratio}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meter.progress.bytes_done < meter.progress.bytes_total,
|
||||||
|
"字节总量要覆盖已下载量:{}/{}",
|
||||||
|
meter.progress.bytes_done,
|
||||||
|
meter.progress.bytes_total
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加载器库也下完 → 这时候才该是满格
|
||||||
|
for _ in 0..29 {
|
||||||
|
meter.note(&downloaded(340_000));
|
||||||
|
}
|
||||||
|
assert!((meter.progress.ratio() - 1.0).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loader_support_matrix_is_explicit() {
|
fn loader_support_matrix_is_explicit() {
|
||||||
assert!(supported(LoaderKind::Vanilla));
|
// M45 语义变更:Forge/NeoForge 从「GUI 显式拒装」扩进受支持集合(走 installer processors)。
|
||||||
assert!(supported(LoaderKind::Fabric));
|
for kind in [
|
||||||
assert!(supported(LoaderKind::Quilt));
|
LoaderKind::Vanilla,
|
||||||
assert!(!supported(LoaderKind::Forge));
|
LoaderKind::Fabric,
|
||||||
assert!(!supported(LoaderKind::NeoForge));
|
LoaderKind::Quilt,
|
||||||
assert!(unsupported_reason(LoaderKind::Forge).contains("ull install"));
|
LoaderKind::Forge,
|
||||||
|
LoaderKind::NeoForge,
|
||||||
|
] {
|
||||||
|
assert!(supported(kind), "{} 应当受支持", kind.label());
|
||||||
|
}
|
||||||
|
// 这两家 core 侧还没有安装管线,仍然显式拒装并说清去哪装。
|
||||||
|
for kind in [LoaderKind::OptiFine, LoaderKind::LiteLoader] {
|
||||||
|
assert!(!supported(kind), "{} 不该被当成可装", kind.label());
|
||||||
|
let reason = unsupported_reason(kind);
|
||||||
|
assert!(reason.contains(kind.label()), "{reason}");
|
||||||
|
assert!(reason.contains("还没有接入"), "{reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_install_is_no_longer_refused_at_the_door() {
|
||||||
|
// 旧文案是把用户赶去终端跑 `ull install`;M45 之后 GUI 与 CLI 同一条路径。
|
||||||
|
assert!(supported(LoaderKind::Forge));
|
||||||
|
assert!(supported(LoaderKind::NeoForge));
|
||||||
|
assert_eq!(loader_label(LoaderKind::Forge, "47.2.0"), "Forge 47.2.0");
|
||||||
|
assert_eq!(
|
||||||
|
loader_label(LoaderKind::NeoForge, "21.1.80"),
|
||||||
|
"NeoForge 21.1.80"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -57,30 +203,16 @@ fn meter_counts_files_and_skips_skipped_bytes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_version_is_rejected_before_network() {
|
fn empty_version_is_rejected_before_network() {
|
||||||
let dir = std::env::temp_dir().join(format!("ull-host-install-{}", std::process::id()));
|
let request = request(LoaderKind::Vanilla, "");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let fixture = Fixture::new(&request);
|
||||||
let env = HostEnv::at_root(&dir);
|
let err = run(&fixture.job(), &request).unwrap_err();
|
||||||
let (tx, _rx) = super::super::job::channel();
|
|
||||||
let request = CreateRequest {
|
|
||||||
version: String::new(),
|
|
||||||
loader: LoaderKind::Vanilla,
|
|
||||||
loader_version: String::new(),
|
|
||||||
name: String::new(),
|
|
||||||
};
|
|
||||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&request)));
|
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
|
||||||
let err = run(&env, &tx, &request, &meter, &cancel).unwrap_err();
|
|
||||||
assert!(err.contains("没有选择版本"), "{err}");
|
assert!(err.contains("没有选择版本"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
let forge = CreateRequest {
|
#[test]
|
||||||
version: "1.20.1".to_string(),
|
fn unsupported_loader_is_rejected_before_network() {
|
||||||
loader: LoaderKind::Forge,
|
let request = request(LoaderKind::OptiFine, "1.20.1");
|
||||||
..request
|
let fixture = Fixture::new(&request);
|
||||||
};
|
let err = run(&fixture.job(), &request).unwrap_err();
|
||||||
let meter = Arc::new(std::sync::Mutex::new(Meter::new(&forge)));
|
assert!(err.contains("还没有接入"), "{err}");
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
|
||||||
assert!(run(&env, &tx, &forge, &meter, &cancel)
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("installer processors"));
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,16 @@ impl Meter {
|
|||||||
self.previous = None;
|
self.previous = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 预记账一段待下载的文件(文件数 + 字节)。
|
||||||
|
///
|
||||||
|
/// 一次安装可能分几段(原版 / 处理器依赖库 / 加载器库):每段开工前都先记账,
|
||||||
|
/// 整条进度曲线才是单调的——某一段下完不会先到 100%,再因为下一段的文件被计入而回落。
|
||||||
|
pub(super) fn expect(&mut self, files: usize, bytes: u64) {
|
||||||
|
let files = u64::try_from(files).unwrap_or(u64::MAX);
|
||||||
|
self.progress.files_total = self.progress.files_total.saturating_add(files);
|
||||||
|
self.progress.bytes_total = self.progress.bytes_total.saturating_add(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/// 记账一个文件的结局。
|
/// 记账一个文件的结局。
|
||||||
pub(super) fn note(&mut self, outcome: &FileOutcome) {
|
pub(super) fn note(&mut self, outcome: &FileOutcome) {
|
||||||
self.progress.files_done += 1;
|
self.progress.files_done += 1;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ mod game;
|
|||||||
mod handle;
|
mod handle;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod install;
|
mod install;
|
||||||
|
mod install_journal;
|
||||||
|
mod install_processors;
|
||||||
mod java;
|
mod java;
|
||||||
mod job;
|
mod job;
|
||||||
mod launch;
|
mod launch;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub struct ShotOptions {
|
|||||||
pub wizard: bool,
|
pub wizard: bool,
|
||||||
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
|
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
|
||||||
pub install: Option<String>,
|
pub install: Option<String>,
|
||||||
|
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
|
||||||
|
pub loader: Option<String>,
|
||||||
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
|
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
|
||||||
pub launch: Option<String>,
|
pub launch: Option<String>,
|
||||||
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
|
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
|
||||||
@@ -58,6 +60,7 @@ pub fn shot_options() -> Option<ShotOptions> {
|
|||||||
},
|
},
|
||||||
wizard: flag("--screenshot-wizard"),
|
wizard: flag("--screenshot-wizard"),
|
||||||
install: value_of("--screenshot-install"),
|
install: value_of("--screenshot-install"),
|
||||||
|
loader: value_of("--screenshot-loader"),
|
||||||
launch: value_of("--screenshot-launch"),
|
launch: value_of("--screenshot-launch"),
|
||||||
session: value_of("--screenshot-session"),
|
session: value_of("--screenshot-session"),
|
||||||
dwell_ms: value_of("--screenshot-dwell")
|
dwell_ms: value_of("--screenshot-dwell")
|
||||||
@@ -92,7 +95,7 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform
|
|||||||
}
|
}
|
||||||
if let Some(version) = &options.install {
|
if let Some(version) = &options.install {
|
||||||
app.dispatch_and_run(
|
app.dispatch_and_run(
|
||||||
AppCommand::InstallVersion(version.clone()),
|
install_command(version, options.loader.as_deref()),
|
||||||
options.dwell_ms,
|
options.dwell_ms,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,20 @@ pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::Platform
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路
|
||||||
|
/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。
|
||||||
|
fn install_command(version: &str, loader: Option<&str>) -> AppCommand {
|
||||||
|
match loader.map(crate::dto::LoaderKind::parse) {
|
||||||
|
Some(Some(kind)) => AppCommand::CreateInstance(crate::dto::CreateRequest {
|
||||||
|
version: version.to_string(),
|
||||||
|
loader: kind,
|
||||||
|
loader_version: String::new(),
|
||||||
|
name: version.to_string(),
|
||||||
|
}),
|
||||||
|
_ => AppCommand::InstallVersion(version.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `--flag` 是否存在。
|
/// `--flag` 是否存在。
|
||||||
pub fn flag(name: &str) -> bool {
|
pub fn flag(name: &str) -> bool {
|
||||||
std::env::args().skip(1).any(|arg| arg == name)
|
std::env::args().skip(1).any(|arg| arg == name)
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ export component NewInstanceWizard inherits Rectangle {
|
|||||||
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
|
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
|
||||||
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
|
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
|
||||||
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
|
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
|
||||||
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(3); } }
|
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } }
|
||||||
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors:本轮请用 ull install"; picked => { PageState.wizard-select-loader(4); } }
|
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ③ 名字
|
// ③ 名字
|
||||||
|
|||||||
Reference in new issue
Block a user