Files
Starlight_Lancher/packages/app-lib/src/mod_metadata/mod.rs

410 lines
14 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()?;
// Try each known metadata path in priority order.
if let Some(meta) = try_fabric(&mut archive) {
return Some(meta);
}
if let Some(meta) = try_quilt(&mut archive) {
return Some(meta);
}
if let Some(meta) =
try_toml_path(&mut archive, "META-INF/neoforge.mods.toml")
{
return Some(meta);
}
if let Some(meta) = try_toml_path(&mut archive, "META-INF/mods.toml") {
return Some(meta);
}
if let Some(meta) = try_mcmod_info(&mut archive) {
return Some(meta);
}
None
}
// ── format-specific parsers ────────────────────────────────────────────────
fn try_fabric(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
) -> 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(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
) -> 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(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
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(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
) -> 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(
version: Option<String>,
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
) -> 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;
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}"));
}
}