feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,255 @@
use serde::Deserialize;
use std::collections::HashMap;
/// Fabric mod.json (and Quilt's similar quilt.mod.json wrapped under quilt_loader).
#[derive(Debug, Deserialize)]
pub(crate) struct FabricModJson {
pub id: Option<String>,
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub authors: Vec<FabricAuthorOrArray>,
#[serde(default)]
pub contributors: Vec<FabricAuthorOrArray>,
pub icon: Option<ModIcon>,
#[serde(rename = "contact")]
pub _contact: Option<serde_json::Value>,
/// Dependency resolution (Fabric: map of id→version, Quilt: array of objects).
/// Uses `serde_json::Value` to handle both formats.
pub depends: Option<serde_json::Value>,
#[allow(dead_code)]
pub recommends: Option<serde_json::Value>,
#[allow(dead_code)]
pub conflicts: Option<serde_json::Value>,
#[allow(dead_code)]
pub breaks: Option<serde_json::Value>,
}
/// Fabric's `icon` field accepts either a plain path or a dictionary mapping
/// icon size to path.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum ModIcon {
Path(String),
Sized(HashMap<String, String>),
}
impl ModIcon {
/// Resolve a single icon path, preferring the largest declared size.
pub(crate) fn resolve(&self) -> Option<String> {
match self {
Self::Path(path) => Some(path.clone()),
Self::Sized(sizes) => sizes
.iter()
.max_by_key(|(size, _)| size.parse::<u64>().unwrap_or(0))
.map(|(_, path)| path.clone()),
}
}
}
/// An author/contributor entry: either a plain string or `{"name": "...", "contact": {...}}`.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum FabricAuthorOrArray {
Plain(String),
Object { name: Option<String> },
}
/// Quilt's wrapper: `{"quilt_loader": { "id": "...", ... }}`.
#[derive(Debug, Deserialize)]
pub(crate) struct QuiltModJson {
pub quilt_loader: FabricModJson,
}
/// Extract a string value from a Fabric-style depends map.
pub(crate) fn fabric_dep_value(
depends: &Option<serde_json::Value>,
key: &str,
) -> Option<String> {
let obj = depends.as_ref()?.as_object()?;
obj.get(key).and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(arr) => {
arr.first().and_then(|v| v.as_str().map(String::from))
}
_ => None,
})
}
/// Extract a string value from a Quilt-style depends array.
pub(crate) fn quilt_dep_value(
depends: &Option<serde_json::Value>,
key: &str,
) -> Option<String> {
let arr = depends.as_ref()?.as_array()?;
for dep in arr {
let id = dep
.as_object()
.and_then(|obj| obj.get("id"))
.and_then(|v| v.as_str())?;
if id == key {
return dep
.as_object()
.and_then(|obj| obj.get("versions"))
.and_then(|v| v.as_str())
.map(String::from);
}
}
None
}
/// Split a `"modid: version-range"` string into its parts.
fn split_dependency_id(text: &str) -> (String, Option<String>) {
match text.split_once(':') {
Some((id, range)) => {
(id.trim().to_string(), Some(range.trim().to_string()))
}
None => (text.trim().to_string(), None),
}
}
fn push_dependency(
out: &mut Vec<super::LocalModDependency>,
mod_id: String,
version_range: Option<String>,
) {
if super::is_env_dependency_id(&mod_id) {
return;
}
out.push(super::LocalModDependency {
mod_id,
version_range,
})
}
/// Extract required dependencies from a Fabric-style `depends` map
/// (`"id": "range"` or `"id": ["alternative", "alternative: range"]`).
pub(crate) fn fabric_dependencies(
depends: &Option<serde_json::Value>,
) -> Vec<super::LocalModDependency> {
let Some(obj) = depends.as_ref().and_then(|v| v.as_object()) else {
return Vec::new();
};
let mut out = Vec::new();
for (id, value) in obj {
match value {
serde_json::Value::String(range) => {
push_dependency(&mut out, id.clone(), Some(range.clone()));
}
serde_json::Value::Array(alternatives) => {
for alternative in alternatives {
let Some(text) = alternative.as_str() else {
continue;
};
let (mod_id, version_range) = split_dependency_id(text);
push_dependency(&mut out, mod_id, version_range);
}
}
_ => {}
}
}
out
}
/// Extract required dependencies from a Quilt-style `depends` array
/// (`[{"id": "...", "versions": "..."}]`, nested arrays list alternatives).
pub(crate) fn quilt_dependencies(
depends: &Option<serde_json::Value>,
) -> Vec<super::LocalModDependency> {
let Some(arr) = depends.as_ref().and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in arr {
match entry {
serde_json::Value::String(text) => {
let (mod_id, version_range) = split_dependency_id(text);
push_dependency(&mut out, mod_id, version_range);
}
serde_json::Value::Object(obj) => {
let Some(mod_id) = obj.get("id").and_then(|v| v.as_str())
else {
continue;
};
let version_range = obj
.get("versions")
.and_then(|v| v.as_str())
.map(String::from);
push_dependency(&mut out, mod_id.to_string(), version_range);
}
serde_json::Value::Array(alternatives) => {
for alternative in alternatives {
let Some(obj) = alternative.as_object() else {
continue;
};
let Some(mod_id) = obj.get("id").and_then(|v| v.as_str())
else {
continue;
};
let version_range = obj
.get("versions")
.and_then(|v| v.as_str())
.map(String::from);
push_dependency(
&mut out,
mod_id.to_string(),
version_range,
);
}
}
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fabric_dependencies_flatten_alternatives_and_skip_environment() {
let depends = serde_json::json!({
"sodium": ">=0.4.10",
"fabricloader": ">=0.15.0",
"minecraft": "~1.20.1",
"physics": ["physx", "physx-fabric: >=1.2"]
});
let deps = fabric_dependencies(&Some(depends));
let mut ids = deps
.iter()
.map(|dep| dep.mod_id.as_str())
.collect::<Vec<_>>();
ids.sort_unstable();
assert_eq!(ids, ["physx", "physx-fabric", "sodium"]);
let sodium = deps.iter().find(|dep| dep.mod_id == "sodium").unwrap();
assert_eq!(sodium.version_range.as_deref(), Some(">=0.4.10"));
let physx = deps.iter().find(|dep| dep.mod_id == "physx").unwrap();
assert_eq!(physx.version_range, None);
let physx_fabric = deps
.iter()
.find(|dep| dep.mod_id == "physx-fabric")
.unwrap();
assert_eq!(physx_fabric.version_range.as_deref(), Some(">=1.2"));
}
#[test]
fn quilt_dependencies_handle_objects_strings_and_alternatives() {
let depends = serde_json::json!([
{ "id": "sodium", "versions": "*" },
"indium",
[
{ "id": "canvas", "versions": ">=1.0" },
{ "id": "minecraft" }
]
]);
let deps = quilt_dependencies(&Some(depends));
let ids = deps
.iter()
.map(|dep| dep.mod_id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, ["sodium", "indium", "canvas"]);
assert_eq!(deps[2].version_range.as_deref(), Some(">=1.0"));
}
}

View File

@ -0,0 +1,76 @@
//! Extract icon artwork from inside mod JARs and resource pack ZIPs so
//! unmatched content files can display real icons.
use bytes::Bytes;
use std::io::{Read, Seek};
use std::path::Path;
use zip::ZipArchive;
/// Entries larger than this are almost certainly not pack icons.
const MAX_ICON_BYTES: u64 = 2 * 1024 * 1024;
/// Returns the matched entry name and validated image bytes for a mod JAR.
///
/// Prefers the icon declared by the embedded mod metadata, then falls back
/// to the in-game `pack.png` convention at the archive root.
pub fn extract_mod_icon(
bytes: &Bytes,
metadata: Option<&crate::mod_metadata::LocalModMetadata>,
) -> Option<(String, Vec<u8>)> {
let cursor = std::io::Cursor::new(&**bytes);
let mut archive = ZipArchive::new(cursor).ok()?;
if let Some(icon_path) = metadata.and_then(|meta| meta.icon_path.as_deref())
&& let Some(icon) = read_entry(&mut archive, icon_path)
{
return Some(icon);
}
read_entry(&mut archive, "pack.png")
}
/// Returns the matched entry name and validated image bytes for a resource
/// pack ZIP. Resource packs declare their icon as `pack.png` at the root.
pub fn extract_resource_pack_icon(path: &Path) -> Option<(String, Vec<u8>)> {
let file = std::fs::File::open(path).ok()?;
let mut archive = ZipArchive::new(file).ok()?;
read_entry(&mut archive, "pack.png")
}
fn read_entry<R: Read + Seek>(
archive: &mut ZipArchive<R>,
entry_name: &str,
) -> Option<(String, Vec<u8>)> {
let normalized = entry_name.trim_start_matches('/').replace('\\', "/");
let entry = open_entry(archive, &normalized)?;
if !entry.is_file() || entry.size() > MAX_ICON_BYTES {
return None;
}
let entry_name = entry.name().to_string();
let mut data = Vec::with_capacity(entry.size() as usize);
entry.take(MAX_ICON_BYTES + 1).read_to_end(&mut data).ok()?;
is_supported_image(&data).then_some((entry_name, data))
}
fn open_entry<'a, R: Read + Seek>(
archive: &'a mut ZipArchive<R>,
normalized: &str,
) -> Option<zip::read::ZipFile<'a, R>> {
if archive.by_name(normalized).is_ok() {
archive.by_name(normalized).ok()
} else {
let names: Vec<String> =
archive.file_names().map(ToOwned::to_owned).collect();
let matched = names
.iter()
.find(|name| name.eq_ignore_ascii_case(normalized))?;
archive.by_name(matched).ok()
}
}
fn is_supported_image(data: &[u8]) -> bool {
data.starts_with(&[0x89, b'P', b'N', b'G'])
|| data.starts_with(&[0xFF, 0xD8, 0xFF])
}

