aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src/core/modpack/formats
diff options
context:
space:
mode:
Diffstat (limited to 'src-tauri/src/core/modpack/formats')
-rw-r--r--src-tauri/src/core/modpack/formats/curseforge.rs129
-rw-r--r--src-tauri/src/core/modpack/formats/mod.rs16
-rw-r--r--src-tauri/src/core/modpack/formats/modrinth.rs66
-rw-r--r--src-tauri/src/core/modpack/formats/multimc.rs83
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)
+}