r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。
改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。
同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。
回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。
占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
152 lines
6.1 KiB
Rust
152 lines
6.1 KiB
Rust
//! `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 换个名字,或 `%prog% 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!(
|
||
"重跑 `%prog% install` 会续传并跳过已就绪文件;游戏根 {}",
|
||
ctx.game().root().display()
|
||
))
|
||
}
|