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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+159
View File
@@ -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(&registration),
&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()
}
+151
View File
@@ -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(&region_path, now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = store_cached(&region_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()
))
}
+262
View File
@@ -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
}
+160
View File
@@ -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>,
}