feat:移除了弹窗,服务器添加sls
This commit is contained in:
409
packages/app-lib/src/mod_metadata/mod.rs
Normal file
409
packages/app-lib/src/mod_metadata/mod.rs
Normal 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}"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user