Files
Starlight_Lancher/packages/app-lib/src/mod_metadata/mod.rs
T
Disy bc904065c3
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

511 lines
17 KiB
Rust

//! Parse mod metadata files from inside JAR archives to extract mod identity
//! information (mod ID, name, version, authors, etc.) when Modrinth API
//! lookups fail or have no match.
//!
//! Supported formats:
//! - Fabric: `fabric.mod.json` (JSON)
//! - Quilt: `quilt.mod.json` (JSON, same shape wrapped under `quilt_loader`)
//! - Forge: `META-INF/mods.toml` (TOML)
//! - NeoForge: `META-INF/neoforge.mods.toml` (TOML)
//! - Legacy Forge: `mcmod.info` (JSON array)
mod fabric;
pub mod icon;
pub mod manifest;
mod mcmod_info;
mod toml_mod;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
/// Unified local mod metadata extracted from inside a JAR.
///
/// Only `mod_id` is required; all other fields are best-effort.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalModMetadata {
/// Unique mod identifier (e.g. "sodium", "minecraft")
pub mod_id: String,
/// Human-readable display name
pub name: Option<String>,
/// Mod version string
pub version: Option<String>,
/// Author list
#[serde(default)]
pub authors: Vec<String>,
/// Short description
pub description: Option<String>,
/// Website or project URL
pub url: Option<String>,
/// Path to icon inside the JAR (e.g. "icon.png" or "assets/.../icon.png")
pub icon_path: Option<String>,
/// Supported Minecraft version range (e.g. ">=1.20", "[1.20,1.21)", "1.12.2")
pub minecraft_version: Option<String>,
/// Required loader version (e.g. ">=0.15.0", "[52,)")
pub loader_version: Option<String>,
/// Loader type (e.g. "fabric", "forge", "neoforge", "quilt")
pub loader: Option<String>,
/// Required dependencies declared in the embedded metadata (Fabric
/// `depends`, Quilt `depends`, Forge mandatory dependencies).
///
/// `None` marks JSON written before dependency extraction existed and
/// triggers a one-time re-extraction of the file.
#[serde(default)]
pub dependencies: Option<Vec<LocalModDependency>>,
}
/// One required dependency declared in embedded mod metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalModDependency {
pub mod_id: String,
#[serde(default)]
pub version_range: Option<String>,
}
/// Dependency ids that refer to the runtime environment instead of content
/// that can be linked against installed files.
pub(crate) fn is_env_dependency_id(id: &str) -> bool {
matches!(
id,
"minecraft"
| "java"
| "fabricloader"
| "quilt_loader"
| "forge"
| "neoforge"
| "fml"
)
}
/// Try to extract `LocalModMetadata` from raw JAR bytes.
///
/// Returns `None` when the JAR does not contain any known mod metadata file
/// or when none of the supported formats can be successfully parsed.
pub fn extract_mod_metadata(bytes: &Bytes) -> Option<LocalModMetadata> {
let cursor = std::io::Cursor::new(&**bytes);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
extract_archive_metadata(&mut archive)
}
/// Read only metadata entries without loading the entire Mod JAR into memory.
pub fn read_mod_metadata(path: &std::path::Path) -> Option<LocalModMetadata> {
let mut archive =
zip::ZipArchive::new(std::fs::File::open(path).ok()?).ok()?;
for name in [
"fabric.mod.json",
"quilt.mod.json",
"META-INF/mods.toml",
"META-INF/neoforge.mods.toml",
"mcmod.info",
"META-INF/MANIFEST.MF",
] {
if archive
.by_name(name)
.ok()
.is_some_and(|entry| entry.size() > 2 * 1024 * 1024)
{
return None;
}
}
extract_archive_metadata(&mut archive)
}
/// All top-level Mod IDs in a JAR, used to replace multi-Mod archives safely.
pub fn read_mod_ids(path: &std::path::Path) -> Vec<String> {
use std::io::Read;
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let mut ids = std::collections::BTreeSet::new();
for name in ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"] {
let Ok(file) = archive.by_name(name) else {
continue;
};
if file.size() > 2 * 1024 * 1024 {
continue;
}
let mut content = String::new();
if file
.take(2 * 1024 * 1024)
.read_to_string(&mut content)
.is_ok()
&& let Ok(parsed) = toml::from_str::<toml_mod::ModsToml>(&content)
{
ids.extend(
parsed
.mods
.unwrap_or_default()
.into_iter()
.filter_map(|entry| entry.mod_id),
);
}
}
for name in ["fabric.mod.json", "quilt.mod.json", "mcmod.info"] {
let Ok(file) = archive.by_name(name) else {
continue;
};
if file.size() > 2 * 1024 * 1024 {
continue;
}
if let Ok(value) = serde_json::from_reader::<_, serde_json::Value>(
file.take(2 * 1024 * 1024),
) {
if let Some(id) = value
.get("id")
.or_else(|| value.pointer("/quilt_loader/id"))
.and_then(|v| v.as_str())
{
ids.insert(id.to_owned());
}
if let Some(mods) = value.as_array() {
ids.extend(mods.iter().filter_map(|entry| {
entry.get("modid")?.as_str().map(str::to_owned)
}));
}
}
}
ids.into_iter().collect()
}
fn extract_archive_metadata<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
// Try each known metadata path in priority order.
if let Some(meta) = try_fabric(archive) {
return Some(meta);
}
if let Some(meta) = try_quilt(archive) {
return Some(meta);
}
if let Some(meta) = try_toml_path(archive, "META-INF/neoforge.mods.toml") {
return Some(meta);
}
if let Some(meta) = try_toml_path(archive, "META-INF/mods.toml") {
return Some(meta);
}
if let Some(meta) = try_mcmod_info(archive) {
return Some(meta);
}
None
}
// ── format-specific parsers ────────────────────────────────────────────────
fn try_fabric<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("fabric.mod.json").ok()?;
let parsed: fabric::FabricModJson =
serde_json::from_reader(&mut file).ok()?;
let authors = merge_authors(&parsed.authors, &parsed.contributors);
// A mod without an id cannot be identified; skip it rather than
// fabricating a shared placeholder that would make mods collide.
let mod_id = parsed.id.clone()?;
Some(LocalModMetadata {
mod_id,
name: parsed.name,
version: parsed.version,
authors,
description: parsed.description,
url: extract_contact_url(&parsed._contact),
icon_path: parsed.icon.as_ref().and_then(|icon| icon.resolve()),
minecraft_version: fabric::fabric_dep_value(
&parsed.depends,
"minecraft",
),
loader_version: fabric::fabric_dep_value(
&parsed.depends,
"fabricloader",
),
loader: Some("fabric".into()),
dependencies: Some(fabric::fabric_dependencies(&parsed.depends)),
})
}
fn try_quilt<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("quilt.mod.json").ok()?;
let parsed: fabric::QuiltModJson =
serde_json::from_reader(&mut file).ok()?;
let inner = parsed.quilt_loader;
let authors = merge_authors(&inner.authors, &inner.contributors);
let mod_id = inner.id.clone()?;
Some(LocalModMetadata {
mod_id,
name: inner.name,
version: inner.version,
authors,
description: inner.description,
url: extract_contact_url(&inner._contact),
icon_path: inner.icon.as_ref().and_then(|icon| icon.resolve()),
minecraft_version: fabric::quilt_dep_value(&inner.depends, "minecraft"),
loader_version: fabric::quilt_dep_value(&inner.depends, "quilt_loader"),
loader: Some("quilt".into()),
dependencies: Some(fabric::quilt_dependencies(&inner.depends)),
})
}
fn try_toml_path<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &str,
) -> Option<LocalModMetadata> {
let mut content = String::new();
{
let mut file = archive.by_name(path).ok()?;
std::io::Read::read_to_string(&mut file, &mut content).ok()?;
}
let parsed: toml_mod::ModsToml = toml::from_str(&content).ok()?;
// A mod jar may declare several [[mods]] entries (bundled mods); report
// the first entry that carries an ID. An id-less entry (e.g. the
// "minecraft" marker used by some packs) must not discard the metadata
// of the real mod that follows.
let entry = parsed
.mods?
.into_iter()
.find(|entry| entry.mod_id.is_some())?;
let mod_id = entry.mod_id.clone()?;
let authors: Vec<String> = entry
.authors
.as_deref()
.map(|s| {
s.split(',')
.map(|a| a.trim().to_string())
.filter(|a| !a.is_empty())
.collect()
})
.unwrap_or_default();
// Determine loader type from the file path.
let is_neoforge = path.contains("neoforge");
let loader = if is_neoforge {
Some("neoforge".into())
} else {
Some("forge".into())
};
// The root `loaderVersion` IS the Forge/NeoForge loader version.
let loader_version = parsed.loader_version.clone();
// Look up dependencies for this mod's modId.
let minecraft_version = parsed
.dependencies
.as_ref()
.and_then(|deps| deps.get(&mod_id))
.and_then(|entries| {
entries
.iter()
.find(|dep| dep.mod_id.as_deref() == Some("minecraft"))
.and_then(|dep| dep.version_range.clone())
});
let dependencies = parsed
.dependencies
.as_ref()
.and_then(|deps| deps.get(&mod_id))
.map(|entries| {
entries
.iter()
.filter(|dep| dep.mandatory.unwrap_or(true))
.filter_map(|dep| {
let dep_id = dep.mod_id.clone()?;
if is_env_dependency_id(&dep_id) {
return None;
}
Some(LocalModDependency {
mod_id: dep_id,
version_range: dep.version_range.clone(),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Forge `mods.toml` commonly stores the version as a Gradle placeholder
// (e.g. `${file.jarVersion}`) that the loader resolves at runtime from the
// JAR manifest's `Implementation-Version`. Resolve it here so the
// placeholder never surfaces as a version in the UI.
let version = resolve_toml_version(entry.version.clone(), archive);
Some(LocalModMetadata {
mod_id,
name: entry.display_name,
version,
authors,
description: entry.description,
url: entry.display_url,
icon_path: entry.logo_file,
minecraft_version,
loader_version,
loader,
dependencies: Some(dependencies),
})
}
fn try_mcmod_info<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("mcmod.info").ok()?;
let entries: Vec<mcmod_info::McmodInfoEntry> =
serde_json::from_reader(&mut file).ok()?;
let entry = entries.into_iter().next()?;
let mod_id = entry.modid.clone()?;
Some(LocalModMetadata {
mod_id,
name: entry.name,
version: entry.version,
authors: entry.authors.unwrap_or_default(),
description: entry.description,
url: entry.url,
icon_path: entry.logo_file,
minecraft_version: entry.mcversion,
loader_version: None,
loader: Some("forge".into()),
dependencies: Some(Vec::new()),
})
}
// ── helpers ────────────────────────────────────────────────────────────────
fn merge_authors(
primary: &[fabric::FabricAuthorOrArray],
contributors: &[fabric::FabricAuthorOrArray],
) -> Vec<String> {
primary
.iter()
.chain(contributors.iter())
.filter_map(|author| match author {
fabric::FabricAuthorOrArray::Plain(s) => Some(s.clone()),
fabric::FabricAuthorOrArray::Object { name } => name.clone(),
})
.collect()
}
/// Extract a URL from Fabric's `contact` object (often has `"homepage"`, `"sources"`, etc.).
fn extract_contact_url(contact: &Option<serde_json::Value>) -> Option<String> {
let obj = contact.as_ref()?.as_object()?;
// Prefer homepage, then sources, then any string value.
if let Some(homepage) = obj.get("homepage").and_then(|v| v.as_str()) {
return Some(homepage.to_string());
}
if let Some(sources) = obj.get("sources").and_then(|v| v.as_str()) {
return Some(sources.to_string());
}
// Fallback: return the first string field found.
obj.values().find_map(|v| v.as_str().map(String::from))
}
/// Resolve a Forge `mods.toml` version string.
///
/// Gradle builds often write an unresolved placeholder (e.g.
/// `${file.jarVersion}`) which the loader substitutes from the JAR manifest at
/// runtime; surface the real `Implementation-Version` from the manifest when
/// present, falling back to the original value otherwise.
fn resolve_toml_version<R: std::io::Read + std::io::Seek>(
version: Option<String>,
archive: &mut zip::ZipArchive<R>,
) -> Option<String> {
let placeholder = version.clone()?;
if !placeholder.starts_with("${") {
return Some(placeholder);
}
Some(
manifest::archive_manifest(archive)
.and_then(|manifest| manifest.implementation_version)
.filter(|resolved| !resolved.trim().is_empty())
.unwrap_or(placeholder),
)
}
#[cfg(test)]
mod tests {
use std::io::Write;
#[test]
fn reads_every_declared_mod_id_from_disk_without_including_dependencies() {
let jar = build_jar(&[(
"META-INF/mods.toml",
"[[mods]]\nmodId = \"first\"\nversion = \"1\"\n[[mods]]\nmodId = \"second\"\nversion = \"1\"\n[[dependencies.first]]\nmodId = \"minecraft\"\n",
)]);
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(&jar).unwrap();
assert_eq!(super::read_mod_ids(file.path()), vec!["first", "second"]);
assert_eq!(
super::read_mod_metadata(file.path()).unwrap().mod_id,
"first"
);
}
fn build_jar(entries: &[(&str, &str)]) -> bytes::Bytes {
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut archive = zip::ZipWriter::new(&mut buffer);
let options = zip::write::FileOptions::<()>::default();
for (name, content) in entries {
archive.start_file(*name, options).expect("start zip entry");
archive
.write_all(content.as_bytes())
.expect("write zip entry");
}
archive.finish().expect("finish zip");
}
bytes::Bytes::from(buffer.into_inner())
}
#[test]
fn forge_placeholder_version_resolves_from_manifest() {
let jar = build_jar(&[
(
"META-INF/MANIFEST.MF",
"Manifest-Version: 1.0\nImplementation-Title: Example\nImplementation-Version: 1.2.3\n",
),
(
"META-INF/mods.toml",
"modLoader = \"javafml\"\nloaderVersion = \"[4,)\"\n\n[[mods]]\nmodId = \"example\"\ndisplayName = \"Example\"\nversion = \"${file.jarVersion}\"\n",
),
]);
let meta = super::extract_mod_metadata(&jar).expect("mod metadata");
assert_eq!(meta.mod_id, "example");
assert_eq!(meta.version.as_deref(), Some("1.2.3"));
}
#[test]
fn resolved_mods_toml_version_is_kept_as_is() {
let jar = build_jar(&[
(
"META-INF/MANIFEST.MF",
"Manifest-Version: 1.0\nImplementation-Version: 9.9.9\n",
),
(
"META-INF/mods.toml",
"[[mods]]\nmodId = \"example\"\nversion = \"1.0.0\"\n",
),
]);
let meta = super::extract_mod_metadata(&jar).expect("mod metadata");
assert_eq!(meta.version.as_deref(), Some("1.0.0"));
}
#[test]
fn placeholder_version_without_manifest_attribute_is_kept() {
let jar = build_jar(&[(
"META-INF/mods.toml",
"[[mods]]\nmodId = \"example\"\nversion = \"${file.jarVersion}\"\n",
)]);
let meta = super::extract_mod_metadata(&jar).expect("mod metadata");
assert_eq!(meta.version.as_deref(), Some("${file.jarVersion}"));
}
}