diff options
Diffstat (limited to 'src-tauri/src/core/modpack/formats')
| -rw-r--r-- | src-tauri/src/core/modpack/formats/curseforge.rs | 129 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/formats/mod.rs | 16 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/formats/modrinth.rs | 66 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/formats/multimc.rs | 83 |
4 files changed, 294 insertions, 0 deletions
diff --git a/src-tauri/src/core/modpack/formats/curseforge.rs b/src-tauri/src/core/modpack/formats/curseforge.rs new file mode 100644 index 0000000..c1e706a --- /dev/null +++ b/src-tauri/src/core/modpack/formats/curseforge.rs @@ -0,0 +1,129 @@ +use super::super::{ + archive::{Archive, read_json}, + types::{ModpackFile, ModpackInfo, ParsedModpack}, +}; +use serde::Deserialize; + +pub(crate) fn parse(archive: &mut Archive) -> Result<ParsedModpack, String> { + let manifest: CurseForgeManifest = serde_json::from_value(read_json(archive, "manifest.json")?) + .map_err(|e| format!("invalid curseforge manifest: {e}"))?; + if manifest.manifest_type.as_deref() != Some("minecraftModpack") { + return Err("not curseforge".into()); + } + + let (mod_loader, mod_loader_version) = manifest.primary_mod_loader(); + let files = manifest + .files + .into_iter() + .filter_map(CurseForgeManifestFile::into_modpack_file) + .collect(); + let overrides = manifest + .overrides + .unwrap_or_else(|| "overrides".to_string()); + + Ok(ParsedModpack { + info: ModpackInfo { + name: manifest + .name + .unwrap_or_else(|| "CurseForge Modpack".to_string()), + minecraft_version: manifest.minecraft.version, + mod_loader, + mod_loader_version, + modpack_type: "curseforge".into(), + instance_id: None, + }, + files, + override_prefixes: vec![format!("{overrides}/")], + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CurseForgeManifest { + manifest_type: Option<String>, + name: Option<String>, + overrides: Option<String>, + #[serde(default)] + minecraft: CurseForgeMinecraft, + #[serde(default)] + files: Vec<CurseForgeManifestFile>, +} + +impl CurseForgeManifest { + fn primary_mod_loader(&self) -> (Option<String>, Option<String>) { + self.minecraft + .mod_loaders + .iter() + .find(|item| item.primary) + .or_else(|| self.minecraft.mod_loaders.first()) + .and_then(|item| item.id.as_deref()?.split_once('-')) + .map(|(name, version)| (Some(name.to_string()), Some(version.to_string()))) + .unwrap_or((None, None)) + } +} + +#[derive(Debug, Default, Deserialize)] +struct CurseForgeMinecraft { + version: Option<String>, + #[serde(default, rename = "modLoaders")] + mod_loaders: Vec<CurseForgeModLoader>, +} + +#[derive(Debug, Deserialize)] +struct CurseForgeModLoader { + id: Option<String>, + #[serde(default)] + primary: bool, +} + +#[derive(Debug, Deserialize)] +struct CurseForgeManifestFile { + #[serde(rename = "projectID", alias = "projectId")] + project_id: Option<u64>, + #[serde(rename = "fileID", alias = "fileId")] + file_id: Option<u64>, +} + +impl CurseForgeManifestFile { + fn into_modpack_file(self) -> Option<ModpackFile> { + Some(ModpackFile { + url: format!("curseforge://{}:{}", self.project_id?, self.file_id?), + path: String::new(), + size: None, + sha1: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::CurseForgeManifestFile; + + #[test] + fn curseforge_manifest_file_deserializes_uppercase_id_fields() { + let file: CurseForgeManifestFile = serde_json::from_value(serde_json::json!({ + "projectID": 253735, + "fileID": 4683468 + })) + .expect("failed to deserialize CurseForge manifest file"); + + assert_eq!(file.project_id, Some(253735)); + assert_eq!(file.file_id, Some(4683468)); + assert_eq!( + file.into_modpack_file().map(|file| file.url), + Some("curseforge://253735:4683468".to_string()) + ); + } + + #[test] + fn curseforge_manifest_file_deserializes_camel_case_id_fields() { + let file: CurseForgeManifestFile = serde_json::from_value(serde_json::json!({ + "projectId": 253735, + "fileId": 4683468 + })) + .expect("failed to deserialize CurseForge manifest file"); + + assert_eq!(file.project_id, Some(253735)); + assert_eq!(file.file_id, Some(4683468)); + } +} diff --git a/src-tauri/src/core/modpack/formats/mod.rs b/src-tauri/src/core/modpack/formats/mod.rs new file mode 100644 index 0000000..d7fbb50 --- /dev/null +++ b/src-tauri/src/core/modpack/formats/mod.rs @@ -0,0 +1,16 @@ +mod curseforge; +mod modrinth; +mod multimc; + +use super::{archive::Archive, types::ParsedModpack}; + +type ParserFn = fn(&mut Archive) -> Result<ParsedModpack, String>; + +const PARSERS: [ParserFn; 3] = [modrinth::parse, curseforge::parse, multimc::parse]; + +pub(crate) fn parse(archive: &mut Archive) -> Result<ParsedModpack, String> { + PARSERS + .iter() + .find_map(|parser| parser(archive).ok()) + .ok_or_else(|| "unsupported modpack".to_string()) +} diff --git a/src-tauri/src/core/modpack/formats/modrinth.rs b/src-tauri/src/core/modpack/formats/modrinth.rs new file mode 100644 index 0000000..aa9ced6 --- /dev/null +++ b/src-tauri/src/core/modpack/formats/modrinth.rs @@ -0,0 +1,66 @@ +use super::super::{ + archive::{Archive, read_json}, + types::{ModpackFile, ModpackInfo, ParsedModpack}, +}; + +pub(crate) fn parse(archive: &mut Archive) -> Result<ParsedModpack, String> { + let json = read_json(archive, "modrinth.index.json")?; + let (mod_loader, mod_loader_version) = parse_loader(&json["dependencies"]); + + let files = json["files"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|file| { + if file["env"]["client"].as_str() == Some("unsupported") { + return None; + } + + let path = file["path"].as_str()?; + if path.contains("..") { + return None; + } + + Some(ModpackFile { + path: path.to_string(), + url: file["downloads"].as_array()?.first()?.as_str()?.to_string(), + size: file["fileSize"].as_u64(), + sha1: file["hashes"]["sha1"].as_str().map(String::from), + }) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ParsedModpack { + info: ModpackInfo { + name: json["name"].as_str().unwrap_or("Modrinth Modpack").into(), + minecraft_version: json["dependencies"]["minecraft"].as_str().map(Into::into), + mod_loader, + mod_loader_version, + modpack_type: "modrinth".into(), + instance_id: None, + }, + files, + override_prefixes: vec!["client-overrides/".into(), "overrides/".into()], + }) +} + +fn parse_loader(deps: &serde_json::Value) -> (Option<String>, Option<String>) { + const LOADERS: [(&str, &str); 5] = [ + ("fabric-loader", "fabric"), + ("forge", "forge"), + ("quilt-loader", "quilt"), + ("neoforge", "neoforge"), + ("neo-forge", "neoforge"), + ]; + + LOADERS + .iter() + .find_map(|(key, name)| { + let version = deps[*key].as_str()?; + Some((Some((*name).to_string()), Some(version.to_string()))) + }) + .unwrap_or((None, None)) +} diff --git a/src-tauri/src/core/modpack/formats/multimc.rs b/src-tauri/src/core/modpack/formats/multimc.rs new file mode 100644 index 0000000..25449ae --- /dev/null +++ b/src-tauri/src/core/modpack/formats/multimc.rs @@ -0,0 +1,83 @@ +use super::super::{ + archive::{Archive, read_entry, read_json}, + types::{ModpackInfo, ParsedModpack}, +}; + +pub(crate) fn parse(archive: &mut Archive) -> Result<ParsedModpack, String> { + let root = find_root(archive).ok_or("not multimc")?; + let cfg = read_entry(archive, &format!("{root}instance.cfg")).ok_or("not multimc")?; + let name = cfg_value(&cfg, "name").unwrap_or_else(|| "MultiMC Modpack".into()); + + let (minecraft_version, mod_loader, mod_loader_version) = + read_json(archive, &format!("{root}mmc-pack.json")) + .map(|json| parse_components(&json)) + .unwrap_or_default(); + let minecraft_version = minecraft_version.or_else(|| cfg_value(&cfg, "IntendedVersion")); + + Ok(ParsedModpack { + info: ModpackInfo { + name, + minecraft_version, + mod_loader, + mod_loader_version, + modpack_type: "multimc".into(), + instance_id: None, + }, + files: Vec::new(), + override_prefixes: vec![format!("{root}.minecraft/"), format!("{root}minecraft/")], + }) +} + +fn cfg_value(content: &str, key: &str) -> Option<String> { + let prefix = format!("{key}="); + content + .lines() + .find_map(|line| Some(line.strip_prefix(&prefix)?.trim().to_string())) +} + +fn find_root(archive: &mut Archive) -> Option<String> { + for index in 0..archive.len() { + let name = archive.by_index_raw(index).ok()?.name().to_string(); + if name == "instance.cfg" { + return Some(String::new()); + } + if name.ends_with("/instance.cfg") && name.matches('/').count() == 1 { + return Some(name.strip_suffix("instance.cfg")?.to_string()); + } + } + None +} + +fn parse_components(json: &serde_json::Value) -> (Option<String>, Option<String>, Option<String>) { + let (mut minecraft_version, mut mod_loader, mut mod_loader_version) = (None, None, None); + + for component in json["components"].as_array().into_iter().flatten() { + let version = component["version"].as_str().map(String::from); + match component["uid"].as_str().unwrap_or("") { + "net.minecraft" => minecraft_version = version, + "net.minecraftforge" => { + mod_loader = Some("forge".into()); + mod_loader_version = version; + } + "net.neoforged" => { + mod_loader = Some("neoforge".into()); + mod_loader_version = version; + } + "net.fabricmc.fabric-loader" => { + mod_loader = Some("fabric".into()); + mod_loader_version = version; + } + "org.quiltmc.quilt-loader" => { + mod_loader = Some("quilt".into()); + mod_loader_version = version; + } + "com.mumfrey.liteloader" => { + mod_loader = Some("liteloader".into()); + mod_loader_version = version; + } + _ => {} + } + } + + (minecraft_version, mod_loader, mod_loader_version) +} |