feat:移除了弹窗,服务器添加sls
This commit is contained in:
255
packages/app-lib/src/mod_metadata/fabric.rs
Normal file
255
packages/app-lib/src/mod_metadata/fabric.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
76
packages/app-lib/src/mod_metadata/icon.rs
Normal file
76
packages/app-lib/src/mod_metadata/icon.rs
Normal 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])
|
||||
}
|
||||
222
packages/app-lib/src/mod_metadata/manifest.rs
Normal file
222
packages/app-lib/src/mod_metadata/manifest.rs
Normal 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(¬_a_jar, "this is not a zip").expect("write file");
|
||||
|
||||
let manifest = read_jar_manifest(¬_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);
|
||||
}
|
||||
}
|
||||
17
packages/app-lib/src/mod_metadata/mcmod_info.rs
Normal file
17
packages/app-lib/src/mod_metadata/mcmod_info.rs
Normal 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>,
|
||||
}
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
80
packages/app-lib/src/mod_metadata/toml_mod.rs
Normal file
80
packages/app-lib/src/mod_metadata/toml_mod.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user