Files
Unlucky-Launcher/crates/core/src/addon/loader/fabric.rs
T
AskaEth 1105a86448
CI / runtime smoke (macos arm64, probe) (push) Skipped
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
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.2
2026-11-07 08:46:11 +08:00

248 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
));
}