View File

@ -0,0 +1,222 @@
//! JAR manifest (META-INF/MANIFEST.MF) reader.
//!
//! Extracts metadata entries from a JAR file's manifest without requiring
//! full mod metadata parsing. Used by the drop classifier to identify
//! launcher JARs (e.g. HMCL).
use std::io::Read;
use std::path::Path;
/// Key-value pairs extracted from a JAR's META-INF/MANIFEST.MF.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct JarManifest {
/// Value of `Main-Class` attribute.
pub main_class: Option<String>,
/// Value of `Implementation-Title` attribute.
pub implementation_title: Option<String>,
/// Value of `Implementation-Version` attribute.
pub implementation_version: Option<String>,
}
/// Read and parse the `META-INF/MANIFEST.MF` from a JAR (ZIP) file.
///
/// Returns `Some(JarManifest)` when the file is a valid ZIP containing
/// `META-INF/MANIFEST.MF` with at least one recognized attribute.
/// Returns `None` if the file cannot be opened, is not a ZIP, does not
/// contain the manifest entry, or parsing fails entirely.
pub fn read_jar_manifest(path: &Path) -> Option<JarManifest> {
let file = std::fs::File::open(path).ok()?;
let mut archive = zip::ZipArchive::new(file).ok()?;
let mut entry = archive.by_name("META-INF/MANIFEST.MF").ok()?;
let mut content = String::new();
entry.read_to_string(&mut content).ok()?;
Some(parse_manifest(&content))
}
/// Read and parse `META-INF/MANIFEST.MF` from an already-opened ZIP archive.
///
/// Used by mod metadata extraction to resolve Gradle placeholders such as
/// `${file.jarVersion}` in Forge `mods.toml` files.
pub(crate) fn archive_manifest<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<JarManifest> {
let mut entry = archive.by_name("META-INF/MANIFEST.MF").ok()?;
let mut content = String::new();
entry.read_to_string(&mut content).ok()?;
Some(parse_manifest(&content))
}
/// Parse raw MANIFEST.MF text into a `JarManifest`.
///
/// Handles continuation lines (lines starting with a space) and
/// case-insensitive attribute names per the JAR specification.
fn parse_manifest(content: &str) -> JarManifest {
let mut manifest = JarManifest::default();
// First pass: normalize continuation lines.
// Lines starting with a space or tab are continuations of the previous line.
let mut lines: Vec<String> = Vec::new();
for line in content.lines() {
if line.is_empty() {
continue;
}
if line.starts_with(' ') || line.starts_with('\t') {
// Continuation line — append to the last line with a single space separator.
if let Some(last) = lines.last_mut() {
last.push(' ');
last.push_str(line.trim());
}
} else {
lines.push(line.to_string());
}
}
for line in &lines {
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
// Case-insensitive matching per JAR spec.
match key.to_ascii_lowercase().as_str() {
"main-class" => manifest.main_class = Some(value.to_string()),
"implementation-title" => {
manifest.implementation_title = Some(value.to_string());
}
"implementation-version" => {
manifest.implementation_version = Some(value.to_string());
}
_ => {}
}
}
}
manifest
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
/// Helper: create a minimal JAR (ZIP) with an optional MANIFEST.MF.
fn create_test_jar(
manifest_content: Option<&str>,
) -> (std::path::PathBuf, tempfile::TempDir) {
let dir = tempdir().expect("temp dir");
let jar_path = dir.path().join("test.jar");
let file = std::fs::File::create(&jar_path).expect("create jar");
let mut zip = zip::ZipWriter::new(file);
if let Some(content) = manifest_content {
zip.start_file(
"META-INF/MANIFEST.MF",
zip::write::FileOptions::<()>::default(),
)
.expect("start manifest entry");
zip.write_all(content.as_bytes()).expect("write manifest");
}
zip.finish().expect("finish zip");
(jar_path, dir)
}
#[test]
fn test_hmcl_manifest() {
let content =
"Manifest-Version: 1.0\nMain-Class: org.jackhuang.hmcl.Main\n";
let (path, _dir) = create_test_jar(Some(content));
let manifest = read_jar_manifest(&path).expect("should read manifest");
assert_eq!(
manifest.main_class,
Some("org.jackhuang.hmcl.Main".to_string())
);
}
#[test]
fn test_no_manifest() {
let (path, _dir) = create_test_jar(None);
let manifest = read_jar_manifest(&path);
assert!(manifest.is_none(), "no manifest should return None");
}
#[test]
fn test_non_jar_file() {
let dir = tempdir().expect("temp dir");
let not_a_jar = dir.path().join("not_a_jar.txt");
std::fs::write(&not_a_jar, "this is not a zip").expect("write file");
let manifest = read_jar_manifest(&not_a_jar);
assert!(manifest.is_none(), "non-zip should return None");
}
#[test]
fn test_nonexistent_file() {
let manifest =
read_jar_manifest(Path::new("/tmp/nonexistent_file_xyz.jar"));
assert!(manifest.is_none(), "nonexistent file should return None");
}
#[test]
fn test_continuation_lines() {
let content = "Manifest-Version: 1.0\nImplementation-Title: Hello\n World\nMain-Class: Test\n";
let (path, _dir) = create_test_jar(Some(content));
let manifest = read_jar_manifest(&path).expect("should read manifest");
assert_eq!(
manifest.implementation_title,
Some("Hello World".to_string())
);
assert_eq!(manifest.main_class, Some("Test".to_string()));
}
#[test]
fn test_case_insensitive_attributes() {
let content = "manifest-version: 1.0\nmain-class: com.example.Main\nimplementation-title: MyApp\n";
let (path, _dir) = create_test_jar(Some(content));
let manifest = read_jar_manifest(&path).expect("should read manifest");
assert_eq!(manifest.main_class, Some("com.example.Main".to_string()));
assert_eq!(manifest.implementation_title, Some("MyApp".to_string()));
}
#[test]
fn test_empty_jar() {
// A valid empty ZIP (no entries) should return None since there's no MANIFEST.MF.
let dir = tempdir().expect("temp dir");
let jar_path = dir.path().join("empty.jar");
let file = std::fs::File::create(&jar_path).expect("create file");
let zip = zip::ZipWriter::new(file);
zip.finish().expect("finish zip");
let manifest = read_jar_manifest(&jar_path);
assert!(manifest.is_none(), "empty jar should return None");
}
#[test]
fn test_all_fields_present() {
let content = "Manifest-Version: 1.0\nMain-Class: org.example.Main\nImplementation-Title: Example\nImplementation-Version: 1.2.3\n";
let (path, _dir) = create_test_jar(Some(content));
let manifest = read_jar_manifest(&path).expect("should read manifest");
assert_eq!(manifest.main_class, Some("org.example.Main".to_string()));
assert_eq!(manifest.implementation_title, Some("Example".to_string()));
assert_eq!(manifest.implementation_version, Some("1.2.3".to_string()));
}
#[test]
fn test_no_known_attributes() {
let content = "Manifest-Version: 1.0\nCreated-By: Someone\n";
let (path, _dir) = create_test_jar(Some(content));
let manifest = read_jar_manifest(&path).expect("should read manifest");
assert_eq!(manifest.main_class, None);
assert_eq!(manifest.implementation_title, None);
assert_eq!(manifest.implementation_version, None);
}
}

