Unlucky Launcher v0.0.1-alpha
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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (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
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,159 @@
|
||||
//! `ull install <version> --loader <kind> [--loader-version <v>]`:加载器层的编排。
|
||||
//!
|
||||
//! ```text
|
||||
//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile)
|
||||
//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点)
|
||||
//! → [--dry-run 到此为止,但多带一段 processors 计划]
|
||||
//! → 原版已装?否 → vanilla::plan_and_install(先装原版)
|
||||
//! → 需要 installer?→ 依赖库 → 选 Java → 串行跑 processors(M35)
|
||||
//! → execute(loader 库并发下载 → 全量就绪才写 versions/<loader-id>/<loader-id>.json)
|
||||
//! → register(实例 versionId 指向 loader 版本)
|
||||
//! ```
|
||||
//!
|
||||
//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层,
|
||||
//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。
|
||||
//!
|
||||
//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是
|
||||
//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。
|
||||
|
||||
use unlucky_core::addon::loader::{
|
||||
LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::execute;
|
||||
|
||||
use super::report_loader::ProcessorSection;
|
||||
use super::{
|
||||
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error,
|
||||
register_instance, report, report_loader, vanilla, CONCURRENCY,
|
||||
};
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 执行加载器安装。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、
|
||||
/// installer processors 失败、实例登记失败。
|
||||
pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Report, CliError> {
|
||||
let version = args.version.trim().to_string();
|
||||
if version.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"install 需要版本 id:ull install <version> --loader fabric",
|
||||
));
|
||||
}
|
||||
|
||||
let engine = build_engine(ctx);
|
||||
let layout = ctx.layout();
|
||||
let game = ctx.game();
|
||||
let meta_cache = layout.meta_cache();
|
||||
let now = now_ms();
|
||||
|
||||
let release = block_on(async {
|
||||
let source = LoaderSource::new(&engine, &meta_cache);
|
||||
source
|
||||
.resolve(kind, &version, args.loader_version.as_deref())
|
||||
.await
|
||||
})
|
||||
.map_err(|error: LoaderError| map_loader_error(&error, ctx))?;
|
||||
|
||||
let name = args
|
||||
.name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| release.version_id.clone());
|
||||
ensure_name_free(ctx, &name, &release.version_id)?;
|
||||
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &game,
|
||||
release: &release,
|
||||
environment: &Environment::host(),
|
||||
vanilla_installed: game.is_version_installed(&version),
|
||||
});
|
||||
let processor_plan = loader_processors::plan(ctx, &release)?;
|
||||
|
||||
if args.dry_run {
|
||||
let section = processor_plan.as_ref().map(|plan| ProcessorSection {
|
||||
plan,
|
||||
libraries: None,
|
||||
run: None,
|
||||
skipped: false,
|
||||
});
|
||||
return Ok(report_loader::build(
|
||||
ctx, &engine, now, true, &plan, None, None, &name, section,
|
||||
));
|
||||
}
|
||||
|
||||
if plan.vanilla_install_needed {
|
||||
let (_, vanilla_report) = vanilla::plan_and_install(
|
||||
ctx,
|
||||
&engine,
|
||||
&version,
|
||||
false,
|
||||
args.progress,
|
||||
args.json_progress,
|
||||
)?;
|
||||
if let Some(report) = &vanilla_report {
|
||||
if !report.is_complete() {
|
||||
return Err(failure_error(report, ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
|
||||
let mut processor_libs = None;
|
||||
let mut processor_run = None;
|
||||
let mut processor_skipped = false;
|
||||
if let Some(processor_plan) = &processor_plan {
|
||||
if game.is_version_installed(&release.version_id) {
|
||||
processor_skipped = true;
|
||||
} else {
|
||||
let (libraries, run) = loader_processors::execute(ctx, &engine, processor_plan, args)?;
|
||||
processor_libs = Some(libraries);
|
||||
processor_run = Some(run);
|
||||
}
|
||||
}
|
||||
|
||||
let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len());
|
||||
let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink))
|
||||
.map_err(|error| map_install_error(&error, ctx))?;
|
||||
if !install_report.is_complete() {
|
||||
return Err(failure_error(&install_report, ctx));
|
||||
}
|
||||
|
||||
let registration = register_instance(ctx, &name, &release.version_id)?;
|
||||
|
||||
let section = processor_plan
|
||||
.as_ref()
|
||||
.map(|processor_plan| ProcessorSection {
|
||||
plan: processor_plan,
|
||||
libraries: processor_libs.as_ref(),
|
||||
run: processor_run.as_ref(),
|
||||
skipped: processor_skipped,
|
||||
});
|
||||
Ok(report_loader::build(
|
||||
ctx,
|
||||
&engine,
|
||||
now,
|
||||
false,
|
||||
&plan,
|
||||
Some(&install_report),
|
||||
Some(®istration),
|
||||
&name,
|
||||
section,
|
||||
))
|
||||
}
|
||||
|
||||
/// 加载器错误 → CLI 错误(稳定码 `loader_*`)。
|
||||
fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError {
|
||||
let hint = match error.hint() {
|
||||
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
|
||||
None => format!("游戏根:{}", ctx.game().root().display()),
|
||||
};
|
||||
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 加载器的 installer processors 段(M35):依赖库下载 → 选 Java → 串行执行。
|
||||
//!
|
||||
//! 只在 `LoaderSupport::RequiresInstaller` 的版本上出现(Forge 1.13~1.20.x 主流段、
|
||||
//! NeoForge 1.20.5+)。其余加载器(Fabric/Quilt、Forge spec 0)走纯 json 路线,这一段整块跳过。
|
||||
//!
|
||||
//! ```text
|
||||
//! ProcessorPlan::build(core,纯计算:变量表 + 每步命令行 + 依赖库清单)
|
||||
//! → download_libraries(M27 引擎:镜像/健康度/退避/校验)
|
||||
//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘)
|
||||
//! → run(串行 java -cp,产物存在性硬校验、摘要软校验)
|
||||
//! ```
|
||||
//!
|
||||
//! 顺序上与版本 JSON 的关系是**硬约束**:processors 全部成功之后才让 M27 的 `execute`
|
||||
//! 写 `versions/<id>/<id>.json`——那份清单是"这版装完了"的判据,抢在 processors 前写会
|
||||
//! 留下一个启动必崩却被报成"已安装"的实例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::processors::{
|
||||
self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink,
|
||||
};
|
||||
use unlucky_core::addon::loader::LoaderRelease;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::InstallReport;
|
||||
|
||||
use super::{failure_error, report, CONCURRENCY};
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::progress_line;
|
||||
|
||||
/// 组执行计划;不需要跑 installer 的加载器返回 `None`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// install profile 的 `data` 表/`processors` 形状不对时返回 `processor_plan` 错误。
|
||||
pub(super) fn plan(ctx: &Ctx, release: &LoaderRelease) -> Result<Option<ProcessorPlan>, CliError> {
|
||||
if !release.requires_installer() {
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(profile), Some(installer)) = (
|
||||
release.install_profile.as_ref(),
|
||||
release.installer.as_deref(),
|
||||
) else {
|
||||
return Err(CliError::user(
|
||||
"loader_metadata",
|
||||
format!(
|
||||
"{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径",
|
||||
release.kind.label(),
|
||||
release.loader_version
|
||||
),
|
||||
)
|
||||
.with_hint("重跑一次(元数据缓存可能不完整)"));
|
||||
};
|
||||
let game = ctx.game();
|
||||
let scratch = ctx
|
||||
.layout()
|
||||
.cache()
|
||||
.join("processors")
|
||||
.join(safe_name(&release.version_id));
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: release.kind,
|
||||
mc_version: &release.mc_version,
|
||||
version_id: &release.version_id,
|
||||
profile,
|
||||
layout: &game,
|
||||
installer,
|
||||
scratch: &scratch,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.map_err(map_processor_error)?;
|
||||
Ok(Some(plan))
|
||||
}
|
||||
|
||||
/// 下载依赖库 + 执行 processors。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 依赖库下载失败(稳定码沿用 `install_*`)、找不到可用 Java、processor 非零退出、
|
||||
/// 产物没落盘。
|
||||
pub(super) fn execute(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
plan: &ProcessorPlan,
|
||||
args: &InstallArgs,
|
||||
) -> Result<(InstallReport, ProcessorRun), CliError> {
|
||||
let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len());
|
||||
let libraries = block_on(processors::download_libraries(
|
||||
engine,
|
||||
&plan.libraries,
|
||||
CONCURRENCY,
|
||||
sink,
|
||||
));
|
||||
if !libraries.is_complete() {
|
||||
return Err(failure_error(&libraries, ctx));
|
||||
}
|
||||
|
||||
let managed = ctx.layout().java_managed();
|
||||
let Some(java) = processors::select_java(&managed, &plan.java_requirement) else {
|
||||
return Err(CliError::user(
|
||||
"processor_java_missing",
|
||||
format!(
|
||||
"跑 installer processors 需要 {},但一个可用的 Java 都没找到",
|
||||
plan.java_requirement.describe()
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})",
|
||||
plan.java_requirement.minimum,
|
||||
managed.display()
|
||||
)));
|
||||
};
|
||||
let sink = step_sink(args.progress, args.json_progress, plan.step_count());
|
||||
let run = processors::run(plan, &java, sink).map_err(map_processor_error)?;
|
||||
Ok((libraries, run))
|
||||
}
|
||||
|
||||
/// 每步一行的实时进度(只写 stderr)。
|
||||
fn step_sink(enabled: bool, json_mode: bool, total: usize) -> Option<StepSink> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
Some(Arc::new(move |outcome: &StepOutcome| {
|
||||
let record = serde_json::json!({
|
||||
"event": "processor",
|
||||
"index": outcome.index,
|
||||
"total": total,
|
||||
"jar": outcome.label,
|
||||
"status": outcome.status.as_str(),
|
||||
"elapsed_ms": outcome.elapsed_ms,
|
||||
});
|
||||
let text = format!(
|
||||
"processor {}/{} {} → {}(输出 {} 个{})",
|
||||
outcome.index,
|
||||
total,
|
||||
outcome.label,
|
||||
if outcome.status.as_str() == "skipped" {
|
||||
"跳过(产物已就绪)"
|
||||
} else {
|
||||
"执行完成"
|
||||
},
|
||||
outcome.outputs,
|
||||
if outcome.elapsed_ms > 0 {
|
||||
format!(",{} ms", outcome.elapsed_ms)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
if json_mode {
|
||||
progress_line(&record.to_string());
|
||||
} else {
|
||||
progress_line(&text);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// processor 执行错误 → CLI 错误(稳定码 `processor_*`)。
|
||||
fn map_processor_error(error: ProcessorError) -> CliError {
|
||||
let cli = CliError::user(error.kind().as_str(), error.message().to_string());
|
||||
match error.hint() {
|
||||
Some(hint) => cli.with_hint(hint.to_string()),
|
||||
None => cli,
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉)。
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `ull install <version> [--loader <kind>] [--loader-version <v>]`:装版本本体 / 装加载器层。
|
||||
//!
|
||||
//! ```text
|
||||
//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register)
|
||||
//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register
|
||||
//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON)
|
||||
//! ```
|
||||
//!
|
||||
//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态
|
||||
//! ([`report`] / [`report_loader`])、把错误翻译成稳定错误码、往 stderr 打实时进度。
|
||||
//! 所有版本/继承/库/下载逻辑都在 core(CLI 不复制任何路径或镜像规则)。
|
||||
|
||||
mod loader;
|
||||
mod loader_processors;
|
||||
mod report;
|
||||
mod report_loader;
|
||||
mod vanilla;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::loader::LoaderKind;
|
||||
use unlucky_core::download::cache::now_ms;
|
||||
use unlucky_core::download::region::{decide_from, load_cached, store_cached};
|
||||
use unlucky_core::download::region_system::detect_signals;
|
||||
use unlucky_core::download::{
|
||||
ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HealthRegistry, MirrorTable,
|
||||
};
|
||||
use unlucky_core::game::install::{InstallError, InstallReport, Registration};
|
||||
|
||||
use crate::cli::InstallArgs;
|
||||
use crate::context::Ctx;
|
||||
use crate::error::CliError;
|
||||
use crate::output::Report;
|
||||
|
||||
/// 文件级并发上限:资源是海量小文件,16 路足够压满带宽又不至于打爆镜像。
|
||||
pub(super) const CONCURRENCY: usize = 16;
|
||||
|
||||
/// 执行 `install`:按 `--loader` 分发。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。
|
||||
pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result<Report, CliError> {
|
||||
match args
|
||||
.loader
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
None | Some("vanilla") | Some("none") => vanilla::run(ctx, args),
|
||||
Some(raw) => match LoaderKind::parse(raw) {
|
||||
Some(kind) => loader::run(ctx, args, kind),
|
||||
None => Err(
|
||||
CliError::user("loader_unknown", format!("未知加载器 `{raw}`"))
|
||||
.with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记实例(幂等):`profiles.json` + `config/instances/<id>.json`。
|
||||
///
|
||||
/// 原版安装与加载器安装共用:前者 `version_id` = 原版 id,后者 = loader 版本 id
|
||||
/// (实例的 `versionId` 必须指向 loader 版本,否则启动会跑成原版)。
|
||||
pub(super) fn register_instance(
|
||||
ctx: &Ctx,
|
||||
name: &str,
|
||||
version_id: &str,
|
||||
) -> Result<Registration, CliError> {
|
||||
let created_at =
|
||||
unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now());
|
||||
unlucky_core::game::install::instance::register(
|
||||
&ctx.layout(),
|
||||
name,
|
||||
version_id,
|
||||
Some(&created_at),
|
||||
)
|
||||
.map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}")))
|
||||
}
|
||||
|
||||
/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。
|
||||
///
|
||||
/// 地区判定**不做网络探测**(P2 首版;探测开关属设置层接线),
|
||||
/// 因此中文环境默认镜像优先、其它环境直连——与 D7 的"不探测时"约定一致。
|
||||
pub(super) fn build_engine(ctx: &Ctx) -> DownloadEngine {
|
||||
let layout = ctx.layout();
|
||||
let cache_root = layout.cache();
|
||||
let now = now_ms();
|
||||
let region_path = cache_root.join("region.json");
|
||||
let region = load_cached(®ion_path, now).unwrap_or_else(|| {
|
||||
let profile = decide_from(&detect_signals(), now);
|
||||
let _ = store_cached(®ion_path, &profile);
|
||||
profile
|
||||
});
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(region),
|
||||
Arc::new(HealthRegistry::open_in(&cache_root, now)),
|
||||
Arc::new(ContentCache::open(&cache_root)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。
|
||||
pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(), CliError> {
|
||||
let profiles = super::instances::read_profiles(ctx)?;
|
||||
let conflict = profiles
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == name && profile.version_id != version);
|
||||
match conflict {
|
||||
Some(profile) => Err(CliError::user(
|
||||
"instance_exists",
|
||||
format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id),
|
||||
)
|
||||
.with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装期错误 → CLI 错误(保留稳定错误码与提示)。
|
||||
pub(super) fn map_install_error(error: &InstallError, ctx: &Ctx) -> CliError {
|
||||
let hint = match error.hint() {
|
||||
Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()),
|
||||
None => format!("游戏根:{}", ctx.game().root().display()),
|
||||
};
|
||||
CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint)
|
||||
}
|
||||
|
||||
/// 有文件失败 → 按"最严重的失败类别"给稳定码,并列出前几条明细。
|
||||
pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError {
|
||||
let code = match report.failure_kind() {
|
||||
Some(DownloadErrorKind::Integrity) => "install_integrity",
|
||||
Some(DownloadErrorKind::Io) => "install_io",
|
||||
_ => "install_network",
|
||||
};
|
||||
let mut message = format!(
|
||||
"{} 个文件没下成(共 {} 个)",
|
||||
report.failed(),
|
||||
report.files.len()
|
||||
);
|
||||
for outcome in report.failures(3) {
|
||||
if let Some(failure) = &outcome.failure {
|
||||
message.push_str(&format!(";{}:{}", outcome.name, failure.message));
|
||||
}
|
||||
}
|
||||
CliError::user(code, message).with_hint(format!(
|
||||
"重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}",
|
||||
ctx.game().root().display()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。
|
||||
//!
|
||||
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
|
||||
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
|
||||
//! 差别只是多出逐文件清单与源选择。
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::install::{
|
||||
FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration,
|
||||
};
|
||||
|
||||
use crate::context::Ctx;
|
||||
use crate::output::{progress_line, Report};
|
||||
|
||||
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
|
||||
const PROGRESS_EVERY: usize = 32;
|
||||
|
||||
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
|
||||
const MAX_PROGRESS_FAILURES: usize = 5;
|
||||
|
||||
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
|
||||
///
|
||||
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
|
||||
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
|
||||
///
|
||||
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
|
||||
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let failures = Arc::new(AtomicUsize::new(0));
|
||||
Some(Arc::new(move |outcome: &FileOutcome| {
|
||||
let index = done.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if let Some(failure) = &outcome.failure {
|
||||
let seen = failures.fetch_add(1, Ordering::Relaxed);
|
||||
if seen < MAX_PROGRESS_FAILURES {
|
||||
emit(
|
||||
json,
|
||||
json!({
|
||||
"event": "file_failed",
|
||||
"done": index,
|
||||
"total": total,
|
||||
"name": outcome.name,
|
||||
"role": outcome.role.as_str(),
|
||||
"kind": failure.kind.as_str(),
|
||||
"message": failure.message,
|
||||
}),
|
||||
&format!("失败 {index}/{total} {}:{}", outcome.name, failure.message),
|
||||
);
|
||||
} else if seen == MAX_PROGRESS_FAILURES {
|
||||
emit(
|
||||
json,
|
||||
json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}),
|
||||
"(更多失败不再逐条打印,见最终错误汇总)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if index.is_multiple_of(PROGRESS_EVERY) || index == total {
|
||||
emit(
|
||||
json,
|
||||
json!({"event": "progress", "done": index, "total": total}),
|
||||
&format!("下载 {index}/{total}"),
|
||||
);
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
|
||||
fn emit(json_mode: bool, record: serde_json::Value, text: &str) {
|
||||
if json_mode {
|
||||
progress_line(&record.to_string());
|
||||
} else {
|
||||
progress_line(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// 排版一次安装结果。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
now: u64,
|
||||
dry_run: bool,
|
||||
plan: &InstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> Report {
|
||||
let samples = if dry_run {
|
||||
plan.source_samples(engine, now)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut data = json!({
|
||||
"dry_run": dry_run,
|
||||
"version_id": plan.version_id,
|
||||
"jar_id": plan.jar_id,
|
||||
"game_root": plan.game_root.display().to_string(),
|
||||
"plan": plan.summary_json(),
|
||||
"result": report.map(|report| json!({
|
||||
"downloaded": report.downloaded(),
|
||||
"skipped": report.skipped(),
|
||||
"failed": report.failed(),
|
||||
"bytes": report.bytes(),
|
||||
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
|
||||
"warnings": report.warnings,
|
||||
"files": report.files.iter().map(outcome_json).collect::<Vec<_>>(),
|
||||
})),
|
||||
"instance": registration.map(|registration| json!({
|
||||
"id": registration.profile.id.as_str(),
|
||||
"name": registration.profile.name,
|
||||
"version_id": registration.profile.version_id,
|
||||
"created": registration.created,
|
||||
"config_path": registration.config_path.display().to_string(),
|
||||
"profiles_path": ctx.profiles_config().display().to_string(),
|
||||
})),
|
||||
});
|
||||
if dry_run {
|
||||
data["files"] = json!(plan.files_json());
|
||||
data["sources"] = json!(samples);
|
||||
}
|
||||
|
||||
Report::new(
|
||||
data,
|
||||
human_lines(dry_run, plan, report, registration, &samples, name, version),
|
||||
)
|
||||
}
|
||||
|
||||
/// 单个文件的执行结果(`--json` 的 `result.files`;脚本据此分流"哪几个没下成")。
|
||||
fn outcome_json(outcome: &FileOutcome) -> serde_json::Value {
|
||||
json!({
|
||||
"role": outcome.role.as_str(),
|
||||
"name": outcome.name,
|
||||
"dest": outcome.dest.display().to_string(),
|
||||
"status": outcome.status.as_str(),
|
||||
"bytes": outcome.bytes,
|
||||
"source": outcome.source,
|
||||
"error": outcome.failure.as_ref().map(|failure| json!({
|
||||
"kind": failure.kind.as_str(),
|
||||
"message": failure.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn human_lines(
|
||||
dry_run: bool,
|
||||
plan: &InstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
samples: &[serde_json::Value],
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> Vec<String> {
|
||||
let mut human = Vec::new();
|
||||
let (total_bytes, unknown) = plan.total_bytes();
|
||||
human.push(if dry_run {
|
||||
format!("安装计划:{}(jar {})", plan.version_id, plan.jar_id)
|
||||
} else {
|
||||
format!("已安装 {}(jar {})", plan.version_id, plan.jar_id)
|
||||
});
|
||||
for (role, (count, bytes)) in plan.by_role() {
|
||||
human.push(format!(
|
||||
" {:<8} {} 个 / {}",
|
||||
role.label(),
|
||||
count,
|
||||
human_bytes(bytes)
|
||||
));
|
||||
}
|
||||
human.push(format!(
|
||||
" 合计 {} 个文件 / {}{}(游戏根 {})",
|
||||
plan.file_count(),
|
||||
human_bytes(total_bytes),
|
||||
if unknown > 0 {
|
||||
format!(" + {unknown} 个大小未知")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
plan.game_root.display()
|
||||
));
|
||||
for sample in samples {
|
||||
let sources: Vec<&str> = sample["candidates"]
|
||||
.as_array()
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|candidate| candidate["source"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
human.push(format!(
|
||||
" 源选择 {} → {}",
|
||||
sample["label"].as_str().unwrap_or_default(),
|
||||
sources.join(" → ")
|
||||
));
|
||||
}
|
||||
for warning in &plan.warnings {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
if let Some(report) = report {
|
||||
human.push(format!(
|
||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||
report.downloaded(),
|
||||
report.skipped(),
|
||||
report.failed(),
|
||||
human_bytes(report.bytes())
|
||||
));
|
||||
if let Some(path) = &report.version_json {
|
||||
human.push(format!("版本清单: {}", path.display()));
|
||||
}
|
||||
}
|
||||
match registration {
|
||||
Some(registration) => human.push(format!(
|
||||
"已登记实例 {}({})→ 版本 {}({})",
|
||||
name,
|
||||
registration.profile.id.as_str(),
|
||||
version,
|
||||
if registration.created {
|
||||
"新建"
|
||||
} else {
|
||||
"已存在,复用"
|
||||
}
|
||||
)),
|
||||
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
|
||||
}
|
||||
human
|
||||
}
|
||||
|
||||
/// 字节 → 人读(MB/GB 一位小数,KB 取整;不做假精度)。
|
||||
pub fn human_bytes(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{} KB", bytes / KB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::human_bytes;
|
||||
|
||||
#[test]
|
||||
fn formats_sizes_without_fake_precision() {
|
||||
assert_eq!(human_bytes(0), "0 B");
|
||||
assert_eq!(human_bytes(1023), "1023 B");
|
||||
assert_eq!(human_bytes(1024), "1 KB");
|
||||
assert_eq!(human_bytes(1024 * 1024), "1.0 MB");
|
||||
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 加载器安装的呈现形态(人类可读行 + `--json` 载荷)。
|
||||
//!
|
||||
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
|
||||
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
|
||||
use unlucky_core::addon::loader::LoaderInstallPlan;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
use unlucky_core::game::install::{InstallReport, Registration};
|
||||
|
||||
use super::report::human_bytes;
|
||||
use crate::context::Ctx;
|
||||
use crate::output::Report;
|
||||
|
||||
/// processors 段(M35)的报告输入:计划 + 依赖库下载结果 + 执行结果。
|
||||
///
|
||||
/// `--dry-run` 只有 `plan`;幂等跳过时 `libraries`/`run` 都是 `None` 且 `skipped = true`。
|
||||
pub struct ProcessorSection<'a> {
|
||||
/// 执行计划(dry-run 也带上:将执行的 processor 清单 + 所需 Java)。
|
||||
pub plan: &'a ProcessorPlan,
|
||||
/// processor 依赖库的下载结果。
|
||||
pub libraries: Option<&'a InstallReport>,
|
||||
/// 执行结果。
|
||||
pub run: Option<&'a ProcessorRun>,
|
||||
/// 整段跳过(版本 JSON 已在盘上 = processors 早已跑完)。
|
||||
pub skipped: bool,
|
||||
}
|
||||
|
||||
/// 排版一次加载器安装结果。
|
||||
///
|
||||
/// 参数多是因为载荷本身就多:计划(loader 版本 + 库)、processors 段、执行结果、实例登记、
|
||||
/// `--dry-run` 的源选择样本。拆成结构体会让调用方多写十行,不如保持扁平。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build(
|
||||
ctx: &Ctx,
|
||||
engine: &DownloadEngine,
|
||||
now: u64,
|
||||
dry_run: bool,
|
||||
plan: &LoaderInstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
name: &str,
|
||||
processors: Option<ProcessorSection<'_>>,
|
||||
) -> Report {
|
||||
let samples = if dry_run {
|
||||
plan.install.source_samples(engine, now)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut data = json!({
|
||||
"dry_run": dry_run,
|
||||
"loader": plan.release.to_json(),
|
||||
"vanilla_install_needed": plan.vanilla_install_needed,
|
||||
"game_root": plan.install.game_root.display().to_string(),
|
||||
"plan": plan.summary_json(),
|
||||
"result": report.map(|report| json!({
|
||||
"downloaded": report.downloaded(),
|
||||
"skipped": report.skipped(),
|
||||
"failed": report.failed(),
|
||||
"bytes": report.bytes(),
|
||||
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
|
||||
"warnings": report.warnings,
|
||||
})),
|
||||
"instance": registration.map(|registration| json!({
|
||||
"id": registration.profile.id.as_str(),
|
||||
"name": registration.profile.name,
|
||||
"version_id": registration.profile.version_id,
|
||||
"created": registration.created,
|
||||
"config_path": registration.config_path.display().to_string(),
|
||||
"profiles_path": ctx.profiles_config().display().to_string(),
|
||||
})),
|
||||
});
|
||||
if dry_run {
|
||||
data["files"] = json!(plan.install.files_json());
|
||||
data["sources"] = json!(samples);
|
||||
}
|
||||
if let Some(section) = &processors {
|
||||
data["processors"] = processor_json(section);
|
||||
}
|
||||
|
||||
Report::new(
|
||||
data,
|
||||
human_lines(
|
||||
dry_run,
|
||||
plan,
|
||||
report,
|
||||
registration,
|
||||
&samples,
|
||||
name,
|
||||
processors.as_ref(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// processors 段的 JSON 载荷(计划 + 依赖库结果 + 逐步骤结果)。
|
||||
fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
|
||||
let mut node = section.plan.to_json();
|
||||
if let Some(object) = node.as_object_mut() {
|
||||
object.insert("skipped".to_string(), json!(section.skipped));
|
||||
object.insert(
|
||||
"libraries_result".to_string(),
|
||||
json!(section.libraries.map(|report| json!({
|
||||
"downloaded": report.downloaded(),
|
||||
"skipped": report.skipped(),
|
||||
"failed": report.failed(),
|
||||
"bytes": report.bytes(),
|
||||
}))),
|
||||
);
|
||||
object.insert(
|
||||
"run".to_string(),
|
||||
json!(section.run.map(ProcessorRun::to_json)),
|
||||
);
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn human_lines(
|
||||
dry_run: bool,
|
||||
plan: &LoaderInstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
samples: &[serde_json::Value],
|
||||
name: &str,
|
||||
processors: Option<&ProcessorSection<'_>>,
|
||||
) -> Vec<String> {
|
||||
let release = &plan.release;
|
||||
let mut human = Vec::new();
|
||||
human.push(format!(
|
||||
"{} {}《{} {}》(继承原版 {},主类 {})",
|
||||
if dry_run {
|
||||
"加载器安装计划"
|
||||
} else {
|
||||
"已安装加载器"
|
||||
},
|
||||
release.kind.label(),
|
||||
release.mc_version,
|
||||
release.loader_version,
|
||||
release.mc_version,
|
||||
release.version_json.main_class.as_deref().unwrap_or("?"),
|
||||
));
|
||||
human.push(format!(
|
||||
" 版本 id {} → {}",
|
||||
release.version_id,
|
||||
plan.install.version_json_path.display()
|
||||
));
|
||||
human.push(format!(
|
||||
" 原版 {}",
|
||||
if plan.vanilla_install_needed {
|
||||
"未安装:执行时先装原版"
|
||||
} else {
|
||||
"已安装:跳过"
|
||||
}
|
||||
));
|
||||
let (total_bytes, unknown) = plan.install.total_bytes();
|
||||
human.push(format!(
|
||||
" loader 库 {} 个 / {}{}",
|
||||
plan.install.file_count(),
|
||||
human_bytes(total_bytes),
|
||||
if unknown > 0 {
|
||||
format!(" + {unknown} 个大小未知")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
for sample in samples {
|
||||
let sources: Vec<&str> = sample["candidates"]
|
||||
.as_array()
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|candidate| candidate["source"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
human.push(format!(
|
||||
" 源选择 {} → {}",
|
||||
sample["label"].as_str().unwrap_or_default(),
|
||||
sources.join(" → ")
|
||||
));
|
||||
}
|
||||
for warning in plan.warnings() {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
if let Some(section) = processors {
|
||||
human.extend(processor_lines(section));
|
||||
}
|
||||
if let Some(report) = report {
|
||||
human.push(format!(
|
||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||
report.downloaded(),
|
||||
report.skipped(),
|
||||
report.failed(),
|
||||
human_bytes(report.bytes())
|
||||
));
|
||||
if let Some(path) = &report.version_json {
|
||||
human.push(format!("loader 版本清单: {}", path.display()));
|
||||
}
|
||||
}
|
||||
match registration {
|
||||
Some(registration) => human.push(format!(
|
||||
"已登记实例 {}({})→ 版本 {}({})",
|
||||
name,
|
||||
registration.profile.id.as_str(),
|
||||
registration.profile.version_id,
|
||||
if registration.created {
|
||||
"新建"
|
||||
} else {
|
||||
"已存在,复用"
|
||||
}
|
||||
)),
|
||||
None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()),
|
||||
}
|
||||
human
|
||||
}
|
||||
|
||||
/// processors 段的人读行(计划 / 依赖库 / 逐步骤 / 告警)。
|
||||
fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
|
||||
let plan = section.plan;
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!(
|
||||
" installer processors {} 步,需要 {}(installer {})",
|
||||
plan.step_count(),
|
||||
plan.java_requirement.describe(),
|
||||
plan.installer.display()
|
||||
));
|
||||
if section.skipped {
|
||||
lines.push(" 版本清单已存在:processors 整段跳过(幂等)".to_string());
|
||||
}
|
||||
if let Some(libraries) = section.libraries {
|
||||
lines.push(format!(
|
||||
" processor 依赖库 {} 个:下载 {} / 跳过 {} / 失败 {}",
|
||||
plan.libraries.len(),
|
||||
libraries.downloaded(),
|
||||
libraries.skipped(),
|
||||
libraries.failed()
|
||||
));
|
||||
}
|
||||
if let Some(run) = section.run {
|
||||
lines.push(format!(
|
||||
" Java {}({}){}",
|
||||
run.java
|
||||
.major
|
||||
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
|
||||
run.java.origin.as_str(),
|
||||
run.java.binary.display()
|
||||
));
|
||||
let total = run.steps.len();
|
||||
for step in &run.steps {
|
||||
lines.push(format!(
|
||||
" [{}/{}] {} → {}{}",
|
||||
step.index,
|
||||
total,
|
||||
step.label,
|
||||
if step.status.as_str() == "skipped" {
|
||||
"跳过(产物已就绪)".to_string()
|
||||
} else {
|
||||
format!("执行 {} ms", step.elapsed_ms)
|
||||
},
|
||||
if step.outputs > 0 {
|
||||
format!("(产物 {} 个)", step.outputs)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
}
|
||||
for warning in &run.warnings {
|
||||
lines.push(format!(" 告警: {warning}"));
|
||||
}
|
||||
}
|
||||
for warning in &plan.warnings {
|
||||
lines.push(format!(" 告警: {warning}"));
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! `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, 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:ull 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)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`",
|
||||
));
|
||||
}
|
||||
let sink = report::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>,
|
||||
}
|
||||
Reference in new issue
Block a user