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,247 @@
|
||||
//! Fabric 系(Fabric / Quilt):官方 meta → loader 版本 JSON。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.4:这两个加载器**没有 installer**,
|
||||
//! `meta.fabricmc.net` / `meta.quiltmc.org` 直接给 launcher 可用的 profile JSON,
|
||||
//! 所以安装 = "把 profile 收成我们的版本 JSON + 补库",没有任何子进程。
|
||||
//!
|
||||
//! 本文件是**纯函数**(不进网络):URL 拼装与 JSON 归一化分开写,测试直接喂离线夹具。
|
||||
//!
|
||||
//! # 归一化做了三件容易被上游坑的事
|
||||
//!
|
||||
//! 1. **`jar` 钉成原版 id**:profile 通常不写 `jar`,而 `game::inherit::resolve` 在缺 `jar`
|
||||
//! 时会把 `jar` 兜底成**自身 id**——那会让启动去找 `versions/<loader>/<loader>.jar`
|
||||
//! (不存在)。这里显式写 `jar = <原版 id>`,让继承链正确地回到原版 jar。
|
||||
//! 2. **`mainClass` 断言**:缺主类就不是一份能启动的清单,宁可在安装期报 metadata,
|
||||
//! 也不要装出一个起不来的实例(HMCL 同款防御,analyse/05 §1.4)。
|
||||
//! 3. **扁平 `sha1`/`size` 提升成 `downloads.artifact`**:Fabric profile 的库把摘要写在
|
||||
//! 库对象顶层,M27 的库展开只认 `downloads.artifact`。不提上来的后果是"没有校验值 →
|
||||
//! 存在即信",重装时无法识别被截断的库文件。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::plan::{LoaderRelease, LoaderSupport};
|
||||
use super::{LoaderError, LoaderKind};
|
||||
use crate::game::library::{Library, LibraryDownloads};
|
||||
use crate::game::manifest::{DownloadInfo, VersionJson};
|
||||
|
||||
/// Fabric 官方 meta 根(BMCLAPI 有对应镜像规则 `bmclapi-fabric-meta`)。
|
||||
pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
|
||||
/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。
|
||||
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
|
||||
|
||||
/// meta 根地址(按加载器)。
|
||||
#[must_use]
|
||||
pub fn meta_base(kind: LoaderKind) -> &'static str {
|
||||
match kind {
|
||||
LoaderKind::Quilt => QUILT_META,
|
||||
_ => FABRIC_META,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该原版版本可用的加载器版本列表地址。
|
||||
#[must_use]
|
||||
pub fn loader_list_url(kind: LoaderKind, mc_version: &str) -> String {
|
||||
format!("{}/versions/loader/{mc_version}", meta_base(kind))
|
||||
}
|
||||
|
||||
/// 指定加载器版本的 launcher profile 地址(官方直出可用的版本 JSON)。
|
||||
#[must_use]
|
||||
pub fn profile_url(kind: LoaderKind, mc_version: &str, loader_version: &str) -> String {
|
||||
format!(
|
||||
"{}/versions/loader/{mc_version}/{loader_version}/profile/json",
|
||||
meta_base(kind)
|
||||
)
|
||||
}
|
||||
|
||||
/// 列表里的一个加载器版本。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LoaderEntry {
|
||||
/// 版本号(`0.15.11`)。
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
/// 是否稳定版(Quilt 的列表不带这个键,缺省按稳定处理)。
|
||||
#[serde(default = "default_true")]
|
||||
pub stable: bool,
|
||||
/// Maven 坐标(`net.fabricmc:fabric-loader:0.15.11`)。
|
||||
#[serde(default)]
|
||||
pub maven: String,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 列表响应的一行(`[{ "loader": {…}, "intermediary": {…}, … }]`)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListRow {
|
||||
#[serde(default)]
|
||||
loader: Option<LoaderEntry>,
|
||||
}
|
||||
|
||||
/// 解析版本列表(最新的排在前,与官方顺序一致——**不重排**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法、或列表形状完全不符(顶层不是数组 / 一条都认不出)时返回 metadata 错误。
|
||||
pub fn parse_loader_list(kind: LoaderKind, json: &str) -> Result<Vec<LoaderEntry>, LoaderError> {
|
||||
let rows: Vec<ListRow> = serde_json::from_str(json).map_err(|error| {
|
||||
LoaderError::metadata(format!("{} 版本列表解析失败:{error}", kind.label()))
|
||||
})?;
|
||||
let entries: Vec<LoaderEntry> = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| row.loader)
|
||||
.filter(|entry| !entry.version.is_empty())
|
||||
.collect();
|
||||
if entries.is_empty() {
|
||||
return Err(LoaderError::not_found(format!(
|
||||
"{} 版本列表为空(该原版版本可能还没有对应加载器)",
|
||||
kind.label()
|
||||
)));
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 从列表里挑一个默认版本:优先第一个稳定版,否则取第一个(列表最新在前)。
|
||||
#[must_use]
|
||||
pub fn pick_default(entries: &[LoaderEntry]) -> Option<&LoaderEntry> {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.stable)
|
||||
.or_else(|| entries.first())
|
||||
}
|
||||
|
||||
/// 把官方 profile JSON 归一化成 `<mc>-<kind>-<loader>` 的 loader 版本 JSON。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// profile 不是合法版本 JSON、缺主类时返回 metadata 错误。
|
||||
pub fn build_release(
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
loader_version: &str,
|
||||
profile_json: &str,
|
||||
) -> Result<LoaderRelease, LoaderError> {
|
||||
let mut version_json = VersionJson::from_json_str(profile_json).map_err(|error| {
|
||||
LoaderError::metadata(format!(
|
||||
"{} {loader_version} 的 profile JSON 解析失败:{error}",
|
||||
kind.label()
|
||||
))
|
||||
})?;
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let version_id = kind.version_id(mc_version, loader_version);
|
||||
version_json.id = Some(version_id.clone());
|
||||
version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label()));
|
||||
version_json.inherits_from = Some(mc_version.to_string());
|
||||
// 启动用原版 jar(见模块文档第 1 条)。
|
||||
version_json.jar = Some(mc_version.to_string());
|
||||
|
||||
if version_json
|
||||
.main_class
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"{} {loader_version} 的 profile 没有 mainClass:这份清单装上去也起不来",
|
||||
kind.label()
|
||||
))
|
||||
.with_hint("换一个加载器版本,或用官方启动器核对"));
|
||||
}
|
||||
|
||||
let libraries = version_json.libraries.get_or_insert_with(Vec::new);
|
||||
for library in libraries.iter_mut() {
|
||||
lift_flat_digest(library);
|
||||
}
|
||||
ensure_loader_library(kind, loader_version, libraries, &mut warnings);
|
||||
|
||||
Ok(LoaderRelease {
|
||||
kind,
|
||||
mc_version: mc_version.to_string(),
|
||||
loader_version: loader_version.to_string(),
|
||||
version_id,
|
||||
version_json,
|
||||
support: LoaderSupport::JsonRoute,
|
||||
installer: None,
|
||||
install_profile: None,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 把库对象顶层的 `sha1`/`size` 提升成 `downloads.artifact`(Fabric profile 的写法)。
|
||||
fn lift_flat_digest(library: &mut Library) {
|
||||
if library.downloads.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(base) = library
|
||||
.url
|
||||
.as_deref()
|
||||
.filter(|url| !url.is_empty())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(relative) = library.relative_path() else {
|
||||
return;
|
||||
};
|
||||
let sha1 = library
|
||||
.extra
|
||||
.get("sha1")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let size = library
|
||||
.extra
|
||||
.get("size")
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
let path = relative.to_string_lossy().replace('\\', "/");
|
||||
let url = if base.ends_with('/') {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
};
|
||||
library.downloads = Some(LibraryDownloads {
|
||||
artifact: Some(DownloadInfo {
|
||||
id: None,
|
||||
sha1,
|
||||
size,
|
||||
url: Some(url),
|
||||
path: Some(path),
|
||||
}),
|
||||
classifiers: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。
|
||||
fn ensure_loader_library(
|
||||
kind: LoaderKind,
|
||||
loader_version: &str,
|
||||
libraries: &mut Vec<Library>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let (group, artifact) = match kind {
|
||||
LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"),
|
||||
_ => ("net.fabricmc", "fabric-loader"),
|
||||
};
|
||||
let present = libraries.iter().any(|library| {
|
||||
library
|
||||
.coordinate()
|
||||
.is_ok_and(|coordinate| coordinate.group == group && coordinate.artifact == artifact)
|
||||
});
|
||||
if present {
|
||||
return;
|
||||
}
|
||||
let maven_base = match kind {
|
||||
LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/",
|
||||
_ => "https://maven.fabricmc.net/",
|
||||
};
|
||||
libraries.push(Library {
|
||||
name: format!("{group}:{artifact}:{loader_version}"),
|
||||
url: Some(maven_base.to_string()),
|
||||
..Library::default()
|
||||
});
|
||||
warnings.push(format!(
|
||||
"{} {loader_version} 的 profile 没带 {group}:{artifact}:已按 maven 坐标补一条(缺库会让游戏起不来)",
|
||||
kind.label()
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! NeoForge / Forge:installer 内的 install profile → loader 版本 JSON。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2/§1.3:Forge 有两代 installer 协议,
|
||||
//! NeoForge 是 fork(早期与 Forge 完全同构)。判据也在那儿:install profile 有没有
|
||||
//! `spec` / processors、有没有独立的 `version.json`。maven 坐标与版本表在 [`super::maven`]。
|
||||
//!
|
||||
//! # 本模块的取舍(为什么只做一部分)
|
||||
//!
|
||||
//! 这两家都**不发**launcher 直接可用的 profile,只发 installer jar;能纯 json 化到什么程度
|
||||
//! 取决于 installer 声明的处理器:
|
||||
//!
|
||||
//! | install profile 形状 | 实测样例 | 本模块结论 |
|
||||
//! |----------------------|----------|-----------|
|
||||
//! | `spec: 1` + 有 client 侧 processors | Forge 1.20.1-47.2.0、NeoForge 21.1.80 | [`LoaderSupport::RequiresInstaller`] |
|
||||
//! | `spec: 0` + 无 processors + 内嵌 `version.json` | Forge 1.12.2-14.23.5.2860 | [`LoaderSupport::JsonRoute`] |
|
||||
//! | 只有内联 `versionInfo`(+ `install.filePath` 解包) | 1.12.2 之前的老重建版 | 明确 unsupported |
|
||||
//!
|
||||
//! 第一类必须跑 `java -cp … installertools …` 才能产出 `client-slim/extra` 等打补丁产物
|
||||
//! (processor 的 `data` 变量表里有 `MC_SLIM`/`MC_EXTRA`/`PATCHED`),解析结果因此带上
|
||||
//! [`LoaderSupport::RequiresInstaller`] 标记 + install profile 本体,交给
|
||||
//! [`super::processors`](M35 执行器)真跑;这里的**判定**仍只管「要不要跑 installer」。
|
||||
//!
|
||||
//! 第二类的本体 jar 在 version.json 里 `url` 是空串,且**官方 maven 只发布 `-universal`
|
||||
//! classifier**(无 classifier 路径官方 404,只有 BMCLAPI 的 302 别名兜底)——
|
||||
//! 补救见 [`fill_missing_urls`],URL 带 classifier、磁盘落点保持官方 installer 的原样。
|
||||
//!
|
||||
//! # 一条容易踩的坑:processor 的库不进启动 classpath
|
||||
//!
|
||||
//! install profile 自带的 `libraries` 是**processor 依赖**(installertools/srgutils…),
|
||||
//! 它们不属于游戏 classpath。把它们并进 loader 版本 JSON 会让启动多加载一堆无关 jar,
|
||||
//! 因此这里只记数量进告警、绝不合并;真下载它们的是 [`super::processors`]。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::maven;
|
||||
use super::plan::{LoaderRelease, LoaderSupport};
|
||||
use super::{LoaderError, LoaderKind};
|
||||
use crate::game::library::Library;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
pub use super::processors::spec::{Datum, Processor};
|
||||
|
||||
/// 老协议 installer 的 `install` 段(`filePath` 指向内嵌的 universal jar)。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyInstall {
|
||||
/// 内嵌 jar 在 installer 里的相对路径。
|
||||
#[serde(default)]
|
||||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
/// installer 里的 `install_profile.json`(只解析我们需要的键,其余忽略)。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallProfile {
|
||||
/// 协议代号(`1` = processor 时代)。
|
||||
#[serde(default)]
|
||||
pub spec: Option<i64>,
|
||||
/// 组件名(`forge` / `NeoForge`)。
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
/// installer 自己的版本串(`1.20.1-forge-47.2.0`)。
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// 目标原版版本。
|
||||
#[serde(default)]
|
||||
pub minecraft: Option<String>,
|
||||
/// 内嵌版本 JSON 在 installer 里的路径(通常是 `/version.json`)。
|
||||
#[serde(default, rename = "json")]
|
||||
pub version_json_path: Option<String>,
|
||||
/// universal jar 的 maven 坐标(`net.minecraftforge:forge:…`,spec 0 才有)。
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// processor 列表。
|
||||
#[serde(default)]
|
||||
pub processors: Vec<Processor>,
|
||||
/// processor 的变量表(`{MAPPINGS}` / `{MC_SLIM}` 之类占位的数据源)。
|
||||
#[serde(default)]
|
||||
pub data: BTreeMap<String, Datum>,
|
||||
/// processor / universal jar 依赖(**不是**启动 classpath,别合并)。
|
||||
#[serde(default)]
|
||||
pub libraries: Vec<Library>,
|
||||
/// 老协议:内嵌 jar 解包信息。
|
||||
#[serde(default)]
|
||||
pub install: Option<LegacyInstall>,
|
||||
/// 老协议:内联的版本 JSON 片段。
|
||||
#[serde(default, rename = "versionInfo")]
|
||||
pub version_info: Option<VersionJson>,
|
||||
}
|
||||
|
||||
impl InstallProfile {
|
||||
/// client 侧 processor 数量。
|
||||
#[must_use]
|
||||
pub fn client_processors(&self) -> usize {
|
||||
self.processors
|
||||
.iter()
|
||||
.filter(|processor| processor.applies_to_client())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 是否是老协议 installer(内联 `versionInfo` + 解包 universal jar)。
|
||||
#[must_use]
|
||||
pub fn is_legacy_protocol(&self) -> bool {
|
||||
self.version_json_path.is_none() && (self.install.is_some() || self.version_info.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 install profile + 内嵌版本 JSON 组 loader 版本。
|
||||
///
|
||||
/// `embedded` 是 installer 内 `version.json` 的内容(读不到就是 `None`)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 原版版本对不上、installer 里没有 `version.json`、生成的清单缺主类时返回错误。
|
||||
pub fn build_release(
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
maven_version: &str,
|
||||
profile: &InstallProfile,
|
||||
embedded: Option<&VersionJson>,
|
||||
) -> Result<LoaderRelease, LoaderError> {
|
||||
if let Some(declared) = profile.minecraft.as_deref() {
|
||||
if declared != mc_version {
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"installer 声明的原版是 {declared},与目标的 {mc_version} 不符(选错了加载器版本)"
|
||||
))
|
||||
.with_hint("`--loader-version` 要和 `install <version>` 的原版版本配套"));
|
||||
}
|
||||
}
|
||||
let loader_version = maven::display_loader_version(kind, mc_version, maven_version);
|
||||
let version_id = kind.version_id(mc_version, &loader_version);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let Some(embedded) = embedded else {
|
||||
let reason = if profile.is_legacy_protocol() {
|
||||
format!(
|
||||
"{} {} 走老协议 installer(内联 versionInfo + 解包 universal jar),本期不做解包路线",
|
||||
kind.label(),
|
||||
loader_version
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} {} 的 installer 里没有可读的 version.json",
|
||||
kind.label(),
|
||||
loader_version
|
||||
)
|
||||
};
|
||||
return Err(LoaderError::unsupported(reason).with_hint(
|
||||
"这段协议要动 installer 内的 jar,属后续 mission;可先用官方启动器装好该加载器",
|
||||
));
|
||||
};
|
||||
|
||||
let mut version_json = embedded.clone();
|
||||
let mut support = LoaderSupport::JsonRoute;
|
||||
let client_processors = profile.client_processors();
|
||||
if client_processors > 0 {
|
||||
support = LoaderSupport::RequiresInstaller {
|
||||
reason: format!(
|
||||
"{} {} 的 installer 声明了 {client_processors} 个 client 侧 processor(拆包/映射/打补丁),必须先外调 java 执行",
|
||||
kind.label(),
|
||||
loader_version
|
||||
),
|
||||
};
|
||||
warnings.push(format!(
|
||||
"install profile 里另有 {} 个 processor 依赖(installertools 等):它们不进启动 classpath,只在安装期给执行器用",
|
||||
profile.libraries.len()
|
||||
));
|
||||
}
|
||||
|
||||
version_json.id = Some(version_id.clone());
|
||||
version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label()));
|
||||
version_json.inherits_from = Some(mc_version.to_string());
|
||||
version_json.jar = Some(mc_version.to_string());
|
||||
|
||||
if version_json
|
||||
.main_class
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"{} {} 的 version.json 没有 mainClass",
|
||||
kind.label(),
|
||||
loader_version
|
||||
)));
|
||||
}
|
||||
|
||||
let libraries = version_json.libraries.get_or_insert_with(Vec::new);
|
||||
let installer_body = profile.path.as_deref();
|
||||
for library in libraries.iter_mut() {
|
||||
fill_missing_urls(library, maven::base(kind), installer_body);
|
||||
}
|
||||
|
||||
Ok(LoaderRelease {
|
||||
kind,
|
||||
mc_version: mc_version.to_string(),
|
||||
loader_version,
|
||||
version_id,
|
||||
version_json,
|
||||
support,
|
||||
installer: None,
|
||||
install_profile: Some(profile.clone()),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// spec 0 的 version.json 里,forge 本体库的 `url` 常是空串(jar 原本内嵌在 installer 里),
|
||||
/// 于是"补一个 maven 地址"就能直取,省掉解包 installer。
|
||||
///
|
||||
/// ⚠️ 本体 jar 的**发布 classifier 是 `universal`**:installer 内嵌的条目叫
|
||||
/// `maven/…/forge-<ver>.jar`(无 classifier),但官方 maven 只托管
|
||||
/// `forge-<ver>-universal.jar`——无 classifier 的那条路径**官方 404**
|
||||
/// (实测 1.12.2-14.23.5.2860:`-universal.jar` 200 / 4466148 字节,sha1 与 version.json 一致;
|
||||
/// 无 classifier 路径仅靠 BMCLAPI 的 302 别名兜底)。
|
||||
/// 因此这里**只改 URL**:给 installer 声明的本体构件追加 `-universal`;
|
||||
/// `artifact.path`(磁盘落点)保持官方 installer 的原样,启动 classpath 与官方布局一致。
|
||||
fn fill_missing_urls(library: &mut Library, base: &str, installer_body: Option<&str>) {
|
||||
if library
|
||||
.url
|
||||
.as_deref()
|
||||
.is_none_or(|url| url.trim().is_empty())
|
||||
{
|
||||
library.url = Some(if base.ends_with('/') {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/")
|
||||
});
|
||||
}
|
||||
// 先算"是不是 installer 声明的本体"(要读 library 的坐标),再拿 artifact 的可变借用。
|
||||
let is_body = is_installer_body(library, installer_body);
|
||||
let root = library.url.clone().unwrap_or_else(|| base.to_string());
|
||||
let Some(artifact) = library
|
||||
.downloads
|
||||
.as_mut()
|
||||
.and_then(|downloads| downloads.artifact.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if artifact.has_url() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = artifact.path.clone() else {
|
||||
return;
|
||||
};
|
||||
let remote_path = if is_body {
|
||||
with_universal_classifier(&path)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
artifact.url = Some(if root.ends_with('/') {
|
||||
format!("{root}{remote_path}")
|
||||
} else {
|
||||
format!("{root}/{remote_path}")
|
||||
});
|
||||
}
|
||||
|
||||
/// 这条库是不是 install profile 的 `path` 声明的本体构件(`group:artifact:version`,无 classifier)。
|
||||
fn is_installer_body(library: &Library, installer_body: Option<&str>) -> bool {
|
||||
let Some(body) = installer_body else {
|
||||
return false;
|
||||
};
|
||||
let mut parts = body.split(':');
|
||||
let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
library.coordinate().is_ok_and(|coordinate| {
|
||||
coordinate.classifier.is_none()
|
||||
&& coordinate.group == group
|
||||
&& coordinate.artifact == artifact
|
||||
})
|
||||
}
|
||||
|
||||
/// `…/forge-1.12.2-14.23.5.2860.jar` → `…/forge-1.12.2-14.23.5.2860-universal.jar`。
|
||||
fn with_universal_classifier(path: &str) -> String {
|
||||
match path.strip_suffix(".jar") {
|
||||
Some(stem) => format!("{stem}-universal.jar"),
|
||||
None => path.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Maven 坐标与版本表(Forge / NeoForge):版本列表地址、版本选择、installer 地址。
|
||||
//!
|
||||
//! 与 [`super::forge`] 的分工:这里只管"maven 上有什么版本、jar 在哪",
|
||||
//! 那边只管"installer 里的 profile 怎么变成版本 JSON"。
|
||||
//!
|
||||
//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` /
|
||||
//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download`
|
||||
//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。
|
||||
|
||||
use super::LoaderKind;
|
||||
|
||||
/// NeoForge maven 根(规则 `bmclapi-neoforge` 把 `/releases` 重写到 `/maven`)。
|
||||
pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases";
|
||||
/// Forge maven 根(规则 `bmclapi-forge-maven-net`)。
|
||||
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
|
||||
/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。
|
||||
pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1";
|
||||
|
||||
/// maven 根地址。
|
||||
#[must_use]
|
||||
pub fn base(kind: LoaderKind) -> &'static str {
|
||||
match kind {
|
||||
LoaderKind::NeoForge => NEOFORGE_MAVEN,
|
||||
_ => FORGE_MAVEN,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该原版版本对应的 `maven-metadata.xml` 地址。
|
||||
#[must_use]
|
||||
pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String {
|
||||
let (root, path) = match kind {
|
||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
|
||||
(NEOFORGE_MAVEN, "net/neoforged/forge")
|
||||
}
|
||||
LoaderKind::NeoForge => (NEOFORGE_MAVEN, "net/neoforged/neoforge"),
|
||||
_ => (FORGE_MAVEN, "net/minecraftforge/forge"),
|
||||
};
|
||||
format!("{root}/{path}/maven-metadata.xml")
|
||||
}
|
||||
|
||||
/// installer jar 地址。
|
||||
#[must_use]
|
||||
pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
||||
let (root, path, file) = match kind {
|
||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => (
|
||||
NEOFORGE_MAVEN,
|
||||
"net/neoforged/forge",
|
||||
format!("forge-{maven_version}-installer.jar"),
|
||||
),
|
||||
LoaderKind::NeoForge => (
|
||||
NEOFORGE_MAVEN,
|
||||
"net/neoforged/neoforge",
|
||||
format!("neoforge-{maven_version}-installer.jar"),
|
||||
),
|
||||
_ => (
|
||||
FORGE_MAVEN,
|
||||
"net/minecraftforge/forge",
|
||||
format!("forge-{maven_version}-installer.jar"),
|
||||
),
|
||||
};
|
||||
format!("{root}/{path}/{maven_version}/{file}")
|
||||
}
|
||||
|
||||
/// 扫 maven-metadata.xml 里的 `<version>` 值。
|
||||
///
|
||||
/// 故意不引 XML 库:maven 元数据是**极简且稳定**的格式(只有一层 `<version>` 列表),
|
||||
/// 为它拉一个 XML 解析器不划算。解析不出就返回空表,由上层报"没有可用版本"。
|
||||
#[must_use]
|
||||
pub fn parse_metadata_versions(xml: &str) -> Vec<String> {
|
||||
const OPEN: &str = "<version>";
|
||||
const CLOSE: &str = "</version>";
|
||||
let mut out = Vec::new();
|
||||
let mut rest = xml;
|
||||
while let Some(start) = rest.find(OPEN) {
|
||||
let after = &rest[start + OPEN.len()..];
|
||||
let Some(end) = after.find(CLOSE) else {
|
||||
break;
|
||||
};
|
||||
let value = after[..end].trim();
|
||||
if !value.is_empty() {
|
||||
out.push(value.to_string());
|
||||
}
|
||||
rest = &after[end + CLOSE.len()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// NeoForge 版本号 → 原版版本号(`21.1.80` → `1.21.1`,analyse/05 §1.3 的反解规则)。
|
||||
///
|
||||
/// - 主版本 `0` 是快照版(命名不带原版版本,返回 `None`);
|
||||
/// - 主版本 `≥26` 是 MC 新命名方案(`26.1.2.125` → `26.1`);
|
||||
/// - 其余是 `1.<主>.<次>`(`20.4.237` → `1.20.4`)。
|
||||
#[must_use]
|
||||
pub fn neoforge_mc_version(version: &str) -> Option<String> {
|
||||
let core = version.split(['-', '+']).next().unwrap_or(version);
|
||||
let segments: Vec<u64> = core
|
||||
.split('.')
|
||||
.map(str::parse::<u64>)
|
||||
.collect::<Result<_, _>>()
|
||||
.ok()?;
|
||||
let (major, minor) = (*segments.first()?, *segments.get(1)?);
|
||||
match major {
|
||||
0 => None,
|
||||
26.. => Some(format!("{major}.{minor}")),
|
||||
_ => Some(format!("1.{major}.{minor}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// maven 版本是否属于该原版版本。
|
||||
#[must_use]
|
||||
pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool {
|
||||
match kind {
|
||||
LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => {
|
||||
version.starts_with(&format!("{mc_version}-"))
|
||||
}
|
||||
LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version),
|
||||
_ => version.starts_with(&format!("{mc_version}-")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 maven 版本表里挑出属于该原版版本的加载器版本(**升序**,末尾最好)。
|
||||
#[must_use]
|
||||
pub fn select_versions(kind: LoaderKind, mc_version: &str, versions: &[String]) -> Vec<String> {
|
||||
let mut picked: Vec<String> = versions
|
||||
.iter()
|
||||
.filter(|version| matches_mc(kind, mc_version, version))
|
||||
.cloned()
|
||||
.collect();
|
||||
picked.sort_by(|left, right| {
|
||||
numeric_key(left)
|
||||
.cmp(&numeric_key(right))
|
||||
.then_with(|| left.cmp(right))
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。
|
||||
///
|
||||
/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受;
|
||||
/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回。
|
||||
#[must_use]
|
||||
pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
let needs_prefix = match kind {
|
||||
LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC,
|
||||
_ => true,
|
||||
};
|
||||
if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) {
|
||||
format!("{mc_version}-{raw}")
|
||||
} else {
|
||||
raw.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。
|
||||
#[must_use]
|
||||
pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String {
|
||||
match kind {
|
||||
LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(),
|
||||
_ => maven_version
|
||||
.strip_prefix(&format!("{mc_version}-"))
|
||||
.unwrap_or(maven_version)
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本号 → 可比较的数字键(非数字段按 0,保证不 panic)。
|
||||
fn numeric_key(version: &str) -> Vec<u64> {
|
||||
version
|
||||
.split(['-', '.', '+'])
|
||||
.map(|segment| segment.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 加载器元数据的网络层:版本列表 + 官方 profile/installer → [`LoaderRelease`]。
|
||||
//!
|
||||
//! 分工与 M27 的 [`crate::game::install::RemoteSource`] 一样:**只发请求、只解析**,
|
||||
//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在
|
||||
//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。
|
||||
//!
|
||||
//! 两族的取数路径完全不同:
|
||||
//!
|
||||
//! ```text
|
||||
//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON)
|
||||
//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json
|
||||
//! ```
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::fabric;
|
||||
use super::forge::{self, InstallProfile};
|
||||
use super::maven;
|
||||
use super::plan::LoaderRelease;
|
||||
use super::{LoaderError, LoaderKind};
|
||||
use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。
|
||||
const INSTALLER_TAG: &str = "installer";
|
||||
|
||||
/// 远端加载器元数据来源。
|
||||
pub struct LoaderSource<'a> {
|
||||
engine: &'a DownloadEngine,
|
||||
meta_cache: &'a Path,
|
||||
}
|
||||
|
||||
impl<'a> LoaderSource<'a> {
|
||||
/// 绑定下载引擎与元数据缓存目录(`.ULL/cache/meta/`)。
|
||||
#[must_use]
|
||||
pub fn new(engine: &'a DownloadEngine, meta_cache: &'a Path) -> Self {
|
||||
Self { engine, meta_cache }
|
||||
}
|
||||
|
||||
/// 取该原版版本可用的加载器版本列表(**最新在前**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络失败、元数据解析失败、该原版版本没有加载器版本时返回 [`LoaderError`]。
|
||||
pub async fn loader_versions(
|
||||
&self,
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
) -> Result<Vec<String>, LoaderError> {
|
||||
if kind.is_fabric_family() {
|
||||
let url = fabric::loader_list_url(kind, mc_version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
let entries = fabric::parse_loader_list(kind, &body)?;
|
||||
return Ok(entries.into_iter().map(|entry| entry.version).collect());
|
||||
}
|
||||
let url = maven::metadata_url(kind, mc_version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
let all = maven::parse_metadata_versions(&body);
|
||||
// maven 表是升序的,统一成"最新在前"。
|
||||
let mut picked = maven::select_versions(kind, mc_version, &all);
|
||||
picked.reverse();
|
||||
if picked.is_empty() {
|
||||
return Err(LoaderError::not_found(format!(
|
||||
"{} 在 {} 上没有可用版本(maven 表 {} 条,全部与该原版版本不匹配)",
|
||||
kind.label(),
|
||||
mc_version,
|
||||
all.len()
|
||||
)));
|
||||
}
|
||||
Ok(picked)
|
||||
}
|
||||
|
||||
/// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版,
|
||||
/// maven 系取该原版版本下最新的一版)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 网络失败、版本不存在、installer 缺 profile、协议不受支持时返回 [`LoaderError`]。
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
kind: LoaderKind,
|
||||
mc_version: &str,
|
||||
loader_version: Option<&str>,
|
||||
) -> Result<LoaderRelease, LoaderError> {
|
||||
let requested = loader_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if kind.is_fabric_family() {
|
||||
let version = match requested {
|
||||
Some(version) => version.to_string(),
|
||||
None => {
|
||||
let url = fabric::loader_list_url(kind, mc_version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
let entries = fabric::parse_loader_list(kind, &body)?;
|
||||
fabric::pick_default(&entries)
|
||||
.map(|entry| entry.version.clone())
|
||||
.ok_or_else(|| {
|
||||
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
|
||||
})?
|
||||
}
|
||||
};
|
||||
let url = fabric::profile_url(kind, mc_version, &version);
|
||||
let body = self.fetch_text(&url).await?;
|
||||
return fabric::build_release(kind, mc_version, &version, &body);
|
||||
}
|
||||
|
||||
let maven_version = match requested {
|
||||
Some(raw) => maven::normalize_maven_version(kind, mc_version, raw),
|
||||
None => self
|
||||
.loader_versions(kind, mc_version)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label()))
|
||||
})?,
|
||||
};
|
||||
let url = maven::installer_url(kind, mc_version, &maven_version);
|
||||
let path = self.installer_path(kind, &maven_version);
|
||||
let jar = self.fetch_installer(&url, &path).await?;
|
||||
let (profile, embedded) = read_installer(&jar)?;
|
||||
let mut release = forge::build_release(
|
||||
kind,
|
||||
mc_version,
|
||||
&maven_version,
|
||||
&profile,
|
||||
embedded.as_ref(),
|
||||
)?;
|
||||
// M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。
|
||||
release.installer = Some(jar);
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
/// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。
|
||||
fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf {
|
||||
let safe: String = maven_version
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.meta_cache
|
||||
.join(format!("{}-{safe}-{INSTALLER_TAG}.jar", kind.as_str()))
|
||||
}
|
||||
|
||||
/// 取 installer jar:本地缓存里有可读的 zip 就直接用(离线复用),否则下载。
|
||||
async fn fetch_installer(&self, url: &str, path: &Path) -> Result<PathBuf, LoaderError> {
|
||||
if path.is_file() && ZipArchive::new(File::open(path).map_err(io_error(path))?).is_ok() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
let request = DownloadRequest::new(ResourceKind::Library, url, path.to_path_buf());
|
||||
let outcome = self.engine.download(request).await?;
|
||||
Ok(outcome.path)
|
||||
}
|
||||
|
||||
/// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。
|
||||
async fn fetch_text(&self, url: &str) -> Result<String, LoaderError> {
|
||||
let dest = self.meta_cache.join("loader-meta.bin");
|
||||
let bytes = self
|
||||
.engine
|
||||
.download_bytes(DownloadRequest::new(
|
||||
ResourceKind::VersionManifest,
|
||||
url,
|
||||
dest,
|
||||
))
|
||||
.await?;
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|_| LoaderError::metadata(format!("元数据不是 UTF-8:{url}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。
|
||||
///
|
||||
/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`);
|
||||
/// 该条目不存在时返回 `None`,由 [`forge::build_release`] 判定为 unsupported。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 不是合法 zip、缺 `install_profile.json`、profile/版本 JSON 无法解析时返回 metadata 错误。
|
||||
pub fn read_installer(path: &Path) -> Result<(InstallProfile, Option<VersionJson>), LoaderError> {
|
||||
let file = File::open(path).map_err(io_error(path))?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|error| {
|
||||
LoaderError::metadata(format!("{} 不是合法 zip:{error}", path.display()))
|
||||
})?;
|
||||
|
||||
let profile_text = read_entry(&mut archive, "install_profile.json")?.ok_or_else(|| {
|
||||
LoaderError::metadata(format!(
|
||||
"{} 里没有 install_profile.json(不是 Forge/NeoForge installer?)",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let profile: InstallProfile = serde_json::from_str(&profile_text).map_err(|error| {
|
||||
LoaderError::metadata(format!("install_profile.json 解析失败:{error}"))
|
||||
})?;
|
||||
|
||||
let embedded = match profile
|
||||
.version_json_path
|
||||
.as_deref()
|
||||
.map(|entry| entry.trim_start_matches('/'))
|
||||
.filter(|entry| !entry.is_empty())
|
||||
{
|
||||
Some(entry) => match read_entry(&mut archive, entry)? {
|
||||
Some(text) => Some(VersionJson::from_json_str(&text).map_err(|error| {
|
||||
LoaderError::metadata(format!("installer 内 {entry} 解析失败:{error}"))
|
||||
})?),
|
||||
None => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok((profile, embedded))
|
||||
}
|
||||
|
||||
/// 读一个 zip 条目成字符串;条目不存在返回 `Ok(None)`。
|
||||
fn read_entry(archive: &mut ZipArchive<File>, name: &str) -> Result<Option<String>, LoaderError> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut entry = match archive.by_name(name) {
|
||||
Ok(entry) => entry,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(LoaderError::metadata(format!(
|
||||
"读 installer 条目 {name} 失败:{error}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let mut text = String::new();
|
||||
entry
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|error| LoaderError::io(format!("installer 条目 {name} 不是可读文本:{error}")))?;
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
fn io_error(path: &Path) -> impl FnOnce(std::io::Error) -> LoaderError + '_ {
|
||||
move |error| LoaderError::io(format!("{} 读写失败:{error}", path.display()))
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! 加载器安装(Fabric / Quilt / NeoForge / Forge):官方元数据 → loader 版本 JSON → 补库清单。
|
||||
//!
|
||||
//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、
|
||||
//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。
|
||||
//!
|
||||
//! # 与 M27 安装层的关系(不复制粘贴,只叠一层)
|
||||
//!
|
||||
//! 加载器不是"另一个版本",而是**叠在已装原版之上的一个 `inheritsFrom` 层**:
|
||||
//!
|
||||
//! ```text
|
||||
//! versions/1.20.1/1.20.1.json 原版(M27 负责装)
|
||||
//! versions/1.20.1-fabric-0.15.11/…json loader 层:inheritsFrom=1.20.1 + 主类 + loader 库
|
||||
//! ```
|
||||
//!
|
||||
//! 因此本模块**只做两件事**:①把官方元数据翻译成一份 loader 版本 JSON;
|
||||
//! ②把它的库列表交给 [`crate::game::install::libraries`] 展开成下载计划。
|
||||
//! 落盘、跳过判定、实例登记一律复用 M27([`crate::game::install::execute`] / `register`),
|
||||
//! 不另起一套下载/写盘逻辑。
|
||||
//!
|
||||
//! # 三条路线(按"能不能不跑 java 就装出来"分类,而不是按加载器名字)
|
||||
//!
|
||||
//! | 路线 | 加载器 | 做法 |
|
||||
//! |------|--------|------|
|
||||
//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON |
|
||||
//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 |
|
||||
//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** |
|
||||
//!
|
||||
//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看),
|
||||
//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。
|
||||
//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。
|
||||
|
||||
pub mod fabric;
|
||||
pub mod forge;
|
||||
pub mod maven;
|
||||
pub mod meta;
|
||||
pub mod plan;
|
||||
pub mod processors;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use meta::LoaderSource;
|
||||
pub use plan::{LoaderInstallPlan, LoaderPlanInput, LoaderRelease, LoaderSupport};
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
/// 支持的加载器种类。
|
||||
///
|
||||
/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LoaderKind {
|
||||
/// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。
|
||||
Fabric,
|
||||
/// Quilt(`meta.quiltmc.org`,与 Fabric 同构)。
|
||||
Quilt,
|
||||
/// NeoForge(`maven.neoforged.net`,installer 内嵌 install profile)。
|
||||
NeoForge,
|
||||
/// Forge(`maven.minecraftforge.net`,两代 installer 协议)。
|
||||
Forge,
|
||||
}
|
||||
|
||||
impl LoaderKind {
|
||||
/// 解析命令行里的加载器名(大小写不敏感;`neo-forge` 是 Modrinth 的另一种拼法)。
|
||||
#[must_use]
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"fabric" => Some(Self::Fabric),
|
||||
"quilt" => Some(Self::Quilt),
|
||||
"neoforge" | "neo-forge" => Some(Self::NeoForge),
|
||||
"forge" => Some(Self::Forge),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定字符串 id(进版本 id、JSON 输出与日志,**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::Forge => "forge",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读名称。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fabric => "Fabric",
|
||||
Self::Quilt => "Quilt",
|
||||
Self::NeoForge => "NeoForge",
|
||||
Self::Forge => "Forge",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否属于 Fabric 系(纯 meta、profile JSON 直接可用)。
|
||||
#[must_use]
|
||||
pub const fn is_fabric_family(self) -> bool {
|
||||
matches!(self, Self::Fabric | Self::Quilt)
|
||||
}
|
||||
|
||||
/// 全部加载器(CLI 的 `--loader` 枚举提示用)。
|
||||
#[must_use]
|
||||
pub const fn all() -> &'static [Self] {
|
||||
&[Self::Fabric, Self::Quilt, Self::NeoForge, Self::Forge]
|
||||
}
|
||||
|
||||
/// loader 版本 id 的词根:`<mc>-<kind>-<loader>`(`versions/<id>/` 与实例 `versionId` 都用它)。
|
||||
#[must_use]
|
||||
pub fn version_id(self, mc_version: &str, loader_version: &str) -> String {
|
||||
format!("{mc_version}-{}-{loader_version}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LoaderKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.label())
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器安装失败的大类(CLI 据此给稳定错误码 `loader_*`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoaderErrorKind {
|
||||
/// 该加载器/该版本走不通我们的路线(需要跑 installer 或属未支持的老协议)。
|
||||
Unsupported,
|
||||
/// 官方元数据里没有这个加载器版本(或没有匹配该原版版本的版本)。
|
||||
NotFound,
|
||||
/// 元数据解析失败(profile JSON / install profile / maven metadata 坏了)。
|
||||
Metadata,
|
||||
/// 网络侧失败(所有候选源都失败 / 超时)。
|
||||
Network,
|
||||
/// 本地 IO(读 installer 缓存、写盘失败)。
|
||||
Io,
|
||||
}
|
||||
|
||||
impl LoaderErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unsupported => "loader_unsupported",
|
||||
Self::NotFound => "loader_not_found",
|
||||
Self::Metadata => "loader_metadata",
|
||||
Self::Network => "loader_network",
|
||||
Self::Io => "loader_io",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器安装错误:大类 + 人读说明 + 可选提示。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoaderError {
|
||||
kind: LoaderErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
}
|
||||
|
||||
impl LoaderError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: LoaderErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 走不通我们的路线(需要外调 java installer / 老协议)。
|
||||
#[must_use]
|
||||
pub fn unsupported(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::Unsupported, message)
|
||||
}
|
||||
|
||||
/// 元数据里查不到。
|
||||
#[must_use]
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::NotFound, message)
|
||||
}
|
||||
|
||||
/// 元数据坏了/形状不符。
|
||||
#[must_use]
|
||||
pub fn metadata(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::Metadata, message)
|
||||
}
|
||||
|
||||
/// 本地 IO。
|
||||
#[must_use]
|
||||
pub fn io(message: impl Into<String>) -> Self {
|
||||
Self::new(LoaderErrorKind::Io, message)
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> LoaderErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LoaderError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoaderError {}
|
||||
|
||||
impl From<DownloadError> for LoaderError {
|
||||
fn from(error: DownloadError) -> Self {
|
||||
let kind = match error.kind {
|
||||
DownloadErrorKind::Io => LoaderErrorKind::Io,
|
||||
DownloadErrorKind::InvalidRequest => LoaderErrorKind::Metadata,
|
||||
DownloadErrorKind::Integrity
|
||||
| DownloadErrorKind::NotFound
|
||||
| DownloadErrorKind::ServerError
|
||||
| DownloadErrorKind::Timeout
|
||||
| DownloadErrorKind::Network
|
||||
| DownloadErrorKind::RangeUnsupported
|
||||
| DownloadErrorKind::AllSourcesFailed => LoaderErrorKind::Network,
|
||||
};
|
||||
let mut message = error.message.clone();
|
||||
if let Some(source) = &error.source {
|
||||
message.push_str(&format!("(源 {source})"));
|
||||
}
|
||||
Self::new(kind, message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 加载器安装计划:一份 loader 版本 JSON + 需要补下载的库清单。
|
||||
//!
|
||||
//! 计划层与网络层分开:网络在 [`super::meta`],这里只做**纯计算**——把已经解析好的
|
||||
//! loader 版本 JSON 交给 [`crate::game::install::libraries`] 展开成待下载文件,再包成
|
||||
//! M27 的 [`InstallPlan`](落盘/跳过判定/原子写版本 JSON 全部沿用同一套实现)。
|
||||
//!
|
||||
//! 为什么不直接调 [`InstallPlan::build`]:那是"装一个完整原版版本"的入口(客户端 jar +
|
||||
//! 资源索引 + 逐对象),而 loader 层**只有库**——jar 与资源都由 `inheritsFrom` 的原版提供。
|
||||
//! 直接组 [`InstallPlan`] 的字段既省掉一堆"没有 jar"的告警,又不复制任何路径规则。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::forge::InstallProfile;
|
||||
use super::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::libraries;
|
||||
use crate::game::install::{InstallPlan, PlanFile};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// 加载器版本的可用路线(判据是"能不能不跑 java 就装出来",不是加载器名字)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoaderSupport {
|
||||
/// 纯元数据/纯下载路线:不跑任何外部进程。
|
||||
JsonRoute,
|
||||
/// 官方 installer 声明了必须先跑的 processors(拆包/映射),走 M35 执行器外调 java。
|
||||
RequiresInstaller {
|
||||
/// 人读原因(含该版本声明的 processors 数量与作用)。
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl LoaderSupport {
|
||||
/// 稳定字符串 id(JSON 输出用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::JsonRoute => "json_route",
|
||||
Self::RequiresInstaller { .. } => "requires_installer",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可真正安装(两条路线都走到了:processors 走 M35 执行器)。
|
||||
#[must_use]
|
||||
pub const fn is_installable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 是否要外调 java 跑 installer processors。
|
||||
#[must_use]
|
||||
pub const fn requires_installer(&self) -> bool {
|
||||
matches!(self, Self::RequiresInstaller { .. })
|
||||
}
|
||||
|
||||
/// JSON 视图。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
match self {
|
||||
Self::JsonRoute => json!({"mode": self.as_str(), "requires_installer": false}),
|
||||
Self::RequiresInstaller { reason } => {
|
||||
json!({"mode": self.as_str(), "requires_installer": true, "reason": reason})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次解析出来的加载器版本(网络层产物,计划层的输入)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoaderRelease {
|
||||
/// 加载器种类。
|
||||
pub kind: LoaderKind,
|
||||
/// 目标原版版本(`inheritsFrom` 的父版本 id)。
|
||||
pub mc_version: String,
|
||||
/// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80`)。
|
||||
pub loader_version: String,
|
||||
/// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions/<id>/` 的目录名)。
|
||||
pub version_id: String,
|
||||
/// loader 版本 JSON(`inheritsFrom` 指向原版)。
|
||||
pub version_json: VersionJson,
|
||||
/// 可走哪条路线。
|
||||
pub support: LoaderSupport,
|
||||
/// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。
|
||||
pub installer: Option<PathBuf>,
|
||||
/// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。
|
||||
pub install_profile: Option<InstallProfile>,
|
||||
/// 解析期告警(不致命:例如 profile 缺 loader 库时我们补了一条)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl LoaderRelease {
|
||||
/// 是否可真正安装。
|
||||
#[must_use]
|
||||
pub const fn is_installable(&self) -> bool {
|
||||
self.support.is_installable()
|
||||
}
|
||||
|
||||
/// 是否要外调 java 跑 installer processors。
|
||||
#[must_use]
|
||||
pub const fn requires_installer(&self) -> bool {
|
||||
self.support.requires_installer()
|
||||
}
|
||||
|
||||
/// 需要 installer 时的原因(`JsonRoute` 返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn installer_reason(&self) -> Option<&str> {
|
||||
match &self.support {
|
||||
LoaderSupport::JsonRoute => None,
|
||||
LoaderSupport::RequiresInstaller { reason } => Some(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON 视图(`--dry-run` 与诊断共用)。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"kind": self.kind.as_str(),
|
||||
"label": self.kind.label(),
|
||||
"mc_version": self.mc_version,
|
||||
"loader_version": self.loader_version,
|
||||
"version_id": self.version_id,
|
||||
"inherits_from": self.version_json.inherits_from,
|
||||
"jar": self.version_json.jar,
|
||||
"main_class": self.version_json.main_class,
|
||||
"libraries": self.version_json.libraries.as_ref().map_or(0, Vec::len),
|
||||
"support": self.support.to_json(),
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`LoaderInstallPlan::build`] 的输入。
|
||||
pub struct LoaderPlanInput<'a> {
|
||||
/// 游戏目录布局(`versions/` `libraries/` 的路径规则)。
|
||||
pub layout: &'a GameLayout,
|
||||
/// 已解析的加载器版本。
|
||||
pub release: &'a LoaderRelease,
|
||||
/// 规则求值环境(OS / 架构 / 特性开关)。
|
||||
pub environment: &'a Environment,
|
||||
/// 原版的 `versions/<mc>/<mc>.json` 是否已在盘上。
|
||||
pub vanilla_installed: bool,
|
||||
}
|
||||
|
||||
/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。
|
||||
///
|
||||
/// 执行期直接 `execute(&engine, &plan.install, …)`:那份计划里的 `version_json` 就是
|
||||
/// 带 `inheritsFrom` 的 loader 层,`execute` 会在**全部库就绪后**原子写到
|
||||
/// `versions/<loader-id>/<loader-id>.json`(半成品不写,沿 M27 的纪律)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoaderInstallPlan {
|
||||
/// 加载器版本上下文。
|
||||
pub release: LoaderRelease,
|
||||
/// 待执行的下载计划(库 + 版本 JSON 落盘)。
|
||||
pub install: InstallPlan,
|
||||
/// 是否还需要先装原版(原版已装则 `false`,真装时会跳过)。
|
||||
pub vanilla_install_needed: bool,
|
||||
}
|
||||
|
||||
impl LoaderInstallPlan {
|
||||
/// 组计划。
|
||||
///
|
||||
/// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败——
|
||||
/// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。
|
||||
#[must_use]
|
||||
pub fn build(input: LoaderPlanInput<'_>) -> Self {
|
||||
let release = input.release;
|
||||
let (files, library_warnings) = libraries::plan(
|
||||
input.layout,
|
||||
&release.version_id,
|
||||
release
|
||||
.version_json
|
||||
.libraries
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
input.environment,
|
||||
);
|
||||
|
||||
let mut warnings = release.warnings.clone();
|
||||
warnings.extend(library_warnings);
|
||||
if let Some(reason) = release.installer_reason() {
|
||||
warnings.push(format!("需要外调 java 跑 installer processors:{reason}"));
|
||||
}
|
||||
if !input.vanilla_installed {
|
||||
warnings.push(format!(
|
||||
"原版 {} 尚未安装:执行时会先装原版再叠加载器",
|
||||
release.mc_version
|
||||
));
|
||||
}
|
||||
|
||||
let install = InstallPlan {
|
||||
version_id: release.version_id.clone(),
|
||||
// jar 来自原版层(生成的 loader JSON 已把 `jar` 钉成原版 id)。
|
||||
jar_id: release.mc_version.clone(),
|
||||
game_root: input.layout.root().to_path_buf(),
|
||||
version_json_path: input.layout.version_json(&release.version_id),
|
||||
files,
|
||||
version_json: release.version_json.clone(),
|
||||
prefetched: Vec::new(),
|
||||
warnings,
|
||||
};
|
||||
|
||||
Self {
|
||||
release: release.clone(),
|
||||
install,
|
||||
vanilla_install_needed: !input.vanilla_installed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 计划里的待下载文件。
|
||||
#[must_use]
|
||||
pub fn files(&self) -> &[PlanFile] {
|
||||
&self.install.files
|
||||
}
|
||||
|
||||
/// 告警(含"原版未装""本期不能真装")。
|
||||
#[must_use]
|
||||
pub fn warnings(&self) -> &[String] {
|
||||
&self.install.warnings
|
||||
}
|
||||
|
||||
/// 汇总 JSON(`--dry-run` 的主载荷)。
|
||||
#[must_use]
|
||||
pub fn summary_json(&self) -> Value {
|
||||
let mut summary = self.install.summary_json();
|
||||
if let Some(object) = summary.as_object_mut() {
|
||||
object.insert("loader".to_string(), self.release.to_json());
|
||||
object.insert(
|
||||
"vanilla_install_needed".to_string(),
|
||||
json!(self.vanilla_install_needed),
|
||||
);
|
||||
}
|
||||
summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! processor 依赖库的下载:把 [`ProcessorPlan::libraries`] 交给 M27 的下载引擎。
|
||||
//!
|
||||
//! 为什么不直接复用 `game::install::execute`:那个入口在**全量就绪后写版本 JSON**,
|
||||
//! 而 processors 的产物要先于版本清单落盘(版本 JSON 存在 = 这版真装完了)。
|
||||
//! 所以这里只借它的**逐文件跳过判定**(`current_bytes`:摘要 → 大小 → 存在即信)
|
||||
//! 与结果类型,下载本身仍走同一个引擎——镜像、健康度、退避、续传一条不少。
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
use crate::download::DownloadEngine;
|
||||
use crate::game::install::execute::current_bytes;
|
||||
use crate::game::install::{
|
||||
FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink,
|
||||
};
|
||||
|
||||
/// 并发下载 processor 依赖库(`concurrency` 上限,引擎内部另有信号量)。
|
||||
///
|
||||
/// 失败**不**中断:与 M27 一致,逐文件结构化返回,由调用方决定是否继续。
|
||||
pub async fn download_libraries(
|
||||
engine: &DownloadEngine,
|
||||
files: &[PlanFile],
|
||||
concurrency: usize,
|
||||
sink: Option<ProgressSink>,
|
||||
) -> InstallReport {
|
||||
let outcomes: Vec<FileOutcome> = stream::iter(files.iter())
|
||||
.map(|file| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
let outcome = install_one(engine, file).await;
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
InstallReport {
|
||||
files: outcomes,
|
||||
version_json: None,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单文件:先判「已就绪」,否则走引擎下载。
|
||||
async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
|
||||
if let Some(bytes) = current_bytes(file) {
|
||||
return FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Skipped,
|
||||
bytes,
|
||||
source: None,
|
||||
failure: None,
|
||||
};
|
||||
}
|
||||
match engine.download(file.request()).await {
|
||||
Ok(outcome) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Downloaded,
|
||||
bytes: outcome.bytes,
|
||||
source: Some(outcome.source),
|
||||
failure: None,
|
||||
},
|
||||
Err(error) => FileOutcome {
|
||||
role: file.role,
|
||||
name: file.name.clone(),
|
||||
dest: file.dest.clone(),
|
||||
status: FileStatus::Failed,
|
||||
bytes: 0,
|
||||
source: None,
|
||||
failure: Some(FileFailure {
|
||||
kind: error.kind,
|
||||
message: error.message.clone(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! processors 执行器的错误分类(CLI 据此给稳定错误码 `processor_*`)。
|
||||
//!
|
||||
//! 与 [`crate::addon::loader::LoaderError`] 分开是刻意的:加载器**解析**阶段的失败
|
||||
//! (元数据坏了、版本不存在)与**执行**阶段的失败(java 跑挂了、产物没落盘)对用户
|
||||
//! 是完全不同的两件事——前者换版本/换线路,后者多半是 Java 或磁盘问题。
|
||||
//! 失败分流也是 mission 的硬要求:`processor_failed` 必须能单独被脚本识别。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// processor 执行失败的大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProcessorErrorKind {
|
||||
/// 规划期:install profile 形状不对(缺 `jar`、坐标不合法、变量缺定义)。
|
||||
Plan,
|
||||
/// 找不到可用的 Java(M33 的解析链一个候选都没有)。
|
||||
Java,
|
||||
/// processor 自己要用的 jar/依赖不在盘上(下载没覆盖到)。
|
||||
Library,
|
||||
/// 外调 java 失败(非零退出),消息里带脱敏后的 stderr 尾部。
|
||||
Failed,
|
||||
/// processor 跑完了但产物没落盘。
|
||||
Output,
|
||||
/// 本地 IO(解包 installer 条目、建目录、复制 MC jar、起进程失败)。
|
||||
Io,
|
||||
}
|
||||
|
||||
impl ProcessorErrorKind {
|
||||
/// 稳定字符串 id(进 `--json` 的 `error.code`)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Plan => "processor_plan",
|
||||
Self::Java => "processor_java_missing",
|
||||
Self::Library => "processor_library_missing",
|
||||
Self::Failed => "processor_failed",
|
||||
Self::Output => "processor_output_missing",
|
||||
Self::Io => "processor_io",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// processor 执行错误:大类 + 人读说明 + 可选提示 + 可选 stderr 尾部(已脱敏)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessorError {
|
||||
kind: ProcessorErrorKind,
|
||||
message: String,
|
||||
hint: Option<String>,
|
||||
stderr_tail: Option<String>,
|
||||
}
|
||||
|
||||
impl ProcessorError {
|
||||
/// 构造。
|
||||
#[must_use]
|
||||
pub fn new(kind: ProcessorErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
hint: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 规划期失败(install profile 形状/变量问题)。
|
||||
#[must_use]
|
||||
pub fn plan(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Plan, message)
|
||||
}
|
||||
|
||||
/// 找不到可用的 Java。
|
||||
#[must_use]
|
||||
pub fn java_missing(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Java, message)
|
||||
}
|
||||
|
||||
/// processor 依赖不在盘上。
|
||||
#[must_use]
|
||||
pub fn library_missing(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Library, message)
|
||||
}
|
||||
|
||||
/// processor 产物没落盘。
|
||||
#[must_use]
|
||||
pub fn output_missing(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Output, message)
|
||||
}
|
||||
|
||||
/// 本地 IO 失败。
|
||||
#[must_use]
|
||||
pub fn io(message: impl Into<String>) -> Self {
|
||||
Self::new(ProcessorErrorKind::Io, message)
|
||||
}
|
||||
|
||||
/// 外调 java 非零退出:消息 = 说明 + 脱敏后的 stderr 尾部。
|
||||
#[must_use]
|
||||
pub fn failed(label: &str, exit: &str, stderr_tail: &str) -> Self {
|
||||
let mut message = format!("processor {label} 执行失败(退出码 {exit})");
|
||||
if !stderr_tail.trim().is_empty() {
|
||||
message.push_str(";stderr 尾部:\n");
|
||||
message.push_str(stderr_tail.trim_end());
|
||||
}
|
||||
let mut error = Self::new(ProcessorErrorKind::Failed, message);
|
||||
error.stderr_tail = Some(stderr_tail.trim_end().to_string());
|
||||
error
|
||||
}
|
||||
|
||||
/// 补一句提示。
|
||||
#[must_use]
|
||||
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 大类。
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ProcessorErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 人读说明。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 下一步提示。
|
||||
#[must_use]
|
||||
pub fn hint(&self) -> Option<&str> {
|
||||
self.hint.as_deref()
|
||||
}
|
||||
|
||||
/// 脱敏后的 stderr 尾部(只有 `processor_failed` 会有)。
|
||||
#[must_use]
|
||||
pub fn stderr_tail(&self) -> Option<&str> {
|
||||
self.stderr_tail.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProcessorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProcessorError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ProcessorError, ProcessorErrorKind};
|
||||
|
||||
#[test]
|
||||
fn stable_codes_are_distinct_and_prefixed() {
|
||||
let kinds = [
|
||||
ProcessorErrorKind::Plan,
|
||||
ProcessorErrorKind::Java,
|
||||
ProcessorErrorKind::Library,
|
||||
ProcessorErrorKind::Failed,
|
||||
ProcessorErrorKind::Output,
|
||||
ProcessorErrorKind::Io,
|
||||
];
|
||||
for kind in kinds {
|
||||
assert!(kind.as_str().starts_with("processor_"), "{}", kind.as_str());
|
||||
}
|
||||
assert_eq!(ProcessorErrorKind::Failed.as_str(), "processor_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_carries_the_stderr_tail_in_message_and_accessor() {
|
||||
let error = ProcessorError::failed("g:a:1", "1", "boom\nCaused by: x");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Failed);
|
||||
assert!(error.message().contains("退出码 1"), "{}", error.message());
|
||||
assert!(error.message().contains("Caused by: x"));
|
||||
assert_eq!(error.stderr_tail(), Some("boom\nCaused by: x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stderr_keeps_the_message_clean() {
|
||||
let error = ProcessorError::failed("g:a:1", "255", "");
|
||||
assert!(!error.message().contains("stderr 尾部"));
|
||||
assert_eq!(error.stderr_tail(), Some(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! 逐个外调 java 执行 processor:`java -cp <依赖…>:<jar> <主类> <参数…>`。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 processors
|
||||
//! 流水线)与 HMCL `ForgeNewInstallTask.ProcessorTask` 的实现,四条硬规矩:
|
||||
//!
|
||||
//! 1. **串行**:后面的 processor 吃前面的产物(拆包 → 映射 → 打补丁),并行必炸;
|
||||
//! 2. **幂等**:每步先按 `outputs` 的 SHA-1 判定,全部命中就整步跳过(重跑 install 零成本);
|
||||
//! 3. **主类来自 jar 的 MANIFEST**:官方 install profile 不写主类(少数 fork 给 `mainClass`);
|
||||
//! 4. **失败要能定位**:非零退出带脱敏后的 stderr 尾部(路径/用户名不能泄漏到日志外)。
|
||||
//!
|
||||
//! # 关于产物 SHA-1 与 zlib 变体(HMCL 踩过的坑)
|
||||
//!
|
||||
//! processor 动态生成的 jar 是用**本机 JDK 的 zlib** 压缩的。zlib-ng 之类非标准实现会把
|
||||
//! 同样的内容压出不同字节 → SHA-1 与 profile 里的期望值不符。HMCL 的处置是「哈希不符时
|
||||
//! 退化成 ZIP 结构校验」。我们的处置更保守:**存在性强制、摘要不符只告警**,
|
||||
//! 既不误杀真实装,也不假装校验通过。
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::jar::read_main_class;
|
||||
use super::plan::{ProcessorPlan, ProcessorStep};
|
||||
use super::prepare::prepare;
|
||||
use super::text::{host_redactor, non_empty, tail};
|
||||
use crate::download::verify;
|
||||
use crate::download::HashAlgo;
|
||||
use crate::game::java::{self, JavaRequirement, JavaRuntime};
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
/// 一步的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StepStatus {
|
||||
/// 产物已就绪(SHA-1 命中),整步跳过。
|
||||
Skipped,
|
||||
/// 真的起了一次 java。
|
||||
Executed,
|
||||
}
|
||||
|
||||
impl StepStatus {
|
||||
/// 稳定字符串 id(`--json` 用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Skipped => "skipped",
|
||||
Self::Executed => "executed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一步的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StepOutcome {
|
||||
/// 序号(从 1 起)。
|
||||
pub index: usize,
|
||||
/// jar 的 maven 坐标。
|
||||
pub label: String,
|
||||
/// 结局。
|
||||
pub status: StepStatus,
|
||||
/// 产物个数。
|
||||
pub outputs: usize,
|
||||
/// 耗时(毫秒;跳过时为 0)。
|
||||
pub elapsed_ms: u128,
|
||||
/// stdout 尾巴(已脱敏,可能为空)。
|
||||
pub stdout_tail: Option<String>,
|
||||
/// stderr 尾巴(已脱敏,可能为空)。
|
||||
pub stderr_tail: Option<String>,
|
||||
}
|
||||
|
||||
impl StepOutcome {
|
||||
/// JSON 视图。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"index": self.index,
|
||||
"jar": self.label,
|
||||
"status": self.status.as_str(),
|
||||
"outputs": self.outputs,
|
||||
"elapsed_ms": self.elapsed_ms,
|
||||
"stderr_tail": self.stderr_tail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次 processors 执行的汇总。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessorRun {
|
||||
/// 实际用的 Java(含来源与主版本)。
|
||||
pub java: JavaRuntime,
|
||||
/// 逐步骤结果(顺序即执行顺序)。
|
||||
pub steps: Vec<StepOutcome>,
|
||||
/// 不致命的告警(摘要不符、没有 outputs 的步骤等)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProcessorRun {
|
||||
/// 真正跑过的步数。
|
||||
#[must_use]
|
||||
pub fn executed(&self) -> usize {
|
||||
self.steps
|
||||
.iter()
|
||||
.filter(|step| step.status == StepStatus::Executed)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 跳过的步数(幂等命中)。
|
||||
#[must_use]
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.steps
|
||||
.iter()
|
||||
.filter(|step| step.status == StepStatus::Skipped)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// JSON 视图。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"java": {
|
||||
"binary": self.java.binary.display().to_string(),
|
||||
"source": self.java.origin.as_str(),
|
||||
"major": self.java.major,
|
||||
"version": self.java.version,
|
||||
},
|
||||
"executed": self.executed(),
|
||||
"skipped": self.skipped(),
|
||||
"steps": self.steps.iter().map(StepOutcome::to_json).collect::<Vec<_>>(),
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 每步完成的回调(CLI 往 stderr 打实时进度用)。
|
||||
pub type StepSink = Arc<dyn Fn(&StepOutcome) + Send + Sync>;
|
||||
|
||||
/// 按 M33 的解析链挑一个能跑 installer 的 Java:托管 > `JAVA_HOME` > `PATH` > 扫盘。
|
||||
///
|
||||
/// 与启动路径的差异只有一点:**扫盘只在前面都不满足时才做**(别为了一次安装白跑
|
||||
/// N 次 `java -version`),这里复用 [`crate::game::java::pick`] 的同一套优先序。
|
||||
#[must_use]
|
||||
pub fn select_java(managed_root: &Path, requirement: &JavaRequirement) -> Option<JavaRuntime> {
|
||||
let mut candidates = java::discover_managed(managed_root);
|
||||
candidates.extend(java::system_candidates());
|
||||
if let Some(runtime) = java::pick(&candidates, requirement) {
|
||||
if usable(runtime) {
|
||||
return Some(runtime.clone());
|
||||
}
|
||||
}
|
||||
candidates.extend(java::standard_candidates());
|
||||
let deduped = java::discover::dedupe(candidates);
|
||||
java::pick(&deduped, requirement).cloned().filter(usable)
|
||||
}
|
||||
|
||||
/// 候选是否真的能用:探测到主版本,或者那个文件确实在盘上。
|
||||
fn usable(runtime: &JavaRuntime) -> bool {
|
||||
runtime.major.is_some() || runtime.binary.is_file()
|
||||
}
|
||||
|
||||
/// 顺序执行所有 processor。
|
||||
///
|
||||
/// 全部产物已就绪时走快路径:不复制 client jar、不解包 installer、不起任何进程。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 临时目录/解包/复制失败(IO)、依赖缺失、`java -cp` 起不来或非零退出、
|
||||
/// 产物没落盘时返回 [`ProcessorError`]。
|
||||
pub fn run(
|
||||
plan: &ProcessorPlan,
|
||||
runtime: &JavaRuntime,
|
||||
sink: Option<StepSink>,
|
||||
) -> Result<ProcessorRun, ProcessorError> {
|
||||
let redactor = host_redactor();
|
||||
let mut warnings = Vec::new();
|
||||
let mut outcomes = Vec::with_capacity(plan.steps.len());
|
||||
let satisfied: Vec<bool> = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| outputs_satisfied(step, &mut warnings))
|
||||
.collect();
|
||||
if satisfied.iter().any(|done| !done) {
|
||||
prepare(plan)?;
|
||||
}
|
||||
for (step, done) in plan.steps.iter().zip(satisfied) {
|
||||
let outcome = if done {
|
||||
StepOutcome {
|
||||
index: step.index,
|
||||
label: step.label.clone(),
|
||||
status: StepStatus::Skipped,
|
||||
outputs: step.outputs.len(),
|
||||
elapsed_ms: 0,
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
} else {
|
||||
run_step(plan, runtime, step, &redactor, &mut warnings)?
|
||||
};
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
}
|
||||
outcomes.push(outcome);
|
||||
}
|
||||
|
||||
Ok(ProcessorRun {
|
||||
java: runtime.clone(),
|
||||
steps: outcomes,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 起一次 java,跑完校验产物。
|
||||
fn run_step(
|
||||
plan: &ProcessorPlan,
|
||||
runtime: &JavaRuntime,
|
||||
step: &ProcessorStep,
|
||||
redactor: &Redactor,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<StepOutcome, ProcessorError> {
|
||||
ensure_dependencies(step)?;
|
||||
let main_class = match &step.main_class {
|
||||
Some(main_class) => main_class.clone(),
|
||||
None => read_main_class(&step.jar)?,
|
||||
};
|
||||
let classpath = step.classpath_join()?;
|
||||
|
||||
let started = Instant::now();
|
||||
let output = Command::new(&runtime.binary)
|
||||
.arg("-cp")
|
||||
.arg(&classpath)
|
||||
.arg(&main_class)
|
||||
.args(&step.args)
|
||||
.current_dir(&plan.scratch)
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"起 java 失败({}):{error}",
|
||||
runtime.binary.display()
|
||||
))
|
||||
.with_hint(
|
||||
"确认 `ull java list` 里的路径可用,或用 `ull java install <major>` 装一个托管 JDK",
|
||||
)
|
||||
})?;
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
let stdout = redactor
|
||||
.scrub(&String::from_utf8_lossy(&output.stdout))
|
||||
.into_owned();
|
||||
let stderr = redactor
|
||||
.scrub(&String::from_utf8_lossy(&output.stderr))
|
||||
.into_owned();
|
||||
|
||||
if !output.status.success() {
|
||||
let exit = output
|
||||
.status
|
||||
.code()
|
||||
.map_or_else(|| "signal".to_string(), |code| code.to_string());
|
||||
return Err(
|
||||
ProcessorError::failed(&step.label, &exit, &tail(&stderr)).with_hint(
|
||||
"installer 的 processor 是官方产物,失败多半是 Java 版本/磁盘权限/网络(部分步骤要联网取映射)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
verify_outputs(step, warnings)?;
|
||||
Ok(StepOutcome {
|
||||
index: step.index,
|
||||
label: step.label.clone(),
|
||||
status: StepStatus::Executed,
|
||||
outputs: step.outputs.len(),
|
||||
elapsed_ms,
|
||||
stdout_tail: non_empty(tail(&stdout)),
|
||||
stderr_tail: non_empty(tail(&stderr)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 依赖 jar 是否都在盘上(缺了直接报错:起了 java 也只会得到 `NoClassDefFoundError`)。
|
||||
fn ensure_dependencies(step: &ProcessorStep) -> Result<(), ProcessorError> {
|
||||
if !step.jar.is_file() {
|
||||
return Err(ProcessorError::library_missing(format!(
|
||||
"processor {} 的可执行 jar 不在盘上:{}",
|
||||
step.label,
|
||||
step.jar.display()
|
||||
)));
|
||||
}
|
||||
let missing: Vec<String> = step
|
||||
.classpath
|
||||
.iter()
|
||||
.filter(|path| !path.is_file())
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ProcessorError::library_missing(format!(
|
||||
"processor {} 有 {} 个依赖 jar 不在盘上:{}",
|
||||
step.label,
|
||||
missing.len(),
|
||||
missing.join(";")
|
||||
))
|
||||
.with_hint("重跑一次 `ull install` 会补齐依赖(下载走镜像/健康度引擎)"))
|
||||
}
|
||||
|
||||
/// 产物存在性(硬)与摘要(软)校验。
|
||||
fn verify_outputs(step: &ProcessorStep, warnings: &mut Vec<String>) -> Result<(), ProcessorError> {
|
||||
let missing: Vec<String> = step
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|output| !output.path.is_file())
|
||||
.map(|output| output.path.display().to_string())
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(ProcessorError::output_missing(format!(
|
||||
"processor {} 跑完了但产物没落盘:{}",
|
||||
step.label,
|
||||
missing.join(";")
|
||||
))
|
||||
.with_hint("磁盘权限/杀软拦截是常见原因;也可以删掉 versions/ 下这版后重装"));
|
||||
}
|
||||
for output in &step.outputs {
|
||||
let Some(expected) = &output.sha1 else {
|
||||
continue;
|
||||
};
|
||||
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
|
||||
continue;
|
||||
};
|
||||
if !verify::matches(expected, &actual) {
|
||||
warnings.push(format!(
|
||||
"processor {} 的产物摘要与 profile 不符(期望 {expected} 实际 {actual}):非标准 zlib 实现会压出不同字节,按可用处理",
|
||||
step.label
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 产物是否都已就绪(幂等判据:存在 + 摘要命中;profile 没给摘要就只认存在)。
|
||||
fn outputs_satisfied(step: &ProcessorStep, warnings: &mut Vec<String>) -> bool {
|
||||
if step.outputs.is_empty() {
|
||||
// 没有产物声明 → 无法判断是否跑过,只能每次都跑(HMCL 同样处理)。
|
||||
return false;
|
||||
}
|
||||
for output in &step.outputs {
|
||||
if !output.path.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Some(expected) = &output.sha1 else {
|
||||
continue;
|
||||
};
|
||||
let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else {
|
||||
return false;
|
||||
};
|
||||
if !verify::matches(expected, &actual) {
|
||||
warnings.push(format!(
|
||||
"{} 的摘要与 profile 不符,重新执行该 processor",
|
||||
output.path.display()
|
||||
));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 从 processor jar 的 `MANIFEST.MF` 里取 `Main-Class`。
|
||||
//!
|
||||
//! 官方 install profile **不写主类**(`processors[].jar` 只给坐标),主类在 jar 的清单里;
|
||||
//! 少数 fork 会额外给 `mainClass`(那条路走 [`super::plan::ProcessorStep::main_class`],
|
||||
//! 不到这里)。清单按 RFC 822 折行:续行以**单个空格**开头。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::error::ProcessorError;
|
||||
|
||||
/// 读 jar 的 `Main-Class`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// jar 打不开/不是 zip(IO/规划错误)、没有 `META-INF/MANIFEST.MF` 或清单里没有
|
||||
/// `Main-Class`(规划错误)。
|
||||
pub(super) fn read_main_class(jar: &Path) -> Result<String, ProcessorError> {
|
||||
use std::io::Read;
|
||||
|
||||
let file = std::fs::File::open(jar).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"打开 processor jar 失败({}):{error}",
|
||||
jar.display()
|
||||
))
|
||||
})?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
ProcessorError::plan(format!("{} 不是合法 jar:{error}", jar.display()))
|
||||
})?;
|
||||
let mut manifest = String::new();
|
||||
{
|
||||
let mut entry = archive.by_name("META-INF/MANIFEST.MF").map_err(|error| {
|
||||
ProcessorError::plan(format!(
|
||||
"{} 里没有 META-INF/MANIFEST.MF(拿不到主类):{error}",
|
||||
jar.display()
|
||||
))
|
||||
})?;
|
||||
entry.read_to_string(&mut manifest).map_err(|error| {
|
||||
ProcessorError::io(format!("读 {} 的 MANIFEST 失败:{error}", jar.display()))
|
||||
})?;
|
||||
}
|
||||
parse_main_class(&manifest).ok_or_else(|| {
|
||||
ProcessorError::plan(format!(
|
||||
"{} 的 MANIFEST 里没有 Main-Class(install profile 也没给 mainClass)",
|
||||
jar.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 MANIFEST 文本里取 `Main-Class`(续行以单个空格开头)。
|
||||
fn parse_main_class(manifest: &str) -> Option<String> {
|
||||
let mut lines = manifest.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let Some(rest) = line.strip_prefix("Main-Class:") else {
|
||||
continue;
|
||||
};
|
||||
let mut value = rest.trim().to_string();
|
||||
while let Some(next) = lines.peek() {
|
||||
let Some(continuation) = next.strip_prefix(' ') else {
|
||||
break;
|
||||
};
|
||||
value.push_str(continuation.trim());
|
||||
lines.next();
|
||||
}
|
||||
return (!value.is_empty()).then_some(value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_main_class;
|
||||
|
||||
#[test]
|
||||
fn parses_main_class_with_continuation_lines() {
|
||||
let manifest = "Manifest-Version: 1.0\r\nMain-Class: com.example.\r\n Tool\r\n\r\n";
|
||||
assert_eq!(
|
||||
parse_main_class(manifest).as_deref(),
|
||||
Some("com.example.Tool")
|
||||
);
|
||||
assert!(parse_main_class("Manifest-Version: 1.0\n").is_none());
|
||||
assert!(parse_main_class("Main-Class: \n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Installer Processors 执行器(M35):把 install profile 的 `processors[]` 跑起来。
|
||||
//!
|
||||
//! 设计输入:`DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的
|
||||
//! processor 流水线)与 HMCL `ForgeNewInstallTask` / `ProcessorTask` 的实现。
|
||||
//!
|
||||
//! # 覆盖范围
|
||||
//!
|
||||
//! | 版本段 | 有无客户端 processor | 本模块 |
|
||||
//! |--------|----------------------|--------|
|
||||
//! | Forge 1.13 ~ 1.20.x(spec 1) | 有(拆包/映射/打补丁) | ✅ 真跑 |
|
||||
//! | NeoForge 1.20.5+(26.x 只剩 1 个 `PROCESS_MINECRAFT_JAR`) | 有 | ✅ 真跑 |
|
||||
//! | Forge spec 0(1.12.2 重建版)/ Fabric / Quilt | 无 | 不需要(走 M32 的 json 路线) |
|
||||
//!
|
||||
//! # 流水线
|
||||
//!
|
||||
//! ```text
|
||||
//! plan install profile 的 data 表 → 变量表({MINECRAFT_JAR}/{SIDE}/[坐标]/'字面量')
|
||||
//! → 每步 java -cp 参数 + 产物/摘要 + processor 依赖库待下清单
|
||||
//! download 依赖库(走 M27 引擎:镜像/健康度/退避/校验)
|
||||
//! exec 串行外调 java(主类取自 jar 的 MANIFEST)→ 产物存在性(硬)+ 摘要(软)
|
||||
//! ```
|
||||
//!
|
||||
//! # 三条边界
|
||||
//!
|
||||
//! 1. **不写版本 JSON**:那是 M27 `execute` 的事,且必须等 processors 全部成功
|
||||
//! (版本清单存在 = 这一版真的装完了,半成品不许留);
|
||||
//! 2. **不改共享层**:原版 client jar 先复制到临时目录再交给 processor,
|
||||
//! processor 只会改临时副本;
|
||||
//! 3. **不猜**:`data` 表里缺变量、processor 缺 `jar`、产物没落盘,一律明确报错,
|
||||
//! 不拿半截参数去试(那种失败的 stderr 更难查)。
|
||||
//!
|
||||
//! # 幂等
|
||||
//!
|
||||
//! 两级:①每步按 `outputs` 的 SHA-1 命中整步跳过(重跑零成本、不碰文件系统);
|
||||
//! ②调用方以「版本 JSON 已落盘」为粗粒度判据整段跳过。
|
||||
|
||||
pub mod download;
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
pub mod jar;
|
||||
pub mod plan;
|
||||
pub mod prepare;
|
||||
pub mod spec;
|
||||
pub mod text;
|
||||
pub mod vars;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use download::download_libraries;
|
||||
pub use error::{ProcessorError, ProcessorErrorKind};
|
||||
pub use exec::{run, select_java, ProcessorRun, StepOutcome, StepSink, StepStatus};
|
||||
pub use plan::{ProcessorPlan, ProcessorPlanInput, ProcessorStep, StepOutput};
|
||||
pub use spec::{Datum, Processor};
|
||||
pub use text::{host_redactor, tail};
|
||||
pub use vars::{EmbeddedFile, Variables, VariablesInput, MINECRAFT_JAR_NAME};
|
||||
@@ -0,0 +1,366 @@
|
||||
//! processor 执行计划:`data` 表展开 → 每步的 `java -cp …` 命令行 → 依赖库待下载清单。
|
||||
//!
|
||||
//! 纯计算(不下载、不起进程),因此 `--dry-run` 能直接把「将执行的 processor 清单 +
|
||||
//! 所需 Java + 依赖库」打出来。唯一读盘的动作是 [`crate::game::java::requirement_for_version`]
|
||||
//! 读原版版本 JSON 判 Java 要求——那是诊断信息,读不到就按静态表兜底。
|
||||
//!
|
||||
//! # 为什么依赖库与启动 classpath 要分开
|
||||
//!
|
||||
//! install profile 的 `libraries` 是 **processor 依赖**(installertools / jarsplitter /
|
||||
//! srgutils…),它们不进游戏 classpath。M32 只把数量记进告警,M35 起真正下载它们
|
||||
//! (否则 `java -cp` 一排文件都不在盘上),但仍然**绝不**并进 loader 版本 JSON。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::spec::Processor;
|
||||
use super::vars::{absolute, Variables, VariablesInput, MINECRAFT_JAR_NAME};
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::{maven, LoaderKind};
|
||||
use crate::download::ResourceKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::libraries;
|
||||
use crate::game::install::{FileRole, PlanFile};
|
||||
use crate::game::java::{requirement_for_version, JavaRequirement};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::library::MavenCoordinate;
|
||||
|
||||
/// 一个 processor 的某个产物。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StepOutput {
|
||||
/// 产物绝对路径。
|
||||
pub path: PathBuf,
|
||||
/// 期望 SHA-1(profile 没给或给的不是摘要时为 `None`,只能按存在性判定)。
|
||||
pub sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 一个待执行的 processor(已展开参数,不再依赖 install profile)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProcessorStep {
|
||||
/// 序号(从 1 起,顺序即执行顺序)。
|
||||
pub index: usize,
|
||||
/// 人读标识(jar 的 maven 坐标)。
|
||||
pub label: String,
|
||||
/// 可执行 jar 的磁盘路径。
|
||||
pub jar: PathBuf,
|
||||
/// 主类(profile 没写就留 `None`,执行期读 jar 的 MANIFEST)。
|
||||
pub main_class: Option<String>,
|
||||
/// `-cp` 的依赖 jar(不含 `jar` 自己)。
|
||||
pub classpath: Vec<PathBuf>,
|
||||
/// 已展开的参数。
|
||||
pub args: Vec<String>,
|
||||
/// 产物与期望摘要。
|
||||
pub outputs: Vec<StepOutput>,
|
||||
}
|
||||
|
||||
impl ProcessorStep {
|
||||
/// 一步的 `-cp` 全串(依赖在前、可执行 jar 在后,与 HMCL 一致)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 路径里含分隔符时返回 IO 错误(Windows 的 `;` 不可能出现在合法路径里,
|
||||
/// 真出现说明路径是拼错的)。
|
||||
pub fn classpath_join(&self) -> Result<String, ProcessorError> {
|
||||
let paths = self
|
||||
.classpath
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.jar))
|
||||
.map(|path| path.as_path());
|
||||
std::env::join_paths(paths)
|
||||
.map(|joined| joined.to_string_lossy().into_owned())
|
||||
.map_err(|error| ProcessorError::io(format!("classpath 拼不起来:{error}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ProcessorPlan::build`] 的输入。
|
||||
pub struct ProcessorPlanInput<'a> {
|
||||
/// 加载器种类(只影响补库时的 maven 基址兜底)。
|
||||
pub kind: LoaderKind,
|
||||
/// 目标原版版本。
|
||||
pub mc_version: &'a str,
|
||||
/// loader 版本 id(依赖库 `hint=local` 的落点)。
|
||||
pub version_id: &'a str,
|
||||
/// installer 里读出来的 install profile。
|
||||
pub profile: &'a InstallProfile,
|
||||
/// 游戏目录布局。
|
||||
pub layout: &'a GameLayout,
|
||||
/// installer jar 的磁盘路径。
|
||||
pub installer: &'a Path,
|
||||
/// 临时目录(解包/`MINECRAFT_JAR` 副本都落这里)。
|
||||
pub scratch: &'a Path,
|
||||
/// 规则求值环境。
|
||||
pub environment: &'a Environment,
|
||||
}
|
||||
|
||||
/// processor 执行计划。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessorPlan {
|
||||
/// loader 版本 id。
|
||||
pub version_id: String,
|
||||
/// installer jar 路径。
|
||||
pub installer: PathBuf,
|
||||
/// 临时目录。
|
||||
pub scratch: PathBuf,
|
||||
/// 原版 client jar(复制源)。
|
||||
pub minecraft_jar: PathBuf,
|
||||
/// 交给 processor 的 client jar 副本(`{MINECRAFT_JAR}`)。
|
||||
pub minecraft_jar_copy: PathBuf,
|
||||
/// 展开后的变量表。
|
||||
pub variables: Variables,
|
||||
/// 按顺序待执行的 processor。
|
||||
pub steps: Vec<ProcessorStep>,
|
||||
/// processor 依赖库(走 M27 引擎下载)。
|
||||
pub libraries: Vec<PlanFile>,
|
||||
/// 跑 installer 需要的 Java 要求。
|
||||
pub java_requirement: JavaRequirement,
|
||||
/// 规划期告警(不致命)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProcessorPlan {
|
||||
/// 组计划。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `data` 表变量缺定义、构件坐标不合法、processor 缺 `jar` 字段时返回规划错误。
|
||||
pub fn build(input: ProcessorPlanInput<'_>) -> Result<Self, ProcessorError> {
|
||||
let scratch = absolute(input.scratch);
|
||||
let variables = Variables::build(VariablesInput {
|
||||
data: &input.profile.data,
|
||||
layout: input.layout,
|
||||
mc_version: input.mc_version,
|
||||
installer: input.installer,
|
||||
scratch: &scratch,
|
||||
})?;
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let mut steps = Vec::new();
|
||||
let mut needed: Vec<(String, PathBuf)> = Vec::new();
|
||||
for processor in input
|
||||
.profile
|
||||
.processors
|
||||
.iter()
|
||||
.filter(|processor| processor.applies_to_client())
|
||||
{
|
||||
let coordinate = processor.jar_coordinate().ok_or_else(|| {
|
||||
ProcessorError::plan("install profile 里有 processor 缺 `jar` 字段")
|
||||
})?;
|
||||
let jar = library_path(input.layout, coordinate)?;
|
||||
needed.push((coordinate.to_string(), jar.clone()));
|
||||
let mut classpath = Vec::with_capacity(processor.classpath.len());
|
||||
for entry in &processor.classpath {
|
||||
let path = library_path(input.layout, entry)?;
|
||||
needed.push((entry.trim().to_string(), path.clone()));
|
||||
classpath.push(path);
|
||||
}
|
||||
let mut args = Vec::with_capacity(processor.args.len());
|
||||
for arg in &processor.args {
|
||||
args.push(variables.expand(arg)?);
|
||||
}
|
||||
let outputs = step_outputs(&variables, processor, coordinate, &scratch, &mut warnings)?;
|
||||
steps.push(ProcessorStep {
|
||||
index: steps.len() + 1,
|
||||
label: coordinate.to_string(),
|
||||
jar,
|
||||
main_class: processor.main_class().map(str::to_string),
|
||||
classpath,
|
||||
args,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
|
||||
let (mut libraries, library_warnings) = libraries::plan(
|
||||
input.layout,
|
||||
input.version_id,
|
||||
&input.profile.libraries,
|
||||
input.environment,
|
||||
);
|
||||
warnings.extend(library_warnings);
|
||||
let base = library_base(input.kind, input.profile);
|
||||
for (coordinate, dest) in &needed {
|
||||
if libraries.iter().any(|file| &file.dest == dest) {
|
||||
continue;
|
||||
}
|
||||
warnings.push(format!(
|
||||
"processor 依赖 {coordinate} 不在 install profile 的 libraries 节里,按 maven 基址补一条"
|
||||
));
|
||||
let Ok(parsed) = MavenCoordinate::parse(coordinate) else {
|
||||
continue;
|
||||
};
|
||||
libraries.push(PlanFile {
|
||||
role: FileRole::Library,
|
||||
name: coordinate.clone(),
|
||||
url: join_url(&base, &parsed.relative_path()),
|
||||
sha1: None,
|
||||
size: None,
|
||||
dest: dest.clone(),
|
||||
resource: ResourceKind::Library,
|
||||
});
|
||||
}
|
||||
// NeoForge 的 install profile 会把同一条依赖**声明多次**(实测 21.1.80:
|
||||
// cli-utils / srgutils / jopt-simple / gson 各 2~3 次)。不下重:同一落点被下两次
|
||||
// 会在同一个 `.part` 中间文件上互相抢(真机现场:原子落盘 os error 2)。
|
||||
let (libraries, duplicated) = dedupe_by_dest(libraries);
|
||||
if duplicated > 0 {
|
||||
warnings.push(format!(
|
||||
"install profile 重复声明了 {duplicated} 个 processor 依赖,已按落点去重"
|
||||
));
|
||||
}
|
||||
|
||||
let java_requirement = requirement_for_version(input.layout, input.mc_version);
|
||||
let minecraft_jar_copy = variables
|
||||
.get("MINECRAFT_JAR")
|
||||
.map_or_else(|| scratch.join(MINECRAFT_JAR_NAME), PathBuf::from);
|
||||
Ok(Self {
|
||||
version_id: input.version_id.to_string(),
|
||||
installer: absolute(input.installer),
|
||||
scratch,
|
||||
minecraft_jar: input.layout.version_jar(input.mc_version),
|
||||
minecraft_jar_copy,
|
||||
variables,
|
||||
steps,
|
||||
libraries,
|
||||
java_requirement,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 是否一个 processor 都没有。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.steps.is_empty()
|
||||
}
|
||||
|
||||
/// 汇总行数(CLI 人读输出用)。
|
||||
#[must_use]
|
||||
pub fn step_count(&self) -> usize {
|
||||
self.steps.len()
|
||||
}
|
||||
|
||||
/// `--dry-run` / 报告里的 processors 载荷。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"count": self.steps.len(),
|
||||
"installer": self.installer.display().to_string(),
|
||||
"scratch": self.scratch.display().to_string(),
|
||||
"minecraft_jar": self.minecraft_jar.display().to_string(),
|
||||
"libraries": self.libraries.len(),
|
||||
"library_files": self.libraries.iter().map(|file| json!({
|
||||
"name": file.name,
|
||||
"url": file.url,
|
||||
"dest": file.dest.display().to_string(),
|
||||
"sha1": file.sha1,
|
||||
"size": file.size,
|
||||
})).collect::<Vec<_>>(),
|
||||
"variables": self.variables.len(),
|
||||
"embedded_files": self.variables.embedded().len(),
|
||||
"java_requirement": {
|
||||
"minimum": self.java_requirement.minimum,
|
||||
"maximum": self.java_requirement.maximum,
|
||||
"describe": self.java_requirement.describe(),
|
||||
"source": self.java_requirement.source.as_str(),
|
||||
},
|
||||
"steps": self.steps.iter().map(step_json).collect::<Vec<_>>(),
|
||||
"warnings": self.warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 processor 的 JSON 视图。
|
||||
fn step_json(step: &ProcessorStep) -> Value {
|
||||
json!({
|
||||
"index": step.index,
|
||||
"jar": step.label,
|
||||
"jar_path": step.jar.display().to_string(),
|
||||
"main_class": step.main_class,
|
||||
"classpath": step.classpath.len(),
|
||||
"args": step.args,
|
||||
"outputs": step.outputs.iter().map(|output| json!({
|
||||
"path": output.path.display().to_string(),
|
||||
"sha1": output.sha1,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 展开一步的 `outputs` 表(路径 + 期望摘要)。
|
||||
fn step_outputs(
|
||||
variables: &Variables,
|
||||
processor: &Processor,
|
||||
coordinate: &str,
|
||||
scratch: &Path,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<Vec<StepOutput>, ProcessorError> {
|
||||
let mut outputs = Vec::with_capacity(processor.outputs.len());
|
||||
for (raw_path, raw_sha1) in &processor.outputs {
|
||||
let expanded = variables.expand(raw_path)?;
|
||||
let path = PathBuf::from(&expanded);
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
scratch.join(path)
|
||||
};
|
||||
let expanded_sha1 = variables.expand(raw_sha1).unwrap_or_default();
|
||||
let sha1 = is_sha1(&expanded_sha1).then(|| expanded_sha1.to_ascii_lowercase());
|
||||
if sha1.is_none() {
|
||||
warnings.push(format!(
|
||||
"processor {coordinate} 的输出 {} 没给 SHA-1,只能按存在性判定",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
outputs.push(StepOutput { path, sha1 });
|
||||
}
|
||||
Ok(outputs)
|
||||
}
|
||||
|
||||
/// 40 位十六进制(SHA-1 的样子)。
|
||||
fn is_sha1(value: &str) -> bool {
|
||||
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// 坐标 → `libraries/` 下的路径。
|
||||
fn library_path(layout: &GameLayout, raw: &str) -> Result<PathBuf, ProcessorError> {
|
||||
let coordinate = MavenCoordinate::parse(raw.trim()).map_err(|error| {
|
||||
ProcessorError::plan(format!("processor 依赖坐标不合法:`{raw}`({error})"))
|
||||
})?;
|
||||
Ok(layout.libraries_dir().join(coordinate.relative_path()))
|
||||
}
|
||||
|
||||
/// 补库时用的 maven 基址:install profile 自己声明的 `url` 优先,否则按加载器兜底。
|
||||
fn library_base(kind: LoaderKind, profile: &InstallProfile) -> String {
|
||||
profile
|
||||
.libraries
|
||||
.iter()
|
||||
.filter_map(|library| library.url.as_deref())
|
||||
.map(str::trim)
|
||||
.find(|url| !url.is_empty())
|
||||
.map_or_else(|| maven::base(kind).to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// `基址 + 相对路径`(保证中间恰好一个 `/`)。
|
||||
fn join_url(base: &str, relative: &Path) -> String {
|
||||
let path = relative.to_string_lossy().replace('\\', "/");
|
||||
if base.ends_with('/') {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 按落点去重(保留首次出现的条目),返回(去重后的清单,丢掉几条)。
|
||||
fn dedupe_by_dest(files: Vec<PlanFile>) -> (Vec<PlanFile>, usize) {
|
||||
let total = files.len();
|
||||
let mut seen: Vec<PathBuf> = Vec::with_capacity(total);
|
||||
let mut kept: Vec<PlanFile> = Vec::with_capacity(total);
|
||||
for file in files {
|
||||
if seen.contains(&file.dest) {
|
||||
continue;
|
||||
}
|
||||
seen.push(file.dest.clone());
|
||||
kept.push(file);
|
||||
}
|
||||
let dropped = total - kept.len();
|
||||
(kept, dropped)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 临时目录准备:建目录、解包 installer 内的文件、复制原版 client jar。
|
||||
//!
|
||||
//! 三条都是「起 java 之前的脏活」,失败一律归 `processor_io`/`processor_plan`
|
||||
//! (区别于 processor 自己跑挂的 `processor_failed`)。
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::plan::ProcessorPlan;
|
||||
|
||||
/// 单条目的**预分配**上限(64 MiB):installer jar 下载后只校验「是合法 zip」,
|
||||
/// 没有校验和(`meta.rs` 的 `fetch_installer`),因此 zip header 里声明的解压尺寸
|
||||
/// 是不可信输入——损坏或镜像投毒的 installer 可以声明天文数字,让一次小文件解包直接
|
||||
/// 申请多 GB 内存。`Vec` 仍按实际读入增长,合法条目行为不变。
|
||||
const PREALLOC_MAX: u64 = 64 << 20;
|
||||
|
||||
/// 单条目的**实际字节**上限(256 MiB):预分配封顶挡不住「声明小、解压极大」的
|
||||
/// zip bomb,所以读入过程本身也要有硬顶。超过就明确报错(不静默截断)。
|
||||
const MAX_EMBEDDED_BYTES: u64 = 256 << 20;
|
||||
|
||||
/// 执行前的准备:建临时目录、解包 installer 内的文件、复制原版 client jar。
|
||||
pub(super) fn prepare(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
|
||||
std::fs::create_dir_all(&plan.scratch).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"建临时目录失败({}):{error}",
|
||||
plan.scratch.display()
|
||||
))
|
||||
})?;
|
||||
extract_embedded(plan)?;
|
||||
copy_minecraft_jar(plan)
|
||||
}
|
||||
|
||||
/// 把 install profile 声明要解包的文件从 installer zip 里抽到临时目录。
|
||||
fn extract_embedded(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
|
||||
let embedded = plan.variables.embedded();
|
||||
if embedded.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
use std::io::Read;
|
||||
|
||||
let installer = &plan.installer;
|
||||
let file = std::fs::File::open(installer).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"打开 installer 失败({}):{error}",
|
||||
installer.display()
|
||||
))
|
||||
})?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
|
||||
ProcessorError::plan(format!("{} 不是合法 zip:{error}", installer.display()))
|
||||
})?;
|
||||
for item in embedded {
|
||||
let mut entry = archive.by_name(&item.entry).map_err(|error| {
|
||||
ProcessorError::plan(format!(
|
||||
"installer 里没有 {}(data 表声明要解包):{error}",
|
||||
item.entry
|
||||
))
|
||||
})?;
|
||||
let mut bytes = Vec::with_capacity(prealloc_hint(entry.size()));
|
||||
let read = (&mut entry)
|
||||
.take(MAX_EMBEDDED_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| {
|
||||
ProcessorError::io(format!("读 installer 条目 {} 失败:{error}", item.entry))
|
||||
})?;
|
||||
if read as u64 > MAX_EMBEDDED_BYTES {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"installer 条目 {} 解压后超过 {} MiB,拒绝继续(installer 可能损坏或被投毒)",
|
||||
item.entry,
|
||||
MAX_EMBEDDED_BYTES >> 20
|
||||
)));
|
||||
}
|
||||
crate::platform::atomic::write_atomic(&item.dest, &bytes).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"写临时文件失败({}):{error}",
|
||||
item.dest.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 预分配提示:**不信任** header 声明的尺寸,封顶 [`PREALLOC_MAX`]。
|
||||
fn prealloc_hint(declared: u64) -> usize {
|
||||
declared.min(PREALLOC_MAX) as usize
|
||||
}
|
||||
|
||||
/// 复制原版 client jar 到临时目录(processor 会改写输入,不能让它碰共享层)。
|
||||
fn copy_minecraft_jar(plan: &ProcessorPlan) -> Result<(), ProcessorError> {
|
||||
let source = &plan.minecraft_jar;
|
||||
let target = &plan.minecraft_jar_copy;
|
||||
let fresh = match (std::fs::metadata(source), std::fs::metadata(target)) {
|
||||
(Ok(left), Ok(right)) => left.len() != right.len(),
|
||||
(Ok(_), Err(_)) => true,
|
||||
(Err(error), _) => {
|
||||
return Err(ProcessorError::io(format!(
|
||||
"原版 client jar 不在盘上({}):{error}",
|
||||
source.display()
|
||||
))
|
||||
.with_hint("先装原版:`ull install <version>`"))
|
||||
}
|
||||
};
|
||||
if !fresh {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| {
|
||||
ProcessorError::io(format!("建目录失败({}):{error}", parent.display()))
|
||||
})?;
|
||||
}
|
||||
std::fs::copy(source, target).map_err(|error| {
|
||||
ProcessorError::io(format!(
|
||||
"复制 {} → {} 失败:{error}",
|
||||
source.display(),
|
||||
target.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::{prealloc_hint, prepare, PREALLOC_MAX};
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::processors::{ProcessorPlan, ProcessorPlanInput};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
/// 一步 processor + 一个要从 installer 里解包的 data 值。
|
||||
const PROFILE: &str = r#"{
|
||||
"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"jar": "net.minecraftforge:binarypatcher:1.1.1", "args": ["--apply", "{BINPATCH}"]}],
|
||||
"libraries": [],
|
||||
"data": {"BINPATCH": {"client": "/data/client.lzma"}}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn preallocation_is_capped_so_a_forged_header_cannot_oom() {
|
||||
// installer jar 没有校验和:header 可以声称 16 EiB 的解压尺寸。
|
||||
assert_eq!(prealloc_hint(u64::MAX), PREALLOC_MAX as usize);
|
||||
assert_eq!(prealloc_hint(1 << 40), PREALLOC_MAX as usize);
|
||||
// 合法条目照旧按实际尺寸预分配(封顶不影响常见大小)。
|
||||
assert_eq!(prealloc_hint(0), 0);
|
||||
assert_eq!(prealloc_hint(4096), 4096);
|
||||
assert_eq!(prealloc_hint(PREALLOC_MAX), PREALLOC_MAX as usize);
|
||||
assert_eq!(prealloc_hint(PREALLOC_MAX - 1), PREALLOC_MAX as usize - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_extracts_the_declared_entry_and_copies_the_client_jar() {
|
||||
let root = super::super::tests::temp_root("prepare-extract");
|
||||
let layout = GameLayout::new(&root);
|
||||
let profile: InstallProfile = serde_json::from_str(PROFILE).expect("夹具应可解析");
|
||||
let installer = root.join("cache/installer.jar");
|
||||
write_zip(&installer, "data/client.lzma", b"patch-bytes");
|
||||
let vanilla = root.join("versions/1.20.1/1.20.1.jar");
|
||||
std::fs::create_dir_all(vanilla.parent().expect("有父目录")).expect("建目录");
|
||||
std::fs::write(&vanilla, b"client-jar").expect("写原版 jar");
|
||||
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: &profile,
|
||||
layout: &layout,
|
||||
installer: &installer,
|
||||
scratch: &root.join("scratch"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来");
|
||||
assert_eq!(plan.variables.embedded().len(), 1);
|
||||
|
||||
prepare(&plan).expect("准备应成功");
|
||||
let dest = &plan.variables.embedded()[0].dest;
|
||||
assert_eq!(std::fs::read(dest).expect("读解包产物"), b"patch-bytes");
|
||||
// processor 拿到的是副本,共享层的原版 jar 内容不受影响。
|
||||
assert_eq!(
|
||||
std::fs::read(&plan.minecraft_jar_copy).expect("读 client jar 副本"),
|
||||
b"client-jar"
|
||||
);
|
||||
assert_ne!(plan.minecraft_jar_copy, plan.minecraft_jar);
|
||||
// 幂等:重跑按大小判定,不重复复制、不报错。
|
||||
prepare(&plan).expect("重跑应成功");
|
||||
}
|
||||
|
||||
/// 写一个只含一条 Stored 条目的 zip(installer 夹具)。
|
||||
fn write_zip(path: &Path, entry: &str, bytes: &[u8]) {
|
||||
use std::io::Write;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建 zip");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
writer.start_file(entry, options).expect("写条目");
|
||||
writer.write_all(bytes).expect("写内容");
|
||||
writer.finish().expect("收尾 zip");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! install profile 的 `processors[]` 与 `data` 表:**纯解析,不动作**。
|
||||
//!
|
||||
//! 字段含义照 Forge installer spec(与 HMCL `ForgeNewInstallProfile.Processor/Datum` 一致):
|
||||
//!
|
||||
//! ```json
|
||||
//! "processors": [{
|
||||
//! "sides": ["client"], // 缺省 = 两侧都跑
|
||||
//! "jar": "net.minecraftforge:installertools:1.3.0", // 可执行 jar(主类在 MANIFEST 里)
|
||||
//! "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], // 依赖 jar(jar 自己不在里面)
|
||||
//! "args": ["--task", "MCP_DATA", "--output", "{MAPPINGS}"],
|
||||
//! "outputs": {"{MAPPINGS}": "ab12…(期望 SHA-1)"}
|
||||
//! }],
|
||||
//! "data": {"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "…"}}
|
||||
//! ```
|
||||
//!
|
||||
//! 三条容易看错的点:
|
||||
//!
|
||||
//! 1. `data` 的每条是**按侧**的(`client` / `server`),client 侧只取 `.client`;
|
||||
//! 2. 官方只写 `jar`,**不写主类**——主类在 jar 的 `META-INF/MANIFEST.MF`(`Main-Class`);
|
||||
//! 少数 fork 会额外给 `mainClass`,所以这里留了口子;
|
||||
//! 3. `classpath` **不含** `jar` 自己(Forge 的约定),拼 `-cp` 时要补上。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// install profile 里的一个 processor。
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct Processor {
|
||||
/// 生效侧(`client` / `server`;空表示两侧都要)。
|
||||
#[serde(default)]
|
||||
pub sides: Vec<String>,
|
||||
/// 可执行 jar 的 maven 坐标。
|
||||
#[serde(default)]
|
||||
pub jar: Option<String>,
|
||||
/// 主类覆盖(官方 profile 不写,留给少数 fork)。
|
||||
#[serde(default, rename = "mainClass")]
|
||||
pub main_class: Option<String>,
|
||||
/// 依赖 jar 的 maven 坐标(**不含** `jar` 自己)。
|
||||
#[serde(default)]
|
||||
pub classpath: Vec<String>,
|
||||
/// 命令行参数(可含 `{VAR}` / `[坐标]` / `'字面量'` 三种占位)。
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// 产物表:路径占位 → 期望 SHA-1(既有产物可用于幂等跳过)。
|
||||
#[serde(default)]
|
||||
pub outputs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Processor {
|
||||
/// 是否在 client 侧生效。
|
||||
#[must_use]
|
||||
pub fn applies_to_client(&self) -> bool {
|
||||
self.sides.is_empty() || self.sides.iter().any(|side| side == "client")
|
||||
}
|
||||
|
||||
/// `jar` 坐标(去掉首尾空白;缺失或空串返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn jar_coordinate(&self) -> Option<&str> {
|
||||
self.jar
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// 主类覆盖(缺失或空串返回 `None`)。
|
||||
#[must_use]
|
||||
pub fn main_class(&self) -> Option<&str> {
|
||||
self.main_class
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// `data` 表的一条:按侧给值(client 侧只用 `.client`)。
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct Datum {
|
||||
/// client 侧的值(`[坐标]` / `'字面量'` / installer 内的文件路径)。
|
||||
#[serde(default)]
|
||||
pub client: Option<String>,
|
||||
/// server 侧的值(本期不跑 server 安装,只解析不动作)。
|
||||
#[serde(default)]
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
impl Datum {
|
||||
/// client 侧的值(空串视为没有)。
|
||||
#[must_use]
|
||||
pub fn client_value(&self) -> Option<&str> {
|
||||
self.client
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Processor;
|
||||
|
||||
#[test]
|
||||
fn sides_empty_means_both_and_client_subset_is_recognised() {
|
||||
let both: Processor = serde_json::from_str(r#"{"jar": "g:a:1"}"#).expect("应可解析");
|
||||
assert!(both.applies_to_client());
|
||||
let server: Processor =
|
||||
serde_json::from_str(r#"{"sides": ["server"], "jar": "g:a:1"}"#).expect("应可解析");
|
||||
assert!(!server.applies_to_client());
|
||||
let client: Processor =
|
||||
serde_json::from_str(r#"{"sides": ["client"], "jar": "g:a:1"}"#).expect("应可解析");
|
||||
assert!(client.applies_to_client());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_jar_and_main_class_are_treated_as_absent() {
|
||||
let processor: Processor =
|
||||
serde_json::from_str(r#"{"jar": " ", "mainClass": ""}"#).expect("应可解析");
|
||||
assert!(processor.jar_coordinate().is_none());
|
||||
assert!(processor.main_class().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 执行器:幂等跳过(不碰文件系统)、依赖缺失分流、起不来 java 的分类、脱敏器。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::processors::{host_redactor, run};
|
||||
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::java::{JavaOrigin, JavaRuntime};
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
use super::temp_root;
|
||||
|
||||
/// 一个最小 profile:一步 processor,产物是临时目录里的 `built.jar`(只按存在性判定)。
|
||||
const MINIMAL_PROFILE: &str = r#"{
|
||||
"spec": 1,
|
||||
"minecraft": "1.20.1",
|
||||
"json": "/version.json",
|
||||
"processors": [{
|
||||
"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
|
||||
"args": ["--task", "SPLIT"],
|
||||
"outputs": {"{OUT}": "'not-a-sha1'"}
|
||||
}],
|
||||
"libraries": [],
|
||||
"data": {"OUT": {"client": "'built.jar'"}}
|
||||
}"#;
|
||||
|
||||
fn minimal_profile() -> InstallProfile {
|
||||
serde_json::from_str(MINIMAL_PROFILE).expect("夹具应可解析")
|
||||
}
|
||||
|
||||
/// 组装计划(依赖库不落盘:执行器不负责下载,缺失由用例自己摆)。
|
||||
fn plan(root: &Path) -> ProcessorPlan {
|
||||
let profile = minimal_profile();
|
||||
let layout = GameLayout::new(root);
|
||||
ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: &profile,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("scratch"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来")
|
||||
}
|
||||
|
||||
/// 一个「必然起不来」的 java:这些用例只验证分类与顺序,不验证 java 行为。
|
||||
fn missing_java() -> JavaRuntime {
|
||||
JavaRuntime::unknown(
|
||||
PathBuf::from("/nonexistent/ull-test-java"),
|
||||
JavaOrigin::Path,
|
||||
)
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
std::fs::write(path, b"stub").expect("写夹具文件");
|
||||
}
|
||||
|
||||
/// 写一个最小 jar(可选带 `Main-Class`),给"主类从 MANIFEST 读"的用例用。
|
||||
fn write_jar(path: &Path, main_class: Option<&str>) {
|
||||
use std::io::Write;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建 jar");
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
if let Some(main_class) = main_class {
|
||||
writer
|
||||
.start_file("META-INF/MANIFEST.MF", options)
|
||||
.expect("写 manifest 条目");
|
||||
write!(
|
||||
writer,
|
||||
"Manifest-Version: 1.0\r\nMain-Class: {main_class}\r\n\r\n"
|
||||
)
|
||||
.expect("写 manifest");
|
||||
}
|
||||
writer.finish().expect("收尾 jar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_outputs_skip_the_whole_step_without_touching_the_disk() {
|
||||
let root = temp_root("exec-idempotent");
|
||||
let plan = plan(&root);
|
||||
// 产物已就绪:原版 client jar 压根不存在也无所谓——幂等快路径不该去复制它。
|
||||
touch(&plan.steps[0].outputs[0].path);
|
||||
|
||||
let run = run(&plan, &missing_java(), None).expect("产物齐备时不该跑任何进程");
|
||||
assert_eq!(run.executed(), 0);
|
||||
assert_eq!(run.skipped(), 1);
|
||||
assert_eq!(run.steps[0].status.as_str(), "skipped");
|
||||
assert_eq!(run.to_json()["steps"][0]["status"], "skipped");
|
||||
// prepare 不该跑:原版 jar 压根不存在、client jar 副本没被复制。
|
||||
assert!(!plan.minecraft_jar_copy.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_minecraft_jar_is_reported_before_any_java_runs() {
|
||||
let root = temp_root("exec-no-vanilla");
|
||||
let plan = plan(&root);
|
||||
let error = run(&plan, &missing_java(), None).expect_err("缺原版 jar 必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Io);
|
||||
assert!(
|
||||
error.message().contains("原版 client jar"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_processor_jar_is_a_library_error_naming_the_file() {
|
||||
let root = temp_root("exec-no-lib");
|
||||
let plan = plan(&root);
|
||||
touch(&plan.minecraft_jar);
|
||||
|
||||
let error = run(&plan, &missing_java(), None).expect_err("缺依赖必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Library);
|
||||
assert!(
|
||||
error.message().contains("installertools-1.3.0.jar"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_java_that_cannot_start_is_an_io_error_with_a_hint() {
|
||||
let root = temp_root("exec-no-java");
|
||||
let plan = plan(&root);
|
||||
touch(&plan.minecraft_jar);
|
||||
// 可执行 jar 得是真的 zip + MANIFEST:主类只从那儿来(profile 没写 mainClass)。
|
||||
write_jar(
|
||||
&plan.steps[0].jar,
|
||||
Some("net.minecraftforge.installertools.Main"),
|
||||
);
|
||||
touch(&plan.steps[0].classpath[0]);
|
||||
|
||||
let error = run(&plan, &missing_java(), None).expect_err("java 起不来必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Io);
|
||||
assert!(
|
||||
error.message().contains("起 java 失败"),
|
||||
"{}",
|
||||
error.message()
|
||||
);
|
||||
assert!(error.hint().is_some());
|
||||
// 走到这一步说明临时目录、MC jar 副本都准备好了(顺序:prepare → 起进程)。
|
||||
assert!(plan.minecraft_jar_copy.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jar_without_a_main_class_is_a_plan_error() {
|
||||
let root = temp_root("exec-no-main");
|
||||
let plan = plan(&root);
|
||||
touch(&plan.minecraft_jar);
|
||||
write_jar(&plan.steps[0].jar, None);
|
||||
touch(&plan.steps[0].classpath[0]);
|
||||
|
||||
let error = run(&plan, &missing_java(), None).expect_err("拿不到主类必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
|
||||
assert!(error.message().contains("主类"), "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_redactor_masks_the_user_home_directory() {
|
||||
let redactor = host_redactor();
|
||||
let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
|
||||
let Some(home) = home else {
|
||||
return; // 极简环境(无 HOME/USERPROFILE)时跳过
|
||||
};
|
||||
let text = format!("{}/game/libraries/x.jar", home.to_string_lossy());
|
||||
let scrubbed = redactor.scrub(&text);
|
||||
assert!(scrubbed.contains("<home>"), "{scrubbed}");
|
||||
assert!(
|
||||
!scrubbed.contains(&home.to_string_lossy().to_string()),
|
||||
"{scrubbed}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `addon::loader::processors` 的单元测试:占位替换 / 计划形状 / 执行与失败分流 / 幂等跳过。
|
||||
//!
|
||||
//! 刻意**全部离线**:installer 用夹具 JSON 描述,依赖库的"在不在盘上"由测试自己摆,
|
||||
//! 真正外调 java 的用例只走「起不来」这一条确定性分支(假 java 路径不存在),
|
||||
//! 真机执行由验收(Forge 1.20.1 / NeoForge 21.1.80)覆盖。
|
||||
|
||||
mod exec;
|
||||
mod plan;
|
||||
mod vars;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
|
||||
/// 实测 Forge 1.20.1-47.2.0 的形状(裁剪到 3 个 processor:2 个 client 侧 + 1 个仅 server)。
|
||||
pub(crate) const FORGE_PROFILE: &str = r#"{
|
||||
"spec": 1,
|
||||
"profile": "forge",
|
||||
"version": "1.20.1-forge-47.2.0",
|
||||
"minecraft": "1.20.1",
|
||||
"json": "/version.json",
|
||||
"processors": [
|
||||
{
|
||||
"sides": ["client"],
|
||||
"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
|
||||
"args": ["--task", "MCP_DATA", "--input", "{MAPPINGS}", "--output", "{MC_SLIM}", "--side", "{SIDE}"],
|
||||
"outputs": {"{MC_SLIM}": "aa11bb22cc33dd44ee55ff660011223344556677"}
|
||||
},
|
||||
{
|
||||
"sides": ["server"],
|
||||
"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": [],
|
||||
"args": ["--task", "SERVER"],
|
||||
"outputs": {}
|
||||
},
|
||||
{
|
||||
"jar": "net.minecraftforge:jarsplitter:1.1.4",
|
||||
"classpath": ["net.minecraftforge:installertools:1.3.0"],
|
||||
"args": ["--input", "{MINECRAFT_JAR}", "--output", "{MC_EXTRA}", "--singleThreaded"],
|
||||
"outputs": {"{MC_EXTRA}": "'not-a-sha1'"}
|
||||
}
|
||||
],
|
||||
"libraries": [
|
||||
{"name": "net.minecraftforge:installertools:1.3.0", "url": "https://maven.minecraftforge.net/"},
|
||||
{"name": "net.minecraftforge:jarsplitter:1.1.4", "url": "https://maven.minecraftforge.net/"}
|
||||
],
|
||||
"data": {
|
||||
"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]", "server": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]"},
|
||||
"MC_SLIM": {"client": "[net.minecraft:client:1.20.1-20230612.114412:slim]", "server": "[net.minecraft:server:1.20.1-20230612.114412:slim]"},
|
||||
"MC_EXTRA": {"client": "[net.minecraft:client:1.20.1-20230612.114412:extra]", "server": "[net.minecraft:server:1.20.1-20230612.114412:extra]"},
|
||||
"BINPATCH": {"client": "/data/client.lzma", "server": "/data/server.lzma"},
|
||||
"SERVER_ONLY": {"server": "'server'"}
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// 夹具 profile。
|
||||
pub(crate) fn profile() -> InstallProfile {
|
||||
serde_json::from_str(FORGE_PROFILE).expect("夹具 install profile 应可解析")
|
||||
}
|
||||
|
||||
/// 一次性临时目录(用例结束不清理,落在系统 temp 下)。
|
||||
pub(crate) fn temp_root(tag: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"ull-processors-{}-{tag}-{unique}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 执行计划:client 侧过滤、参数展开、产物/摘要、依赖库补齐、Java 要求。
|
||||
|
||||
use crate::addon::loader::forge::InstallProfile;
|
||||
use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
use super::{profile, temp_root};
|
||||
|
||||
fn plan_for(root: &std::path::Path, profile: &InstallProfile) -> ProcessorPlan {
|
||||
let layout = GameLayout::new(root);
|
||||
ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile,
|
||||
layout: &layout,
|
||||
installer: &root.join("cache/forge-installer.jar"),
|
||||
scratch: &root.join("cache/processors"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_client_side_processors_become_steps_in_declared_order() {
|
||||
let root = temp_root("plan-order");
|
||||
let plan = plan_for(&root, &profile());
|
||||
|
||||
assert_eq!(plan.step_count(), 2, "server-only 的 processor 必须被过滤");
|
||||
assert_eq!(plan.steps[0].index, 1);
|
||||
assert_eq!(
|
||||
plan.steps[0].label,
|
||||
"net.minecraftforge:installertools:1.3.0"
|
||||
);
|
||||
assert_eq!(plan.steps[1].index, 2);
|
||||
assert_eq!(plan.steps[1].label, "net.minecraftforge:jarsplitter:1.1.4");
|
||||
assert_eq!(plan.steps[1].classpath.len(), 1);
|
||||
assert!(!plan.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_are_expanded_with_paths_and_the_client_side() {
|
||||
let root = temp_root("plan-args");
|
||||
let plan = plan_for(&root, &profile());
|
||||
let args = &plan.steps[0].args;
|
||||
|
||||
assert_eq!(args[0], "--task");
|
||||
assert_eq!(args[1], "MCP_DATA");
|
||||
assert!(
|
||||
args[3].ends_with("mcp_config-1.20.1-20230612.114412.zip"),
|
||||
"输入构件应展开成 libraries 路径:{}",
|
||||
args[3]
|
||||
);
|
||||
assert!(
|
||||
args[5].ends_with("client-1.20.1-20230612.114412-slim.jar"),
|
||||
"产物路径应按 data 表展开:{}",
|
||||
args[5]
|
||||
);
|
||||
assert_eq!(args[7], "client");
|
||||
// 第二步用的是临时目录里的 client jar 副本,不是共享层的原版 jar。
|
||||
assert_eq!(
|
||||
plan.steps[1].args[1],
|
||||
plan.minecraft_jar_copy.to_string_lossy()
|
||||
);
|
||||
assert!(plan
|
||||
.minecraft_jar_copy
|
||||
.to_string_lossy()
|
||||
.ends_with("minecraft.jar"));
|
||||
assert!(!plan.minecraft_jar_copy.starts_with(root.join("versions")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_carry_paths_and_only_real_sha1s() {
|
||||
let root = temp_root("plan-outputs");
|
||||
let plan = plan_for(&root, &profile());
|
||||
|
||||
let first = &plan.steps[0].outputs;
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(
|
||||
first[0].sha1.as_deref(),
|
||||
Some("aa11bb22cc33dd44ee55ff660011223344556677")
|
||||
);
|
||||
assert!(first[0].path.to_string_lossy().ends_with("-slim.jar"));
|
||||
// 非 SHA-1 的 values 只算"存在性校验",并留一条告警。
|
||||
assert!(plan.steps[1].outputs[0].sha1.is_none());
|
||||
assert!(
|
||||
plan.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("没给 SHA-1")),
|
||||
"{:?}",
|
||||
plan.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_dependencies_are_planned_once_even_when_libraries_omit_them() {
|
||||
let root = temp_root("plan-libs");
|
||||
let lean = serde_json::from_str(
|
||||
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"jar": "net.minecraftforge:installertools:1.3.0",
|
||||
"classpath": ["net.minecraftforge:jarsplitter:1.1.4"],
|
||||
"args": [], "outputs": {}}],
|
||||
"libraries": [],
|
||||
"data": {}}"#,
|
||||
)
|
||||
.expect("夹具应可解析");
|
||||
let layout = GameLayout::new(&root);
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::NeoForge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-neoforge-21.1.80",
|
||||
profile: &lean,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("tmp"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来");
|
||||
|
||||
assert_eq!(plan.libraries.len(), 2, "jar 与 classpath 各补一条");
|
||||
for file in &plan.libraries {
|
||||
assert!(
|
||||
file.url.contains("neoforged") || file.url.contains("minecraftforge"),
|
||||
"补库应落在加载器的 maven 上:{}",
|
||||
file.url
|
||||
);
|
||||
assert!(file.dest.starts_with(root.join("libraries")));
|
||||
}
|
||||
assert!(plan
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("不在 install profile 的 libraries 节里")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_requirement_follows_the_target_minecraft_version() {
|
||||
let root = temp_root("plan-java");
|
||||
let plan = plan_for(&root, &profile());
|
||||
assert_eq!(plan.java_requirement.minimum, 17);
|
||||
assert_eq!(plan.java_requirement.maximum, None);
|
||||
assert_eq!(plan.java_requirement.describe(), "Java 17+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_view_lists_every_step_for_dry_run() {
|
||||
let root = temp_root("plan-json");
|
||||
let plan = plan_for(&root, &profile());
|
||||
let json = plan.to_json();
|
||||
|
||||
assert_eq!(json["count"], 2);
|
||||
assert_eq!(json["java_requirement"]["describe"], "Java 17+");
|
||||
assert_eq!(
|
||||
json["steps"][0]["jar"],
|
||||
"net.minecraftforge:installertools:1.3.0"
|
||||
);
|
||||
assert_eq!(json["steps"][0]["classpath"], 1);
|
||||
assert_eq!(
|
||||
json["steps"][1]["outputs"][0]["sha1"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_library_declarations_are_deduped_by_destination() {
|
||||
// NeoForge 21.1.80 实测:install profile 把 cli-utils / srgutils 等声明了 2~3 次。
|
||||
// 同一落点下两次会在同一个 `.part` 中间文件上互相抢(真机 os error 2),必须去重。
|
||||
let root = temp_root("plan-dedup");
|
||||
let duplicated: InstallProfile = serde_json::from_str(
|
||||
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"jar": "net.neoforged.installertools:cli-utils:2.1.2@jar"}],
|
||||
"libraries": [
|
||||
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
|
||||
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"},
|
||||
{"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}
|
||||
],
|
||||
"data": {}}"#,
|
||||
)
|
||||
.expect("夹具应可解析");
|
||||
let layout = GameLayout::new(&root);
|
||||
let plan = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::NeoForge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-neoforge-21.1.80",
|
||||
profile: &duplicated,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("tmp"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("计划应能组出来");
|
||||
|
||||
assert_eq!(plan.libraries.len(), 1, "同一落点只应留一条");
|
||||
assert!(
|
||||
plan.warnings.iter().any(|warning| warning.contains("去重")),
|
||||
"{:?}",
|
||||
plan.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_without_jar_is_a_plan_error() {
|
||||
let root = temp_root("plan-nojar");
|
||||
let broken: InstallProfile = serde_json::from_str(
|
||||
r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json",
|
||||
"processors": [{"args": ["--task", "X"]}], "libraries": [], "data": {}}"#,
|
||||
)
|
||||
.expect("夹具应可解析");
|
||||
let layout = GameLayout::new(&root);
|
||||
let error = ProcessorPlan::build(ProcessorPlanInput {
|
||||
kind: LoaderKind::Forge,
|
||||
mc_version: "1.20.1",
|
||||
version_id: "1.20.1-forge-47.2.0",
|
||||
profile: &broken,
|
||||
layout: &layout,
|
||||
installer: &root.join("installer.jar"),
|
||||
scratch: &root.join("tmp"),
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect_err("缺 jar 必须报错");
|
||||
assert_eq!(error.kind(), ProcessorErrorKind::Plan);
|
||||
assert!(error.message().contains("jar"), "{}", error.message());
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! 变量表:三种字面量、标准变量、token 替换的边界。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::addon::loader::processors::spec::Datum;
|
||||
use crate::addon::loader::processors::vars::{Variables, VariablesInput};
|
||||
use crate::addon::loader::processors::ProcessorErrorKind;
|
||||
use crate::game::layout::GameLayout;
|
||||
|
||||
use super::{profile, temp_root};
|
||||
|
||||
fn data(entries: &[(&str, &str)]) -> BTreeMap<String, Datum> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
(*key).to_string(),
|
||||
Datum {
|
||||
client: Some((*value).to_string()),
|
||||
server: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build<'a>(
|
||||
layout: &'a GameLayout,
|
||||
data: &'a BTreeMap<String, Datum>,
|
||||
installer: &'a Path,
|
||||
scratch: &'a Path,
|
||||
) -> Variables {
|
||||
Variables::build(VariablesInput {
|
||||
data,
|
||||
layout,
|
||||
mc_version: "1.20.1",
|
||||
installer,
|
||||
scratch,
|
||||
})
|
||||
.expect("变量表应能组装")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_literals_resolve_into_the_libraries_directory() {
|
||||
let root = temp_root("vars-artifact");
|
||||
let layout = GameLayout::new(&root);
|
||||
let installer = root.join("installer.jar");
|
||||
let scratch = root.join("tmp");
|
||||
let table = data(&[("MAPPINGS", "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")]);
|
||||
let variables = build(&layout, &table, &installer, &scratch);
|
||||
|
||||
let value = variables.get("MAPPINGS").expect("应有 MAPPINGS");
|
||||
assert!(
|
||||
Path::new(value).ends_with("de/oceanlabs/mcp/mcp_config/1.20.1/mcp_config-1.20.1.zip"),
|
||||
"{value}"
|
||||
);
|
||||
assert!(Path::new(value).is_absolute(), "{value}");
|
||||
assert!(
|
||||
Path::new(value).starts_with(layout.libraries_dir()),
|
||||
"{value}"
|
||||
);
|
||||
|
||||
// 官方 profile 也把坐标直接写在 `args` 里(`--input [de.oceanlabs.mcp:…@zip]`),
|
||||
// 整段是坐标时同样要展开成路径。
|
||||
let arg = variables
|
||||
.expand("[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")
|
||||
.expect("整段坐标应能展开");
|
||||
assert_eq!(arg, value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_literals_lose_the_quotes() {
|
||||
let root = temp_root("vars-literal");
|
||||
let layout = GameLayout::new(&root);
|
||||
let table = data(&[("SIDE_HINT", "'client'")]);
|
||||
let variables = build(&layout, &table, &root.join("i.jar"), &root.join("tmp"));
|
||||
assert_eq!(variables.get("SIDE_HINT"), Some("client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_paths_are_extracted_from_the_installer_into_scratch() {
|
||||
let root = temp_root("vars-embedded");
|
||||
let layout = GameLayout::new(&root);
|
||||
let installer = root.join("installer.jar");
|
||||
let scratch = root.join("tmp");
|
||||
let table = data(&[("BINPATCH", "/data/client.lzma")]);
|
||||
let variables = build(&layout, &table, &installer, &scratch);
|
||||
|
||||
let value = variables.get("BINPATCH").expect("应有 BINPATCH");
|
||||
assert!(value.ends_with("data-client.lzma"), "{value}");
|
||||
assert!(
|
||||
value.starts_with(&scratch.to_string_lossy().to_string()),
|
||||
"{value}"
|
||||
);
|
||||
assert_eq!(variables.embedded().len(), 1);
|
||||
assert_eq!(variables.embedded()[0].entry, "data/client.lzma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_only_entries_are_ignored_for_client_installs() {
|
||||
let root = temp_root("vars-server");
|
||||
let layout = GameLayout::new(&root);
|
||||
let variables = build(
|
||||
&layout,
|
||||
&profile().data,
|
||||
&root.join("i.jar"),
|
||||
&root.join("tmp"),
|
||||
);
|
||||
assert!(variables.get("SERVER_ONLY").is_none());
|
||||
assert!(variables.get("MAPPINGS").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_variables_are_absolute_and_side_is_client() {
|
||||
let root = temp_root("vars-standard");
|
||||
let layout = GameLayout::new(&root);
|
||||
let installer = root.join("installer.jar");
|
||||
let scratch = root.join("tmp");
|
||||
let variables = build(&layout, &BTreeMap::new(), &installer, &scratch);
|
||||
|
||||
assert_eq!(variables.get("SIDE"), Some("client"));
|
||||
assert_eq!(variables.get("MINECRAFT_VERSION"), Some("1.20.1"));
|
||||
for key in ["MINECRAFT_JAR", "ROOT", "INSTALLER", "LIBRARY_DIR"] {
|
||||
let value = variables.get(key).unwrap_or_else(|| panic!("缺 {key}"));
|
||||
assert!(
|
||||
Path::new(value).is_absolute(),
|
||||
"{key} 不是绝对路径:{value}"
|
||||
);
|
||||
}
|
||||
assert!(variables
|
||||
.get("MINECRAFT_JAR")
|
||||
.unwrap_or_default()
|
||||
.ends_with("minecraft.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_replacement_handles_quotes_and_escapes() {
|
||||
let root = temp_root("vars-expand");
|
||||
let layout = GameLayout::new(&root);
|
||||
let variables = build(
|
||||
&layout,
|
||||
&BTreeMap::new(),
|
||||
&root.join("i.jar"),
|
||||
&root.join("tmp"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
variables.expand("--side '{SIDE}'").expect("应能展开"),
|
||||
"--side {SIDE}"
|
||||
);
|
||||
assert_eq!(variables.expand("a\\{b").expect("应能展开"), "a{b");
|
||||
assert_eq!(
|
||||
variables.expand("--side {SIDE}").expect("应能展开"),
|
||||
"--side client"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_unclosed_placeholders_are_plan_errors() {
|
||||
let root = temp_root("vars-error");
|
||||
let layout = GameLayout::new(&root);
|
||||
let variables = build(
|
||||
&layout,
|
||||
&BTreeMap::new(),
|
||||
&root.join("i.jar"),
|
||||
&root.join("tmp"),
|
||||
);
|
||||
|
||||
let missing = variables
|
||||
.expand("--output {NOPE}")
|
||||
.expect_err("缺变量必须报错");
|
||||
assert_eq!(missing.kind(), ProcessorErrorKind::Plan);
|
||||
assert!(missing.message().contains("NOPE"), "{}", missing.message());
|
||||
|
||||
let unclosed = variables.expand("{SIDE").expect_err("未闭合必须报错");
|
||||
assert_eq!(unclosed.kind(), ProcessorErrorKind::Plan);
|
||||
|
||||
let bad_escape = variables.expand("abc\\").expect_err("悬空转义必须报错");
|
||||
assert_eq!(bad_escape.kind(), ProcessorErrorKind::Plan);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! 进程输出的尾部截取与脱敏。
|
||||
//!
|
||||
//! processor 的 stdout/stderr 是外部程序(而且部分步骤要联网)写的,内容不可控:
|
||||
//! 可能带绝对路径、用户名、甚至一大段堆栈。进错误消息/`--json` 前必须做两件事:
|
||||
//! **只留尾巴**(够定位、不灌满日志)与**抹掉主目录**(`C:\Users\<名>` → `<home>`)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
/// 尾巴保留的行数(够看异常栈头)。
|
||||
const TAIL_LINES: usize = 12;
|
||||
/// 尾巴保留的字符上限。
|
||||
const TAIL_CHARS: usize = 1200;
|
||||
|
||||
/// 宿主脱敏器:把用户主目录换成 `<home>`。
|
||||
///
|
||||
/// 复用 [`crate::launch::redact::Redactor`](与 M15/M31 落地的同一套规则:token、
|
||||
/// `<home>`、长不透明串、带点 token/JWT)。注册 `USERPROFILE`(Windows)/`HOME`(macOS、Linux)
|
||||
/// 两个来源,因为 MSYS/CI 环境下两者可能只有一个在场。
|
||||
#[must_use]
|
||||
pub fn host_redactor() -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
redactor = redactor.with_home(PathBuf::from(value));
|
||||
}
|
||||
}
|
||||
redactor
|
||||
}
|
||||
|
||||
/// 取文本尾部(末 [`TAIL_LINES`] 行,再截到 [`TAIL_CHARS`] 字符)。
|
||||
#[must_use]
|
||||
pub fn tail(text: &str) -> String {
|
||||
let trimmed = text.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let lines: Vec<&str> = trimmed.lines().collect();
|
||||
let start = lines.len().saturating_sub(TAIL_LINES);
|
||||
let mut joined = lines[start..].join("\n");
|
||||
let length = joined.chars().count();
|
||||
if length > TAIL_CHARS {
|
||||
joined = joined.chars().skip(length - TAIL_CHARS).collect();
|
||||
joined.insert(0, '…');
|
||||
}
|
||||
joined
|
||||
}
|
||||
|
||||
/// 空串 → `None`(JSON 里不吐空字段)。
|
||||
pub(super) fn non_empty(value: String) -> Option<String> {
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tail;
|
||||
|
||||
#[test]
|
||||
fn tail_keeps_the_last_lines_and_truncates_long_ones() {
|
||||
let text: String = (1..=40).map(|index| format!("line {index}\n")).collect();
|
||||
let tailed = tail(&text);
|
||||
assert!(tailed.starts_with("line 29"), "{tailed}");
|
||||
assert!(tailed.ends_with("line 40"));
|
||||
let long = "x".repeat(5000);
|
||||
let tailed = tail(&long);
|
||||
assert!(tailed.starts_with('…'));
|
||||
assert_eq!(tailed.chars().count(), 1201);
|
||||
assert_eq!(tail(" \n"), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! 占位替换:install profile 的 `data` 表 + 标准变量 → 一张 `{键 → 值}` 表。
|
||||
//!
|
||||
//! 这是 Forge processor 协议里最容易出错的一层,三种字面量语义(照 installer spec,
|
||||
//! 与 HMCL `ForgeNewInstallTask.parseLiteral` 同构):
|
||||
//!
|
||||
//! | 写法 | 含义 | 例 |
|
||||
//! |------|------|----|
|
||||
//! | `[group:artifact:version(:classifier)(@ext)]` | maven 构件 → `libraries/` 下的绝对路径 | `[de.oceanlabs.mcp:mcp_config:1.20.1@zip]` |
|
||||
//! | `'literal'` | 去掉引号的常量 | `'client'` |
|
||||
//! | 其它 | **installer 里的文件路径** → 解包到临时目录后的绝对路径 | `/data/client.lzma` |
|
||||
//!
|
||||
//! 展开(`{KEY}`)的规则与 HMCL 的 `replaceTokens` 一致:`\` 转义下一个字符、
|
||||
//! `'…'` 原样输出、`{KEY}` 查表替换(**查不到就报错**,不静默留空——留空会让
|
||||
//! processor 拿着半截参数跑出更难查的错)。
|
||||
//!
|
||||
//! # 为什么 `MINECRAFT_JAR` 指向临时目录的副本
|
||||
//!
|
||||
//! HMCL 在起 processor 前把原版 client jar **复制到临时目录**再用,注释写得很直白:
|
||||
//! `External processors must not receive the shared cache path`。processor 会改写输入的
|
||||
//! jar(拆包/打补丁),直接把 `versions/<mc>/<mc>.jar` 递过去等于让外挂程序改共享层。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::ProcessorError;
|
||||
use super::spec::Datum;
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::library::MavenCoordinate;
|
||||
|
||||
/// 临时目录里那份原版 client jar 的文件名。
|
||||
pub const MINECRAFT_JAR_NAME: &str = "minecraft.jar";
|
||||
|
||||
/// 一个需要从 installer zip 里解出来的文件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EmbeddedFile {
|
||||
/// installer 内的条目路径(`data/client.lzma`)。
|
||||
pub entry: String,
|
||||
/// 解包落点(临时目录下的扁平文件名)。
|
||||
pub dest: PathBuf,
|
||||
}
|
||||
|
||||
/// 变量表的构造输入。
|
||||
pub struct VariablesInput<'a> {
|
||||
/// install profile 的 `data` 表。
|
||||
pub data: &'a BTreeMap<String, Datum>,
|
||||
/// 游戏目录布局(`libraries/` 与 `versions/` 的路径规则)。
|
||||
pub layout: &'a GameLayout,
|
||||
/// 目标原版版本(`net.minecraft:client:<ver>` 这类坐标回落用)。
|
||||
pub mc_version: &'a str,
|
||||
/// installer jar 的磁盘路径(`{INSTALLER}`,也是解包来源)。
|
||||
pub installer: &'a Path,
|
||||
/// 临时目录(`{MINECRAFT_JAR}` 与解包文件都落这里)。
|
||||
pub scratch: &'a Path,
|
||||
}
|
||||
|
||||
/// 展开后的变量表。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Variables {
|
||||
values: BTreeMap<String, String>,
|
||||
embedded: Vec<EmbeddedFile>,
|
||||
/// 参数里 `[坐标]` 的解析上下文(官方 profile 会在 `args` 里直接写坐标,不只写在 `data` 里)。
|
||||
artifacts: Option<ArtifactRoots>,
|
||||
}
|
||||
|
||||
/// `[坐标]` 的解析上下文。
|
||||
#[derive(Debug, Clone)]
|
||||
struct ArtifactRoots {
|
||||
libraries: PathBuf,
|
||||
vanilla_jar: PathBuf,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
/// 组装变量表:先展开 `data` 表,再注入标准变量。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 构件坐标不合法、`data` 表里的值是空路径、变量引用缺定义时返回
|
||||
/// [`ProcessorErrorKind::Plan`](super::error::ProcessorErrorKind::Plan) 错误。
|
||||
pub fn build(input: VariablesInput<'_>) -> Result<Self, ProcessorError> {
|
||||
let mut variables = Self {
|
||||
artifacts: Some(ArtifactRoots {
|
||||
libraries: input.layout.libraries_dir(),
|
||||
vanilla_jar: input.layout.version_jar(input.mc_version),
|
||||
}),
|
||||
..Self::default()
|
||||
};
|
||||
for (key, datum) in input.data {
|
||||
let Some(raw) = datum.client_value() else {
|
||||
continue;
|
||||
};
|
||||
let value = variables.resolve_literal(raw, &input)?;
|
||||
variables.values.insert(key.clone(), value);
|
||||
}
|
||||
variables.inject_standard(&input);
|
||||
Ok(variables)
|
||||
}
|
||||
|
||||
/// 标准变量(installer spec 没有写死,但各家启动器都注入同一套)。
|
||||
fn inject_standard(&mut self, input: &VariablesInput<'_>) {
|
||||
let scratch = absolute(input.scratch);
|
||||
let standard = [
|
||||
("SIDE", "client".to_string()),
|
||||
("MINECRAFT_VERSION", input.mc_version.to_string()),
|
||||
(
|
||||
"MINECRAFT_JAR",
|
||||
absolute(&scratch.join(MINECRAFT_JAR_NAME))
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
),
|
||||
(
|
||||
"ROOT",
|
||||
absolute(input.layout.root()).to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"INSTALLER",
|
||||
absolute(input.installer).to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"LIBRARY_DIR",
|
||||
absolute(&input.layout.libraries_dir())
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
),
|
||||
];
|
||||
for (key, value) in standard {
|
||||
self.values.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一个 `data` 表的值展开成实际字符串。
|
||||
fn resolve_literal(
|
||||
&mut self,
|
||||
raw: &str,
|
||||
input: &VariablesInput<'_>,
|
||||
) -> Result<String, ProcessorError> {
|
||||
let trimmed = raw.trim();
|
||||
if let Some(inner) = trim_brackets(trimmed) {
|
||||
return self.resolve_artifact(inner);
|
||||
}
|
||||
if let Some(inner) = trimmed
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
{
|
||||
return Ok(inner.to_string());
|
||||
}
|
||||
if trimmed.starts_with('{') {
|
||||
// data 表自己引用别的键:官方 profile 不这么写,但展开一次成本为零。
|
||||
return self.expand(trimmed);
|
||||
}
|
||||
let entry = trimmed.trim_start_matches('/').to_string();
|
||||
if entry.is_empty() {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"install profile 的 data 表里有空路径:`{raw}`"
|
||||
)));
|
||||
}
|
||||
// 其余一律当 installer 内的文件:解到临时目录(执行期才真正解包)。
|
||||
let dest = absolute(&absolute(input.scratch).join(embedded_name(&entry)));
|
||||
self.embedded.push(EmbeddedFile {
|
||||
entry,
|
||||
dest: dest.clone(),
|
||||
});
|
||||
Ok(dest.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// `[坐标]` → `libraries/` 下的绝对路径。
|
||||
fn resolve_artifact(&self, descriptor: &str) -> Result<String, ProcessorError> {
|
||||
let Some(roots) = &self.artifacts else {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"构件坐标 `{descriptor}` 需要游戏目录上下文才能解析"
|
||||
)));
|
||||
};
|
||||
artifact_path(&roots.libraries, &roots.vanilla_jar, descriptor)
|
||||
}
|
||||
|
||||
/// 展开一段含占位的文本(`{KEY}` / `'字面量'` / `[坐标]` / `\` 转义)。
|
||||
///
|
||||
/// `[坐标]` 只在**整段就是坐标**时生效(与 HMCL `parseLiteral` 一致):
|
||||
/// official profile 的 `args` 里常直接写 `--input [de.oceanlabs.mcp:…@zip]`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `{KEY}` 在表里查不到、`\` 结尾无字符可转义、`{`/`'` 未闭合、坐标不合法时返回规划错误。
|
||||
pub fn expand(&self, pattern: &str) -> Result<String, ProcessorError> {
|
||||
let trimmed = pattern.trim();
|
||||
if let Some(inner) = trim_brackets(trimmed) {
|
||||
return self.resolve_artifact(inner);
|
||||
}
|
||||
let mut out = String::with_capacity(pattern.len());
|
||||
let mut chars = pattern.chars();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\\' => match chars.next() {
|
||||
Some(next) => out.push(next),
|
||||
None => {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
|
||||
)))
|
||||
}
|
||||
},
|
||||
'{' => {
|
||||
let key = collect_until(&mut chars, '}', pattern)?;
|
||||
let value = self.values.get(&key).ok_or_else(|| {
|
||||
ProcessorError::plan(format!(
|
||||
"占位 {{{key}}} 在 install profile 的 data 表里没有定义(原文 `{pattern}`)"
|
||||
))
|
||||
})?;
|
||||
out.push_str(value);
|
||||
}
|
||||
'\'' => out.push_str(&collect_until(&mut chars, '\'', pattern)?),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 某个变量的值。
|
||||
#[must_use]
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.values.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
/// 需要从 installer 里解包的文件(执行期用)。
|
||||
#[must_use]
|
||||
pub fn embedded(&self) -> &[EmbeddedFile] {
|
||||
&self.embedded
|
||||
}
|
||||
|
||||
/// 变量条数(`--dry-run` 只报数量,不吐全部路径)。
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
|
||||
/// 是否一条变量都没有(实际不可能:标准变量恒在)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 读到 `closing` 为止(跳过 `\` 转义),返回中间文本。
|
||||
fn collect_until(
|
||||
chars: &mut impl Iterator<Item = char>,
|
||||
closing: char,
|
||||
pattern: &str,
|
||||
) -> Result<String, ProcessorError> {
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
match chars.next() {
|
||||
Some(next) => out.push(next),
|
||||
None => {
|
||||
return Err(ProcessorError::plan(format!(
|
||||
"占位文本以 `\\` 结尾(无法转义):`{pattern}`"
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else if ch == closing {
|
||||
return Ok(out);
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
Err(ProcessorError::plan(format!(
|
||||
"占位文本缺少收尾的 `{closing}`:`{pattern}`"
|
||||
)))
|
||||
}
|
||||
|
||||
/// 整段是 `[坐标]` 时取出坐标(不到首尾就返回 `None`)。
|
||||
fn trim_brackets(raw: &str) -> Option<&str> {
|
||||
raw.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
}
|
||||
|
||||
/// `[坐标]` → `libraries/` 下的绝对路径。
|
||||
///
|
||||
/// 少数坐标进不了 `libraries/`:`net.minecraft:client:<ver>`(无 classifier)对应的是
|
||||
/// `versions/<ver>/<ver>.jar`。官方 installer 会把 client jar 塞进本地 maven 布局,
|
||||
/// 我们不复制(磁盘上原版 jar 只有一份),因此按目标版本回落。
|
||||
fn artifact_path(
|
||||
libraries: &Path,
|
||||
vanilla_jar: &Path,
|
||||
descriptor: &str,
|
||||
) -> Result<String, ProcessorError> {
|
||||
let coordinate = MavenCoordinate::parse(descriptor).map_err(|error| {
|
||||
ProcessorError::plan(format!(
|
||||
"install profile 里的构件坐标不合法:`{descriptor}`({error})"
|
||||
))
|
||||
})?;
|
||||
let path = libraries.join(coordinate.relative_path());
|
||||
if coordinate.group == "net.minecraft"
|
||||
&& coordinate.artifact == "client"
|
||||
&& coordinate.classifier.is_none()
|
||||
&& vanilla_jar.is_file()
|
||||
{
|
||||
return Ok(absolute(vanilla_jar).to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(absolute(&path).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// installer 内的条目 → 临时目录里的扁平文件名(`data/client.lzma` → `data-client.lzma`)。
|
||||
///
|
||||
/// 压平是为了防路径穿越:`../` 与绝对路径在拼进临时目录时都会失去意义。
|
||||
fn embedded_name(entry: &str) -> String {
|
||||
entry
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if matches!(ch, '/' | '\\' | ':') {
|
||||
'-'
|
||||
} else {
|
||||
ch
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 尽量转绝对路径(相对路径按当前工作目录;失败就原样返回)。
|
||||
#[must_use]
|
||||
pub fn absolute(path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Fabric / Quilt:profile 归一化的形状对拍。
|
||||
|
||||
use super::{FABRIC_PROFILE, QUILT_PROFILE};
|
||||
use crate::addon::loader::fabric::{build_release, parse_loader_list, pick_default};
|
||||
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
|
||||
|
||||
#[test]
|
||||
fn fabric_profile_becomes_inheriting_loader_version() {
|
||||
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
|
||||
.expect("fabric profile 应可归一化");
|
||||
|
||||
assert_eq!(release.version_id, "1.20.1-fabric-0.15.11");
|
||||
let json = &release.version_json;
|
||||
assert_eq!(json.id.as_deref(), Some("1.20.1-fabric-0.15.11"));
|
||||
assert_eq!(json.inherits_from.as_deref(), Some("1.20.1"));
|
||||
// ★ 关键:jar 必须钉成原版 id,否则继承解析会把 jar 兜底成 loader 自身 id。
|
||||
assert_eq!(json.jar.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
json.main_class.as_deref(),
|
||||
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
// jvm 参数(`-DFabricMcEmu`)必须原样保留:老版本启动靠它。
|
||||
let jvm = json
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|arguments| arguments.jvm.len())
|
||||
.unwrap_or_default();
|
||||
assert_eq!(jvm, 1, "profile 的 jvm 参数丢了");
|
||||
assert_eq!(release.support, LoaderSupport::JsonRoute);
|
||||
assert!(release.is_installable());
|
||||
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fabric_flat_digest_is_lifted_into_artifact() {
|
||||
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE)
|
||||
.expect("fabric profile 应可归一化");
|
||||
let libraries = release.version_json.libraries.expect("应有库");
|
||||
let asm = libraries
|
||||
.iter()
|
||||
.find(|library| library.name.starts_with("org.ow2.asm:asm:"))
|
||||
.expect("应有 asm");
|
||||
let artifact = asm
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.expect("扁平 sha1/size 应提升成 downloads.artifact");
|
||||
assert_eq!(
|
||||
artifact.sha1.as_deref(),
|
||||
Some("aa205cf0a06dbd8e04ece91c0b37c3f5d567546a")
|
||||
);
|
||||
assert_eq!(artifact.size, Some(123_598));
|
||||
assert_eq!(
|
||||
artifact.url.as_deref(),
|
||||
Some("https://maven.fabricmc.net/org/ow2/asm/asm/9.6/asm-9.6.jar")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quilt_profile_is_isomorphic() {
|
||||
let release = build_release(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9", QUILT_PROFILE)
|
||||
.expect("quilt profile 应可归一化");
|
||||
|
||||
assert_eq!(release.version_id, "1.20.1-quilt-0.20.0-beta.9");
|
||||
assert_eq!(release.version_json.jar.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
release.version_json.main_class.as_deref(),
|
||||
Some("org.quiltmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
assert_eq!(release.support, LoaderSupport::JsonRoute);
|
||||
// Quilt 的 profile 没有 loading 库时不该编造;这里有 quilt-loader,因此无告警。
|
||||
assert!(release.warnings.is_empty(), "{:?}", release.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_main_class_is_metadata_error() {
|
||||
let profile = r#"{"id":"x","inheritsFrom":"1.20.1","libraries":[]}"#;
|
||||
let error = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
|
||||
.expect_err("缺主类必须报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_loader_library_is_appended_with_warning() {
|
||||
// 去掉 fabric-loader 那条,看归一化会不会补。
|
||||
let profile = r#"{
|
||||
"id": "fabric-loader-0.15.11-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"libraries": [{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/"}]
|
||||
}"#;
|
||||
let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile)
|
||||
.expect("缺 loader 库应降级为告警 + 补一条");
|
||||
assert_eq!(release.warnings.len(), 1);
|
||||
let libraries = release.version_json.libraries.expect("应有库");
|
||||
assert!(libraries
|
||||
.iter()
|
||||
.any(|library| library.name == "net.fabricmc:fabric-loader:0.15.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_list_parses_and_prefers_stable() {
|
||||
let list = r#"[
|
||||
{"loader": {"version": "0.16.0-beta.1", "stable": false, "maven": "net.fabricmc:fabric-loader:0.16.0-beta.1"}},
|
||||
{"loader": {"version": "0.15.11", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.11"}},
|
||||
{"loader": {"version": "0.15.10", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.10"}}
|
||||
]"#;
|
||||
let entries = parse_loader_list(LoaderKind::Fabric, list).expect("列表应可解析");
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(
|
||||
pick_default(&entries).map(|entry| entry.version.as_str()),
|
||||
Some("0.15.11")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quilt_loader_list_without_stable_flag_still_picks_first() {
|
||||
// 实测 Quilt 的 loader 条目没有 stable 键。
|
||||
let list = r#"[
|
||||
{"loader": {"version": "0.20.0-beta.9", "maven": "org.quiltmc:quilt-loader:0.20.0-beta.9"}},
|
||||
{"loader": {"version": "0.19.2", "maven": "org.quiltmc:quilt-loader:0.19.2"}}
|
||||
]"#;
|
||||
let entries = parse_loader_list(LoaderKind::Quilt, list).expect("列表应可解析");
|
||||
assert!(entries.iter().all(|entry| entry.stable));
|
||||
assert_eq!(
|
||||
pick_default(&entries).map(|entry| entry.version.as_str()),
|
||||
Some("0.20.0-beta.9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_loader_list_is_not_found() {
|
||||
let error = parse_loader_list(LoaderKind::Fabric, "[]").expect_err("空列表应报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::NotFound);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! NeoForge / Forge:install profile 解析与三条路线的判定。
|
||||
|
||||
use crate::addon::loader::forge::{build_release, InstallProfile};
|
||||
use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport};
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
/// spec 1 + client 侧 processor(实测 NeoForge 21.1.80 / Forge 1.20.1 的形状,裁剪过)。
|
||||
pub(crate) const NEOFORGE_PROFILE: &str = r#"{
|
||||
"spec": 1,
|
||||
"profile": "NeoForge",
|
||||
"version": "neoforge-21.1.80",
|
||||
"minecraft": "1.21.1",
|
||||
"json": "/version.json",
|
||||
"path": null,
|
||||
"processors": [
|
||||
{"sides": ["server"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
|
||||
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"},
|
||||
{"jar": "net.neoforged.installertools:installertools:2.1.2"},
|
||||
{"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"}
|
||||
],
|
||||
"libraries": [
|
||||
{"name": "com.google.code.gson:gson:2.8.9@jar"},
|
||||
{"name": "org.apache.commons:commons-text:1.3@jar"}
|
||||
],
|
||||
"data": {"MC_SLIM": {"client": "[net.neoforged:neoforge:21.1.80:client-slim]"}}
|
||||
}"#;
|
||||
|
||||
/// spec 1 版本 JSON 的裁剪版(内容形状照 21.1.80)。
|
||||
pub(crate) const NEOFORGE_VERSION_JSON: &str = r#"{
|
||||
"id": "neoforge-21.1.80",
|
||||
"inheritsFrom": "1.21.1",
|
||||
"type": "release",
|
||||
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
|
||||
"arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-DignoreList=bootstraplauncher"]},
|
||||
"libraries": [
|
||||
{"name": "cpw.mods:securejarhandler:2.1.10",
|
||||
"downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
|
||||
"url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar",
|
||||
"sha1": "51e6a22c6c716beb11e244bf5b8be480f51dd6b5", "size": 88749}}}
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// spec 0 + 无 processors(实测 Forge 1.12.2-14.23.5.2860),universal jar 的 url 是空串。
|
||||
pub(crate) const FORGE_LEGACY_PROFILE: &str = r#"{
|
||||
"spec": 0,
|
||||
"profile": "forge",
|
||||
"version": "1.12.2-forge-14.23.5.2860",
|
||||
"minecraft": "1.12.2",
|
||||
"json": "/version.json",
|
||||
"path": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
|
||||
"processors": [],
|
||||
"libraries": [{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860"}]
|
||||
}"#;
|
||||
|
||||
pub(crate) const FORGE_LEGACY_VERSION_JSON: &str = r#"{
|
||||
"id": "1.12.2-forge-14.23.5.2860",
|
||||
"inheritsFrom": "1.12.2",
|
||||
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||
"minecraftArguments": "--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker",
|
||||
"libraries": [
|
||||
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
|
||||
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar",
|
||||
"url": "", "sha1": "029250575d3aa2cf80b56dffb66238a1eeaea2ac", "size": 4466148}}}
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn profile(json: &str) -> InstallProfile {
|
||||
serde_json::from_str(json).expect("夹具 profile 应可解析")
|
||||
}
|
||||
|
||||
fn version_json(json: &str) -> VersionJson {
|
||||
VersionJson::from_json_str(json).expect("夹具版本 JSON 应可解析")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_processors_mark_version_as_installer_required() {
|
||||
let release = build_release(
|
||||
LoaderKind::NeoForge,
|
||||
"1.21.1",
|
||||
"21.1.80",
|
||||
&profile(NEOFORGE_PROFILE),
|
||||
Some(&version_json(NEOFORGE_VERSION_JSON)),
|
||||
)
|
||||
.expect("版本 JSON 齐备时应能生成清单");
|
||||
|
||||
// 2 条 sides 含 client + 1 条无 sides(两侧都要)= 3 条 client 侧。
|
||||
assert_eq!(profile(NEOFORGE_PROFILE).client_processors(), 3);
|
||||
// M35 起 RequiresInstaller 是可真装的路线(外调 java 跑 processors),
|
||||
// 但"要跑 installer"这个事实仍要能被上层看见。
|
||||
assert!(release.is_installable());
|
||||
assert!(release.requires_installer());
|
||||
let reason = release
|
||||
.installer_reason()
|
||||
.expect("应给需要 installer 的原因");
|
||||
assert!(reason.contains("3 个 client 侧 processor"), "{reason}");
|
||||
// 版本清单本身仍然正确生成(--dry-run 能看到它)。
|
||||
assert_eq!(release.version_id, "1.21.1-neoforge-21.1.80");
|
||||
assert_eq!(
|
||||
release.version_json.inherits_from.as_deref(),
|
||||
Some("1.21.1")
|
||||
);
|
||||
assert_eq!(release.version_json.jar.as_deref(), Some("1.21.1"));
|
||||
// processor 依赖不进启动 classpath,只记数量进告警。
|
||||
assert_eq!(release.version_json.libraries.map_or(0, |l| l.len()), 1);
|
||||
assert!(release
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("processor 依赖")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_zero_profile_is_json_route_and_fills_empty_url() {
|
||||
let release = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.12.2",
|
||||
"1.12.2-14.23.5.2860",
|
||||
&profile(FORGE_LEGACY_PROFILE),
|
||||
Some(&version_json(FORGE_LEGACY_VERSION_JSON)),
|
||||
)
|
||||
.expect("spec 0 应走 json 路线");
|
||||
|
||||
assert_eq!(release.support, LoaderSupport::JsonRoute);
|
||||
assert_eq!(release.loader_version, "14.23.5.2860");
|
||||
assert_eq!(release.version_id, "1.12.2-forge-14.23.5.2860");
|
||||
assert_eq!(
|
||||
release.version_json.main_class.as_deref(),
|
||||
Some("net.minecraft.launchwrapper.Launch")
|
||||
);
|
||||
|
||||
// 空 url 必须补成 maven 地址:本体 jar 在官方 maven 上**只发布在 -universal classifier 下**
|
||||
// (无 classifier 路径官方 404,仅靠 BMCLAPI 302 别名兜底),所以 URL 要带 -universal。
|
||||
let library = release
|
||||
.version_json
|
||||
.libraries
|
||||
.as_ref()
|
||||
.and_then(|libraries| libraries.first())
|
||||
.expect("应有 forge 本体库");
|
||||
let artifact = library
|
||||
.downloads
|
||||
.as_ref()
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.expect("应有 artifact");
|
||||
assert_eq!(
|
||||
artifact.url.as_deref(),
|
||||
Some(
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-universal.jar"
|
||||
),
|
||||
"URL 必须带 -universal(官方 maven 只托管这个 classifier)"
|
||||
);
|
||||
assert_eq!(artifact.size, Some(4_466_148));
|
||||
assert!(library
|
||||
.url
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("minecraftforge"));
|
||||
|
||||
// 磁盘落点仍用原 path(与官方 installer 内嵌条目同名,启动 classpath 布局不变)。
|
||||
assert_eq!(
|
||||
artifact.path.as_deref(),
|
||||
Some("net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar")
|
||||
);
|
||||
let dest = library.relative_path().expect("落点应可推导");
|
||||
assert!(
|
||||
dest.ends_with("forge-1.12.2-14.23.5.2860.jar"),
|
||||
"落点不该带 classifier:{}",
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inline_protocol_is_unsupported() {
|
||||
// 老协议:没有独立 version.json,只有内联 versionInfo + 解包信息。
|
||||
let profile = profile(
|
||||
r#"{"spec": 1, "minecraft": "1.7.10",
|
||||
"install": {"filePath": "forge-1.7.10-10.13.4.1614-universal.jar"},
|
||||
"versionInfo": {"id": "1.7.10-Forge10.13.4.1614", "mainClass": "net.minecraft.launchwrapper.Launch"},
|
||||
"processors": []}"#,
|
||||
);
|
||||
assert!(profile.is_legacy_protocol());
|
||||
let error = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.7.10",
|
||||
"1.7.10-10.13.4.1614",
|
||||
&profile,
|
||||
None,
|
||||
)
|
||||
.expect_err("老协议必须明确 unsupported");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Unsupported);
|
||||
assert!(error.message().contains("老协议"), "{}", error.message());
|
||||
assert!(error.hint().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn universal_classifier_rewrite_is_scoped_to_the_installer_body() {
|
||||
// 只有 install profile 的 `path` 声明的本体构件走 -universal;
|
||||
// 其它同样"空 url + 有 path"的库必须保持原路径(乱加 classifier 会 404)。
|
||||
let embedded = version_json(
|
||||
r#"{"id": "1.12.2-forge-14.23.5.2860", "inheritsFrom": "1.12.2",
|
||||
"mainClass": "net.minecraft.launchwrapper.Launch",
|
||||
"libraries": [
|
||||
{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860",
|
||||
"downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", "url": ""}}},
|
||||
{"name": "net.minecraftforge:mergetool:1.1.5",
|
||||
"downloads": {"artifact": {"path": "net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar", "url": ""}}}
|
||||
]}"#,
|
||||
);
|
||||
let release = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.12.2",
|
||||
"1.12.2-14.23.5.2860",
|
||||
&profile(FORGE_LEGACY_PROFILE),
|
||||
Some(&embedded),
|
||||
)
|
||||
.expect("spec 0 应走 json 路线");
|
||||
let libraries = release.version_json.libraries.expect("应有库");
|
||||
let url_of = |name: &str| {
|
||||
libraries
|
||||
.iter()
|
||||
.find(|library| library.name == name)
|
||||
.and_then(|library| library.downloads.as_ref())
|
||||
.and_then(|downloads| downloads.artifact.as_ref())
|
||||
.and_then(|artifact| artifact.url.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert!(url_of("net.minecraftforge:forge:1.12.2-14.23.5.2860").ends_with("-universal.jar"));
|
||||
assert_eq!(
|
||||
url_of("net.minecraftforge:mergetool:1.1.5"),
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_between_installer_and_target_is_metadata_error() {
|
||||
let error = build_release(
|
||||
LoaderKind::NeoForge,
|
||||
"1.20.1",
|
||||
"21.1.80",
|
||||
&profile(NEOFORGE_PROFILE),
|
||||
Some(&version_json(NEOFORGE_VERSION_JSON)),
|
||||
)
|
||||
.expect_err("原版版本不符必须报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||
assert!(error.message().contains("1.21.1"), "{}", error.message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_main_class_is_metadata_error() {
|
||||
let embedded = version_json(r#"{"id": "x", "inheritsFrom": "1.12.2", "libraries": []}"#);
|
||||
let error = build_release(
|
||||
LoaderKind::Forge,
|
||||
"1.12.2",
|
||||
"1.12.2-14.23.5.2860",
|
||||
&profile(FORGE_LEGACY_PROFILE),
|
||||
Some(&embedded),
|
||||
)
|
||||
.expect_err("缺主类必须报错");
|
||||
assert_eq!(error.kind(), LoaderErrorKind::Metadata);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! maven 版本表:XML 扫描、NeoForge 版本反解、版本挑选、installer 地址。
|
||||
|
||||
use crate::addon::loader::maven::{
|
||||
display_loader_version, installer_url, matches_mc, metadata_url, neoforge_mc_version,
|
||||
normalize_maven_version, parse_metadata_versions, select_versions,
|
||||
};
|
||||
use crate::addon::loader::LoaderKind;
|
||||
|
||||
/// 裁剪过的 maven-metadata.xml(真实结构,只留 `<version>` 列表)。
|
||||
const METADATA: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>neoforge</artifactId>
|
||||
<versioning>
|
||||
<latest>26.1.2.125</latest>
|
||||
<versions>
|
||||
<version>20.4.237</version>
|
||||
<version>21.1.9</version>
|
||||
<version>21.1.80</version>
|
||||
<version>26.1.2.125</version>
|
||||
<version>0.0.1-snapshot-1</version>
|
||||
</versions>
|
||||
</versioning>
|
||||
</metadata>"#;
|
||||
|
||||
#[test]
|
||||
fn metadata_versions_are_scanned() {
|
||||
let versions = parse_metadata_versions(METADATA);
|
||||
assert_eq!(
|
||||
versions,
|
||||
vec![
|
||||
"20.4.237",
|
||||
"21.1.9",
|
||||
"21.1.80",
|
||||
"26.1.2.125",
|
||||
"0.0.1-snapshot-1"
|
||||
]
|
||||
);
|
||||
assert!(parse_metadata_versions("not xml").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neoforge_versions_map_back_to_minecraft() {
|
||||
// analyse/05 §1.3 的三条规则。
|
||||
assert_eq!(neoforge_mc_version("21.1.80").as_deref(), Some("1.21.1"));
|
||||
assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4"));
|
||||
assert_eq!(neoforge_mc_version("26.1.2.125").as_deref(), Some("26.1"));
|
||||
// 快照(主版本 0)不挂原版版本号。
|
||||
assert_eq!(neoforge_mc_version("0.0.1-snapshot-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_versions_filters_and_sorts_numerically() {
|
||||
let versions = parse_metadata_versions(METADATA);
|
||||
let picked = select_versions(LoaderKind::NeoForge, "1.21.1", &versions);
|
||||
// 数字序:9 < 80(字符串序会反过来)。
|
||||
assert_eq!(picked, vec!["21.1.9", "21.1.80"]);
|
||||
assert!(select_versions(LoaderKind::NeoForge, "1.19.2", &versions).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_versions_carry_the_minecraft_prefix() {
|
||||
let versions = vec![
|
||||
"1.20.1-47.3.0".to_string(),
|
||||
"1.20.1-47.2.0".to_string(),
|
||||
"1.12.2-14.23.5.2860".to_string(),
|
||||
];
|
||||
let picked = select_versions(LoaderKind::Forge, "1.20.1", &versions);
|
||||
assert_eq!(picked, vec!["1.20.1-47.2.0", "1.20.1-47.3.0"]);
|
||||
assert!(matches_mc(LoaderKind::Forge, "1.20.1", "1.20.1-47.3.0"));
|
||||
assert!(!matches_mc(LoaderKind::Forge, "1.20.2", "1.20.1-47.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neoforge_1201_uses_forge_layout() {
|
||||
// 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。
|
||||
assert!(metadata_url(LoaderKind::NeoForge, "1.20.1")
|
||||
.ends_with("net/neoforged/forge/maven-metadata.xml"));
|
||||
assert!(metadata_url(LoaderKind::NeoForge, "1.21.1")
|
||||
.ends_with("net/neoforged/neoforge/maven-metadata.xml"));
|
||||
assert!(metadata_url(LoaderKind::Forge, "1.20.1")
|
||||
.ends_with("net/minecraftforge/forge/maven-metadata.xml"));
|
||||
|
||||
let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()];
|
||||
let picked = select_versions(LoaderKind::NeoForge, "1.20.1", &versions);
|
||||
assert_eq!(picked.len(), 2);
|
||||
assert_eq!(
|
||||
display_loader_version(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
|
||||
"47.1.106"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_are_normalized_and_displayed() {
|
||||
// Forge:用户给 "47.2.0",maven 上是 "1.20.1-47.2.0"。
|
||||
assert_eq!(
|
||||
normalize_maven_version(LoaderKind::Forge, "1.20.1", "47.2.0"),
|
||||
"1.20.1-47.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_maven_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
||||
"1.20.1-47.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
display_loader_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
||||
"47.2.0"
|
||||
);
|
||||
// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本。
|
||||
assert_eq!(
|
||||
normalize_maven_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
||||
"21.1.80"
|
||||
);
|
||||
assert_eq!(
|
||||
display_loader_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
||||
"21.1.80"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_urls_point_at_official_maven() {
|
||||
assert_eq!(
|
||||
installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"),
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
|
||||
);
|
||||
assert_eq!(
|
||||
installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"),
|
||||
"https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar"
|
||||
);
|
||||
// 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。
|
||||
assert_eq!(
|
||||
installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"),
|
||||
"https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! `addon::loader` 的单元测试:离线夹具对拍 + 镜像选择 + unsupported 判定。
|
||||
//!
|
||||
//! 刻意**不打真实网络**:JSON 归一化、版本表挑选、镜像候选、计划形状全是纯计算。
|
||||
//! 夹具是官方响应的**裁剪版**(保留形状与关键字段,去掉几百行库与 base64 图标)。
|
||||
|
||||
mod fabric;
|
||||
mod forge;
|
||||
mod maven;
|
||||
mod plan;
|
||||
|
||||
/// 裁剪过的 Fabric `/profile/json`(1.20.1 + fabric-loader 0.15.11)。
|
||||
pub(crate) const FABRIC_PROFILE: &str = r#"{
|
||||
"id": "fabric-loader-0.15.11-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"releaseTime": "2026-10-29T10:25:18+0000",
|
||||
"time": "2026-10-29T10:25:18+0000",
|
||||
"type": "release",
|
||||
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
|
||||
"libraries": [
|
||||
{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/",
|
||||
"sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", "size": 123598},
|
||||
{"name": "net.fabricmc:sponge-mixin:0.13.3+mixin.0.8.5", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "net.fabricmc:fabric-loader:0.15.11", "url": "https://maven.fabricmc.net/"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// 裁剪过的 Quilt `/profile/json`(1.20.1 + quilt-loader 0.20.0-beta.9)。
|
||||
pub(crate) const QUILT_PROFILE: &str = r#"{
|
||||
"id": "quilt-loader-0.20.0-beta.9-1.20.1",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"type": "release",
|
||||
"mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient",
|
||||
"arguments": {"game": []},
|
||||
"libraries": [
|
||||
{"name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "org.quiltmc:quilt-json5:1.0.4+final", "url": "https://maven.quiltmc.org/repository/release/"},
|
||||
{"name": "org.quiltmc:quilt-loader:0.20.0-beta.9", "url": "https://maven.quiltmc.org/repository/release/"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// 原版 1.20.1 的**已裁剪**版本 JSON(继承链解析的父层)。
|
||||
pub(crate) const VANILLA_1201: &str = r#"{
|
||||
"id": "1.20.1",
|
||||
"type": "release",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"assets": "5",
|
||||
"assetIndex": {"id": "5", "sha1": "0000", "size": 1,
|
||||
"url": "https://piston-meta.mojang.com/v1/packages/5.json"},
|
||||
"downloads": {"client": {"sha1": "1111", "size": 2,
|
||||
"url": "https://piston-data.mojang.com/v1/objects/1/client.jar"}},
|
||||
"libraries": [
|
||||
{"name": "com.mojang:brigadier:1.0.18", "url": "https://libraries.minecraft.net/"}
|
||||
]
|
||||
}"#;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 计划形状 / 继承链两级解析 / 镜像选择。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{FABRIC_PROFILE, VANILLA_1201};
|
||||
use crate::addon::loader::fabric::{build_release, loader_list_url, profile_url};
|
||||
use crate::addon::loader::maven;
|
||||
use crate::addon::loader::{
|
||||
LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport,
|
||||
};
|
||||
use crate::download::mirrors::MirrorTable;
|
||||
use crate::download::region::RegionProfile;
|
||||
use crate::download::types::ResourceKind;
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::FileRole;
|
||||
use crate::game::{inherit, layout::GameLayout, VersionJson};
|
||||
|
||||
fn fabric_release() -> LoaderRelease {
|
||||
build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE).expect("夹具应可归一化")
|
||||
}
|
||||
|
||||
fn environment() -> Environment {
|
||||
Environment {
|
||||
os: "windows".to_string(),
|
||||
os_version: String::new(),
|
||||
arch: "x86_64".to_string(),
|
||||
..Environment::host()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_contains_only_loader_libraries() {
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let release = fabric_release();
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: false,
|
||||
});
|
||||
|
||||
assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11");
|
||||
// jar 与资源归原版层,loader 计划里只有库。
|
||||
assert_eq!(plan.install.jar_id, "1.20.1");
|
||||
assert!(plan
|
||||
.files()
|
||||
.iter()
|
||||
.all(|file| file.role == FileRole::Library));
|
||||
assert!(
|
||||
plan.files()
|
||||
.iter()
|
||||
.any(|file| file.name.contains("fabric-loader")),
|
||||
"缺 fabric-loader:{:?}",
|
||||
plan.files().iter().map(|f| &f.name).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(plan.install.version_json_path.ends_with(
|
||||
std::path::Path::new("versions")
|
||||
.join("1.20.1-fabric-0.15.11")
|
||||
.join("1.20.1-fabric-0.15.11.json")
|
||||
));
|
||||
// 只下 loader 那几个库,不该冒出资产/客户端 jar。
|
||||
assert!(plan.install.file_count() >= 4);
|
||||
assert!(plan.install.client_jar().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_marks_missing_vanilla_and_unsupported() {
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let release = fabric_release();
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: false,
|
||||
});
|
||||
assert!(plan.vanilla_install_needed);
|
||||
assert!(plan
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("原版 1.20.1 尚未安装")));
|
||||
|
||||
let installed = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: true,
|
||||
});
|
||||
assert!(!installed.vanilla_install_needed);
|
||||
|
||||
// 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。
|
||||
let mut blocked = fabric_release();
|
||||
blocked.support = LoaderSupport::RequiresInstaller {
|
||||
reason: "需要 processors".to_string(),
|
||||
};
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &blocked,
|
||||
environment: &environment(),
|
||||
vanilla_installed: true,
|
||||
});
|
||||
assert!(plan
|
||||
.warnings()
|
||||
.iter()
|
||||
.any(|warning| warning.contains("需要外调 java 跑 installer processors")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_json_carries_loader_block() {
|
||||
let layout = GameLayout::new("/g/.minecraft");
|
||||
let release = fabric_release();
|
||||
let plan = LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &environment(),
|
||||
vanilla_installed: true,
|
||||
});
|
||||
let summary = plan.summary_json();
|
||||
assert_eq!(summary["loader"]["kind"], "fabric");
|
||||
assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11");
|
||||
assert_eq!(summary["loader"]["support"]["mode"], "json_route");
|
||||
assert_eq!(summary["loader"]["inherits_from"], "1.20.1");
|
||||
assert_eq!(summary["vanilla_install_needed"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_json_resolves_two_level_chain() {
|
||||
// loader → 原版 两级继承:解析后必须是扁平的、jar 指原版、库两侧合并。
|
||||
let vanilla = VersionJson::from_json_str(VANILLA_1201).expect("原版夹具");
|
||||
let loader = fabric_release().version_json;
|
||||
let mut table: BTreeMap<String, VersionJson> = BTreeMap::new();
|
||||
table.insert("1.20.1".to_string(), vanilla);
|
||||
table.insert("1.20.1-fabric-0.15.11".to_string(), loader.clone());
|
||||
|
||||
let resolved = inherit::resolve(&table, &loader).expect("两级链应可解析");
|
||||
assert!(resolved.is_resolved());
|
||||
assert_eq!(resolved.inherits_from, None);
|
||||
assert_eq!(resolved.jar.as_deref(), Some("1.20.1"));
|
||||
assert_eq!(
|
||||
resolved.main_class.as_deref(),
|
||||
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
// 资源来自原版层。
|
||||
assert_eq!(resolved.assets.as_deref(), Some("5"));
|
||||
assert!(resolved.client_jar().is_some());
|
||||
// 库两层的并集(父在前,子在后)。
|
||||
let libraries = resolved.libraries.expect("应有库");
|
||||
assert_eq!(libraries.len(), 5);
|
||||
assert_eq!(libraries[0].name, "com.mojang:brigadier:1.0.18");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_meta_and_maven_urls_hit_bmclapi_mirrors() {
|
||||
let table = MirrorTable::builtin();
|
||||
let mirror_first = RegionProfile::mirror_first(0);
|
||||
|
||||
let fabric_meta = loader_list_url(LoaderKind::Fabric, "1.20.1");
|
||||
let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &mirror_first);
|
||||
let sources: Vec<&str> = candidates.iter().map(|c| c.source.as_str()).collect();
|
||||
assert_eq!(
|
||||
sources.first(),
|
||||
Some(&"bmclapi"),
|
||||
"镜像优先时应先走 BMCLAPI"
|
||||
);
|
||||
assert!(candidates.iter().any(|candidate| candidate
|
||||
.url
|
||||
.contains("/fabric-meta/v2/versions/loader/1.20.1")));
|
||||
|
||||
let quilt_profile = profile_url(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9");
|
||||
let candidates = table.expand(&quilt_profile, ResourceKind::VersionManifest, &mirror_first);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert!(candidates[0].url.contains("/quilt-meta/"));
|
||||
|
||||
let neoforge_installer = maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80");
|
||||
let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert!(candidates[0]
|
||||
.url
|
||||
.starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/"));
|
||||
|
||||
let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1");
|
||||
let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first);
|
||||
assert_eq!(candidates[0].source, "bmclapi");
|
||||
assert!(candidates[0]
|
||||
.url
|
||||
.starts_with("https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/"));
|
||||
|
||||
// 直连模式:官方在前,镜像只兜底。
|
||||
let direct = RegionProfile::direct(0);
|
||||
let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &direct);
|
||||
assert_eq!(candidates[0].source, "official");
|
||||
assert_eq!(candidates[0].url, fabric_meta);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Mod 与整合包:加载器安装(Fabric/Quilt/NeoForge/Forge)、整合包双格式导入导出、
|
||||
//! 本地 Mod 管理、CurseForge / Modrinth 客户端。
|
||||
//!
|
||||
//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md`(决策 D10)。
|
||||
//! 安全要点:整合包路径穿越双检;AI 推荐走「结构化过滤在前 + LLM 排序在后 + 来源链接防幻觉」。
|
||||
//!
|
||||
//! # 当前公共面
|
||||
//!
|
||||
//! - [`loader`](P2 · M32):加载器安装——官方元数据 → 带 `inheritsFrom` 的 loader 版本 JSON
|
||||
//! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。
|
||||
//!
|
||||
//! 规划中:整合包导入导出(D10 §2)、本地 mod 管理(§3)、CF/Modrinth 客户端(§5)。
|
||||
|
||||
pub mod loader;
|
||||
|
||||
pub use loader::{
|
||||
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
|
||||
LoaderSource, LoaderSupport,
|
||||
};
|
||||
Reference in new issue
Block a user