View File

@ -0,0 +1,17 @@
use serde::Deserialize;
/// Legacy Forge mcmod.info — JSON array of mod entries.
#[derive(Debug, Deserialize)]
pub(crate) struct McmodInfoEntry {
pub modid: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub version: Option<String>,
#[serde(default)]
pub authors: Option<Vec<String>>,
#[serde(rename = "logoFile")]
pub logo_file: Option<String>,
pub url: Option<String>,
/// Minecraft version targeted by this mod (single version, not a range).
pub mcversion: Option<String>,
}

View File

@ -0,0 +1,409 @@
//! 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}"));
}
}

View File

@ -0,0 +1,80 @@
use serde::Deserialize;
use std::collections::HashMap;
/// Forge / NeoForge mods.toml — `[[mods]]` array of tables plus root metadata.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModsToml {
/// Name of the mod loader (e.g. "javafml").
#[allow(dead_code)]
pub mod_loader: Option<String>,
/// Required loader version range (e.g. "[52,)"). For Forge this IS the Forge version.
pub loader_version: Option<String>,
#[serde(rename = "mods")]
pub mods: Option<Vec<ModsTomlEntry>>,
/// Dependencies keyed by modId: `[[dependencies.<modId>]]`.
pub dependencies: Option<HashMap<String, Vec<ForgeDependencyEntry>>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModsTomlEntry {
pub mod_id: Option<String>,
pub display_name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub authors: Option<String>,
pub logo_file: Option<String>,
#[serde(alias = "displayURL")]
pub display_url: Option<String>,
#[allow(dead_code)]
pub credits: Option<String>,
}
/// An entry in a Forge/NeoForge `[[dependencies.<modId>]]` array.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ForgeDependencyEntry {
pub mod_id: Option<String>,
pub mandatory: Option<bool>,
pub version_range: Option<String>,
#[allow(dead_code)]
pub ordering: Option<String>,
#[allow(dead_code)]
pub side: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_camel_case_mods_toml_fields() {
let toml = r#"
modLoader = "javafml"
loaderVersion = "[4,)"
[[mods]]
modId = "sodium"
displayName = "Sodium"
logoFile = "sodium-icon.png"
displayURL = "https://example.com"
[[dependencies.sodium]]
modId = "minecraft"
type = "required"
versionRange = "1.21.1"
"#;
let parsed: ModsToml = toml::from_str(toml).unwrap();
let entry = parsed.mods.unwrap().into_iter().next().unwrap();
assert_eq!(entry.mod_id.as_deref(), Some("sodium"));
assert_eq!(entry.display_name.as_deref(), Some("Sodium"));
assert_eq!(entry.logo_file.as_deref(), Some("sodium-icon.png"));
assert_eq!(entry.display_url.as_deref(), Some("https://example.com"));
let dependencies = parsed.dependencies.unwrap();
let minecraft = dependencies["sodium"].first().unwrap();
assert_eq!(minecraft.mod_id.as_deref(), Some("minecraft"));
assert_eq!(minecraft.version_range.as_deref(), Some("1.21.1"));
}
}