Files
Unlucky-Launcher/crates/cli/src/commands/install/vanilla.rs
T
AskaEth d8ca411117 fix(core): bmclapi 库镜像补 /maven;install 进度行改说实话
D1(high):mirrors.json 的 bmclapi-libraries 少了 inject,官方
libraries.minecraft.net/<path> 被重写成镜像根 + <path>(BMCLAPI 的 maven 仓库挂在
/maven 下),实测 70/70 个库全 404、全部回落官方源(单请求 EMA 6.3 s)。本轮实测:
guava:32.1.2-jre 无 /maven → 404 / 9 B;带 /maven → 200(2 次 302)/ 3041591 B,
sha1 与官方逐字节一致。补法与 bmclapi-assets-objects 的 /assets 同一机制(inject),
不把 /maven 拼进 to(to 是镜像根)。

守卫两道,抓不同的东西:
- 形态:凡 from 指向 maven 仓库的规则,to+inject 必须以 /maven 结尾(并点名覆盖的
  5 条规则 id,删/改名都会红);
- 行为:4 个真实官方 maven 根展开后必须逐字等于 /maven/<子路径>。
原有 prefix_rule_preserves_sub_path 的期望值曾把 D1 的 bug 钉成期望,一并改正。
变异(删掉 inject)实测三条同时红,恢复后 mirrors 14 passed。

D11(low):进度回调只累加「处理到第几个文件」,还打「下载 {done}/{total}」,
重装同版本(全部已就绪)时会连打 3961 行「下载」而一个字节都没下。拆出
install/progress.rs:计数全部来自 FileOutcome.status(Downloaded/Skipped/Failed),
下载数为 0 时人读行不出现「下载」二字(改说「已就绪 d/t(跳过 N 个)」),--json 的
progress 事件补 downloaded/skipped/failed。用例构造 96 个全 Skipped ⇒ 断言每行都不含
「下载」且末行为「已就绪 96/96(跳过 96 个)」;变异(退回旧文本)实测红。

其它镜像表(assets / jar / version_manifest / 索引 / fabric / forge)的 inject 形状
实测一致,另有 liteloader 目标路径 404、quilt maven 无规则两条【不同类】观察,
不在本轮范围,报告里交回 tower。
2026-11-09 13:57:32 +08:00

161 lines
5.2 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.
//! `ull install <version>`(无加载器):M27 原版安装流程的宿主。
//!
//! ```text
//! RemoteSource::resolve(版本清单缓存 → 版本 JSON → 继承链)
//! → RemoteSource::asset_index(资源索引,顺便拿字节给下面预取)
//! → InstallPlan::build(纯计算:文件清单 / 总大小 / 角色)
//! → [--dry-run 到此为止]
//! → execute(跳过已就绪 → 引擎并发下载 → 原子写版本 JSON)
//! → register(profiles.json + config/instances/<id>.json)
//! ```
//!
//! 异步整段包在一次 `block_on` 里:CLI 不依赖 tokio(D18 依赖树纪律),
//! reqwest 的连接池也不会跨运行时而失效。
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, InstallErrorKind, InstallPlan, InstallReport, PlanInput, RemoteSource,
};
use super::{
build_engine, ensure_name_free, failure_error, map_install_error, progress, report, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
use crate::output::Report;
/// 执行 `install`(原版)。
///
/// # Errors
///
/// 用法错误、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
let version = args.version.trim().to_string();
if version.is_empty() {
return Err(CliError::usage(
"install 需要版本 id:%prog% install <version>",
));
}
let name = args
.name
.clone()
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| version.clone());
ensure_name_free(ctx, &name, &version)?;
let engine = build_engine(ctx);
let now = now_ms();
let (plan, report) = plan_and_install(
ctx,
&engine,
&version,
args.dry_run,
args.progress,
args.json_progress,
)?;
if let Some(report) = &report {
if !report.is_complete() {
return Err(failure_error(report, ctx));
}
}
let registration = if args.dry_run {
None
} else {
Some(super::register_instance(ctx, &name, &version)?)
};
Ok(report::build(
ctx,
&engine,
now,
args.dry_run,
&plan,
report.as_ref(),
registration.as_ref(),
&name,
&version,
))
}
/// 规划 + 执行原版安装(`--dry-run` 只规划),供加载器安装当"前置步骤"复用。
///
/// # Errors
///
/// 版本不存在、网络失败、清单装不全、写盘失败。
pub(super) fn plan_and_install(
ctx: &Ctx,
engine: &DownloadEngine,
version: &str,
dry_run: bool,
progress: bool,
json_progress: bool,
) -> Result<(InstallPlan, Option<InstallReport>), CliError> {
let game = ctx.game();
let meta_cache = ctx.layout().meta_cache();
let now = now_ms();
block_on(async {
let source = RemoteSource::new(engine, &game, &meta_cache, now);
let resolved = source.resolve(version).await?;
let fetched = match resolved
.asset_index
.as_ref()
.filter(|index| index.url.is_some())
{
Some(reference) => {
let (index, bytes) = source.asset_index(reference).await?;
Some((index, game.asset_index(&reference.id), bytes))
}
None => None,
};
let mut plan = InstallPlan::build(PlanInput {
layout: &game,
version_id: version,
resolved: &resolved,
asset_index: fetched.as_ref().map(|(index, _, _)| index),
environment: &Environment::host(),
})?;
if let Some((_, dest, bytes)) = fetched {
plan.prefetch(dest, bytes);
}
if dry_run {
return Ok::<Attempt, InstallError>(Attempt { plan, report: None });
}
// 计划里没有客户端 jar(旧版清单缺 downloads.client)→ 拒绝"装出个空壳"。
if !plan.is_installable() {
let reasons = if plan.warnings.is_empty() {
"清单缺少客户端 jar 的下载信息".to_string()
} else {
plan.warnings.join(";")
};
return Err(InstallError::new(
InstallErrorKind::Metadata,
format!("版本 {version} 装不全:{reasons}"),
)
.with_hint(
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`",
));
}
let sink = progress::progress_sink(progress, json_progress, plan.file_count());
let report = execute(engine, &plan, CONCURRENCY, sink).await?;
Ok(Attempt {
plan,
report: Some(report),
})
})
.map(|attempt| (attempt.plan, attempt.report))
.map_err(|error: InstallError| map_install_error(&error, ctx))
}
/// 一次安装尝试的产物(`--dry-run` 只有计划)。
struct Attempt {
plan: InstallPlan,
report: Option<InstallReport>,
}