aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src-tauri/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'src-tauri/src/core')
-rw-r--r--src-tauri/src/core/modpack.rs492
-rw-r--r--src-tauri/src/core/modpack/api.rs287
-rw-r--r--src-tauri/src/core/modpack/archive.rs25
-rw-r--r--src-tauri/src/core/modpack/curseforge.rs436
-rw-r--r--src-tauri/src/core/modpack/extractor.rs96
-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
-rw-r--r--src-tauri/src/core/modpack/mod.rs11
-rw-r--r--src-tauri/src/core/modpack/parser.rs23
-rw-r--r--src-tauri/src/core/modpack/resolver.rs177
-rw-r--r--src-tauri/src/core/modpack/types.rs50
13 files changed, 1399 insertions, 492 deletions
diff --git a/src-tauri/src/core/modpack.rs b/src-tauri/src/core/modpack.rs
deleted file mode 100644
index a580000..0000000
--- a/src-tauri/src/core/modpack.rs
+++ /dev/null
@@ -1,492 +0,0 @@
-//! Modpack parsing and extraction module.
-//!
-//! Supported formats:
-//! - Modrinth (.mrpack / zip with `modrinth.index.json`)
-//! - CurseForge (zip with `manifest.json`, manifestType = "minecraftModpack")
-//! - MultiMC / PrismLauncher (zip with `instance.cfg`)
-//!
-//! ## Usage
-//!
-//! ```ignore
-//! // 1. Parse modpack → get metadata + file list + override prefixes
-//! let pack = modpack::import(&path).await?;
-//!
-//! // 2. These can run in parallel for Modrinth/CurseForge:
-//! // a) Extract override files (configs, resource packs, etc.)
-//! modpack::extract_overrides(&path, &game_dir, &pack.override_prefixes, |cur, total, name| {
-//! println!("Extracting ({cur}/{total}) {name}");
-//! })?;
-//! // b) Install Minecraft version — use pack.info.minecraft_version (e.g. "1.20.1")
-//! // → Fetch version manifest, download client jar, assets, libraries.
-//! // c) Install mod loader — use pack.info.mod_loader + mod_loader_version
-//! // → Download loader installer/profile, patch version JSON.
-//!
-//! // 3. Download mod files (use pack.files)
-//! // Each ModpackFile has url, path (relative to game_dir), sha1, size.
-//! // Partial failure is acceptable — missing mods can be retried on next launch.
-//! ```
-
-use serde::{Deserialize, Serialize};
-use std::collections::HashMap;
-use std::fs;
-use std::io::Read;
-use std::path::Path;
-
-type Archive = zip::ZipArchive<fs::File>;
-
-// ── Public types ──────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ModpackInfo {
- pub name: String,
- pub minecraft_version: Option<String>,
- pub mod_loader: Option<String>,
- pub mod_loader_version: Option<String>,
- pub modpack_type: String,
- #[serde(default)]
- pub instance_id: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ModpackFile {
- pub url: String,
- pub path: String,
- pub size: Option<u64>,
- pub sha1: Option<String>,
-}
-
-/// Unified parse result from any modpack format.
-pub struct ParsedModpack {
- pub info: ModpackInfo,
- pub files: Vec<ModpackFile>,
- pub override_prefixes: Vec<String>,
-}
-
-// ── Public API ────────────────────────────────────────────────────────────
-
-/// Parse a modpack zip and return metadata only (no network, no side effects).
-pub fn detect(path: &Path) -> Result<ModpackInfo, String> {
- Ok(parse(path)?.info)
-}
-
-/// Parse a modpack zip, resolve download URLs, and return everything needed
-/// to complete the installation.
-pub async fn import(path: &Path) -> Result<ParsedModpack, String> {
- let mut result = parse(path)?;
- if result.info.modpack_type == "curseforge" {
- result.files = resolve_curseforge_files(&result.files).await?;
- }
- Ok(result)
-}
-
-/// Extract override files from the modpack zip into the game directory.
-pub fn extract_overrides(
- path: &Path,
- game_dir: &Path,
- override_prefixes: &[String],
- on_progress: impl Fn(usize, usize, &str),
-) -> Result<(), String> {
- let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?;
- let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?;
-
- // Collect which prefixes actually exist
- let all_names: Vec<String> = (0..archive.len())
- .filter_map(|i| Some(archive.by_index_raw(i).ok()?.name().to_string()))
- .collect();
- let prefixes: Vec<&str> = override_prefixes
- .iter()
- .filter(|pfx| all_names.iter().any(|n| n.starts_with(pfx.as_str())))
- .map(|s| s.as_str())
- .collect();
-
- let strip = |name: &str| -> Option<String> {
- prefixes.iter().find_map(|pfx| {
- let rel = name.strip_prefix(*pfx)?;
- (!rel.is_empty()).then(|| rel.to_string())
- })
- };
-
- let total = all_names.iter().filter(|n| strip(n).is_some()).count();
- let mut current = 0;
-
- for i in 0..archive.len() {
- let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
- let name = entry.name().to_string();
- let Some(relative) = strip(&name) else {
- continue;
- };
-
- let outpath = game_dir.join(&relative);
- if !outpath.starts_with(game_dir) {
- continue;
- } // path traversal guard
-
- if entry.is_dir() {
- fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
- } else {
- if let Some(p) = outpath.parent() {
- fs::create_dir_all(p).map_err(|e| e.to_string())?;
- }
- let mut f = fs::File::create(&outpath).map_err(|e| e.to_string())?;
- std::io::copy(&mut entry, &mut f).map_err(|e| e.to_string())?;
- }
- current += 1;
- on_progress(current, total, &relative);
- }
- Ok(())
-}
-
-// ── Core parse dispatch ───────────────────────────────────────────────────
-
-type ParserFn = fn(&mut Archive) -> Result<ParsedModpack, String>;
-
-const PARSERS: &[ParserFn] = &[parse_modrinth, parse_curseforge, parse_multimc];
-
-fn parse(path: &Path) -> Result<ParsedModpack, String> {
- let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?;
- let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?;
-
- for parser in PARSERS {
- if let Ok(result) = parser(&mut archive) {
- return Ok(result);
- }
- }
- Ok(ParsedModpack {
- info: ModpackInfo {
- name: path
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or("Imported Modpack")
- .to_string(),
- minecraft_version: None,
- mod_loader: None,
- mod_loader_version: None,
- modpack_type: "unknown".into(),
- instance_id: None,
- },
- files: vec![],
- override_prefixes: vec![],
- })
-}
-
-// ── Format parsers ────────────────────────────────────────────────────────
-
-fn parse_modrinth(archive: &mut Archive) -> Result<ParsedModpack, String> {
- let json = read_json(archive, "modrinth.index.json")?;
- let (mod_loader, mod_loader_version) = parse_modrinth_loader(&json["dependencies"]);
-
- let files = json["files"]
- .as_array()
- .map(|arr| {
- arr.iter()
- .filter_map(|f| {
- if f["env"]["client"].as_str() == Some("unsupported") {
- return None;
- }
- let path = f["path"].as_str()?;
- if path.contains("..") {
- return None;
- }
- Some(ModpackFile {
- path: path.to_string(),
- url: f["downloads"].as_array()?.first()?.as_str()?.to_string(),
- size: f["fileSize"].as_u64(),
- sha1: f["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_curseforge(archive: &mut Archive) -> Result<ParsedModpack, String> {
- let json = read_json(archive, "manifest.json")?;
- if json["manifestType"].as_str() != Some("minecraftModpack") {
- return Err("not curseforge".into());
- }
-
- let (loader, loader_ver) = json["minecraft"]["modLoaders"]
- .as_array()
- .and_then(|arr| {
- arr.iter()
- .find(|ml| ml["primary"].as_bool() == Some(true))
- .or_else(|| arr.first())
- })
- .and_then(|ml| {
- let (l, v) = ml["id"].as_str()?.split_once('-')?;
- Some((Some(l.to_string()), Some(v.to_string())))
- })
- .unwrap_or((None, None));
-
- let files = json["files"]
- .as_array()
- .map(|arr| {
- arr.iter()
- .filter_map(|f| {
- Some(ModpackFile {
- url: format!(
- "curseforge://{}:{}",
- f["projectID"].as_u64()?,
- f["fileID"].as_u64()?
- ),
- path: String::new(),
- size: None,
- sha1: None,
- })
- })
- .collect()
- })
- .unwrap_or_default();
-
- let overrides = json["overrides"].as_str().unwrap_or("overrides");
-
- Ok(ParsedModpack {
- info: ModpackInfo {
- name: json["name"].as_str().unwrap_or("CurseForge Modpack").into(),
- minecraft_version: json["minecraft"]["version"].as_str().map(Into::into),
- mod_loader: loader,
- mod_loader_version: loader_ver,
- modpack_type: "curseforge".into(),
- instance_id: None,
- },
- files,
- override_prefixes: vec![format!("{overrides}/")],
- })
-}
-
-fn parse_multimc(archive: &mut Archive) -> Result<ParsedModpack, String> {
- let root = find_multimc_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 (mc, loader, loader_ver) = read_json(archive, &format!("{root}mmc-pack.json"))
- .map(|j| parse_mmc_components(&j))
- .unwrap_or_default();
- let mc = mc.or_else(|| cfg_value(&cfg, "IntendedVersion"));
-
- Ok(ParsedModpack {
- info: ModpackInfo {
- name,
- minecraft_version: mc,
- mod_loader: loader,
- mod_loader_version: loader_ver,
- modpack_type: "multimc".into(),
- instance_id: None,
- },
- files: vec![],
- override_prefixes: vec![format!("{root}.minecraft/"), format!("{root}minecraft/")],
- })
-}
-
-// ── CurseForge API resolution ─────────────────────────────────────────────
-
-const CURSEFORGE_API_KEY: Option<&str> = option_env!("CURSEFORGE_API_KEY");
-
-async fn resolve_curseforge_files(files: &[ModpackFile]) -> Result<Vec<ModpackFile>, String> {
- let file_ids: Vec<u64> = files
- .iter()
- .filter_map(|f| {
- f.url
- .strip_prefix("curseforge://")?
- .split(':')
- .nth(1)?
- .parse()
- .ok()
- })
- .collect();
- if file_ids.is_empty() {
- return Ok(vec![]);
- }
-
- let client = reqwest::Client::new();
-
- // 1. Batch-resolve file metadata
- let body = cf_post(
- &client,
- "/v1/mods/files",
- &serde_json::json!({ "fileIds": file_ids }),
- )
- .await?;
- let file_arr = body["data"].as_array().cloned().unwrap_or_default();
-
- // 2. Batch-resolve mod classIds for directory placement
- let mod_ids: Vec<u64> = file_arr
- .iter()
- .filter_map(|f| f["modId"].as_u64())
- .collect::<std::collections::HashSet<_>>()
- .into_iter()
- .collect();
- let class_map = cf_class_ids(&client, &mod_ids).await;
-
- // 3. Build results
- Ok(file_arr
- .iter()
- .filter_map(|f| {
- let name = f["fileName"].as_str()?;
- let id = f["id"].as_u64()?;
- let url = f["downloadUrl"]
- .as_str()
- .map(String::from)
- .unwrap_or_else(|| {
- format!(
- "https://edge.forgecdn.net/files/{}/{}/{name}",
- id / 1000,
- id % 1000
- )
- });
- let dir = match f["modId"].as_u64().and_then(|mid| class_map.get(&mid)) {
- Some(12) => "resourcepacks",
- Some(6552) => "shaderpacks",
- _ => "mods",
- };
- Some(ModpackFile {
- url,
- path: format!("{dir}/{name}"),
- size: f["fileLength"].as_u64(),
- sha1: None,
- })
- })
- .collect())
-}
-
-async fn cf_post(
- client: &reqwest::Client,
- endpoint: &str,
- body: &serde_json::Value,
-) -> Result<serde_json::Value, String> {
- let api_key = CURSEFORGE_API_KEY
- .ok_or("CurseForge modpack support requires CURSEFORGE_API_KEY set at build time")?;
-
- let resp = client
- .post(format!("https://api.curseforge.com{endpoint}"))
- .header("x-api-key", api_key)
- .json(body)
- .send()
- .await
- .map_err(|e| format!("CurseForge API error: {e}"))?;
- if !resp.status().is_success() {
- return Err(format!("CurseForge API returned {}", resp.status()));
- }
- resp.json().await.map_err(|e| e.to_string())
-}
-
-async fn cf_class_ids(client: &reqwest::Client, mod_ids: &[u64]) -> HashMap<u64, u64> {
- if mod_ids.is_empty() {
- return Default::default();
- }
- let Ok(body) = cf_post(
- client,
- "/v1/mods",
- &serde_json::json!({ "modIds": mod_ids }),
- )
- .await
- else {
- return Default::default();
- };
- body["data"]
- .as_array()
- .map(|arr| {
- arr.iter()
- .filter_map(|m| Some((m["id"].as_u64()?, m["classId"].as_u64()?)))
- .collect()
- })
- .unwrap_or_default()
-}
-
-// ── Helpers ───────────────────────────────────────────────────────────────
-
-fn read_entry(archive: &mut Archive, name: &str) -> Option<String> {
- let mut buf = String::new();
- archive.by_name(name).ok()?.read_to_string(&mut buf).ok()?;
- Some(buf)
-}
-
-fn read_json(archive: &mut Archive, name: &str) -> Result<serde_json::Value, String> {
- let content = read_entry(archive, name).ok_or_else(|| format!("{name} not found"))?;
- serde_json::from_str(&content).map_err(|e| e.to_string())
-}
-
-fn cfg_value(content: &str, key: &str) -> Option<String> {
- let prefix = format!("{key}=");
- content
- .lines()
- .find_map(|l| Some(l.strip_prefix(&prefix)?.trim().to_string()))
-}
-
-fn find_multimc_root(archive: &mut Archive) -> Option<String> {
- for i in 0..archive.len() {
- let name = archive.by_index_raw(i).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_modrinth_loader(deps: &serde_json::Value) -> (Option<String>, Option<String>) {
- const LOADERS: &[(&str, &str)] = &[
- ("fabric-loader", "fabric"),
- ("forge", "forge"),
- ("quilt-loader", "quilt"),
- ("neoforge", "neoforge"),
- ("neo-forge", "neoforge"),
- ];
- LOADERS
- .iter()
- .find_map(|(key, name)| {
- let v = deps[*key].as_str()?;
- Some((Some((*name).into()), Some(v.into())))
- })
- .unwrap_or((None, None))
-}
-
-fn parse_mmc_components(
- json: &serde_json::Value,
-) -> (Option<String>, Option<String>, Option<String>) {
- let (mut mc, mut loader, mut loader_ver) = (None, None, None);
- for c in json["components"].as_array().into_iter().flatten() {
- let ver = c["version"].as_str().map(String::from);
- match c["uid"].as_str().unwrap_or("") {
- "net.minecraft" => mc = ver,
- "net.minecraftforge" => {
- loader = Some("forge".into());
- loader_ver = ver;
- }
- "net.neoforged" => {
- loader = Some("neoforge".into());
- loader_ver = ver;
- }
- "net.fabricmc.fabric-loader" => {
- loader = Some("fabric".into());
- loader_ver = ver;
- }
- "org.quiltmc.quilt-loader" => {
- loader = Some("quilt".into());
- loader_ver = ver;
- }
- "com.mumfrey.liteloader" => {
- loader = Some("liteloader".into());
- loader_ver = ver;
- }
- _ => {}
- }
- }
- (mc, loader, loader_ver)
-}
diff --git a/src-tauri/src/core/modpack/api.rs b/src-tauri/src/core/modpack/api.rs
new file mode 100644
index 0000000..44db674
--- /dev/null
+++ b/src-tauri/src/core/modpack/api.rs
@@ -0,0 +1,287 @@
+use std::path::Path;
+
+use super::{
+ extractor::{OverrideExtractor, ProgressReporter, ZipOverrideExtractor},
+ parser::{ModpackParser, ZipModpackParser},
+ resolver::{ModpackFileResolver, ResolverChain},
+};
+
+#[allow(unused_imports)]
+pub use super::types::{ModpackFile, ModpackInfo, ParsedModpack};
+
+pub struct ModpackApi {
+ parser: Box<dyn ModpackParser>,
+ resolver: Box<dyn ModpackFileResolver>,
+ extractor: Box<dyn OverrideExtractor>,
+}
+
+impl ModpackApi {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub(crate) fn with_components<P, R, E>(parser: P, resolver: R, extractor: E) -> Self
+ where
+ P: ModpackParser + 'static,
+ R: ModpackFileResolver + 'static,
+ E: OverrideExtractor + 'static,
+ {
+ Self {
+ parser: Box::new(parser),
+ resolver: Box::new(resolver),
+ extractor: Box::new(extractor),
+ }
+ }
+
+ pub fn detect(&self, path: &Path) -> Result<ModpackInfo, String> {
+ self.parser.parse(path).map(|modpack| modpack.info)
+ }
+
+ pub async fn import(&self, path: &Path) -> Result<ParsedModpack, String> {
+ let modpack = self.parser.parse(path)?;
+ self.resolver.resolve(modpack).await
+ }
+
+ pub fn extract_overrides<F>(
+ &self,
+ path: &Path,
+ game_dir: &Path,
+ override_prefixes: &[String],
+ on_progress: F,
+ ) -> Result<(), String>
+ where
+ F: FnMut(usize, usize, &str),
+ {
+ let mut reporter = on_progress;
+ self.extract_overrides_with_reporter(path, game_dir, override_prefixes, &mut reporter)
+ }
+
+ fn extract_overrides_with_reporter(
+ &self,
+ path: &Path,
+ game_dir: &Path,
+ override_prefixes: &[String],
+ reporter: &mut dyn ProgressReporter,
+ ) -> Result<(), String> {
+ self.extractor
+ .extract(path, game_dir, override_prefixes, reporter)
+ }
+}
+
+impl Default for ModpackApi {
+ fn default() -> Self {
+ Self::with_components(
+ ZipModpackParser::default(),
+ ResolverChain::default(),
+ ZipOverrideExtractor::default(),
+ )
+ }
+}
+
+pub fn detect(path: &Path) -> Result<ModpackInfo, String> {
+ ModpackApi::default().detect(path)
+}
+
+pub async fn import(path: &Path) -> Result<ParsedModpack, String> {
+ ModpackApi::default().import(path).await
+}
+
+pub fn extract_overrides<F>(
+ path: &Path,
+ game_dir: &Path,
+ override_prefixes: &[String],
+ on_progress: F,
+) -> Result<(), String>
+where
+ F: FnMut(usize, usize, &str),
+{
+ ModpackApi::default().extract_overrides(path, game_dir, override_prefixes, on_progress)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::{
+ fs,
+ io::Write,
+ path::{Path, PathBuf},
+ };
+ use uuid::Uuid;
+ use zip::write::SimpleFileOptions;
+
+ struct TestWorkspace {
+ root: PathBuf,
+ archive: PathBuf,
+ game_dir: PathBuf,
+ }
+
+ impl TestWorkspace {
+ fn new() -> Result<Self, String> {
+ let root = std::env::temp_dir().join(format!("dropout-modpack-api-{}", Uuid::new_v4()));
+ let archive = root.join("demo.mrpack");
+ let game_dir = root.join("game");
+
+ fs::create_dir_all(&game_dir).map_err(|e| e.to_string())?;
+ write_modrinth_pack(&archive)?;
+
+ Ok(Self {
+ root,
+ archive,
+ game_dir,
+ })
+ }
+
+ fn for_archive(archive: impl Into<PathBuf>) -> Result<Self, String> {
+ let root =
+ std::env::temp_dir().join(format!("dropout-modpack-manual-{}", Uuid::new_v4()));
+ let archive = archive.into();
+ let game_dir = root.join("game");
+
+ fs::create_dir_all(&game_dir).map_err(|e| e.to_string())?;
+
+ Ok(Self {
+ root,
+ archive,
+ game_dir,
+ })
+ }
+ }
+
+ impl Drop for TestWorkspace {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.root);
+ }
+ }
+
+ fn write_modrinth_pack(path: &Path) -> Result<(), String> {
+ let file = fs::File::create(path).map_err(|e| e.to_string())?;
+ let mut writer = zip::ZipWriter::new(file);
+ let options = SimpleFileOptions::default()
+ .compression_method(zip::CompressionMethod::Stored)
+ .unix_permissions(0o644);
+ let index = serde_json::json!({
+ "name": "Demo Pack",
+ "dependencies": {
+ "minecraft": "1.20.1",
+ "fabric-loader": "0.15.11"
+ },
+ "files": [
+ {
+ "path": "mods/demo.jar",
+ "downloads": ["https://example.com/demo.jar"],
+ "fileSize": 42,
+ "hashes": {
+ "sha1": "abc123"
+ }
+ }
+ ]
+ });
+
+ writer
+ .start_file("modrinth.index.json", options)
+ .map_err(|e| e.to_string())?;
+ writer
+ .write_all(index.to_string().as_bytes())
+ .map_err(|e| e.to_string())?;
+ writer
+ .start_file("overrides/config/demo.txt", options)
+ .map_err(|e| e.to_string())?;
+ writer
+ .write_all(b"demo-config")
+ .map_err(|e| e.to_string())?;
+ writer.finish().map_err(|e| e.to_string())?;
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn modpack_api_imports_and_extracts_modrinth_pack() {
+ let workspace = TestWorkspace::new().unwrap();
+ let api = ModpackApi::new();
+
+ let detected = api.detect(&workspace.archive).unwrap();
+ assert_eq!(detected.name, "Demo Pack");
+ assert_eq!(detected.minecraft_version.as_deref(), Some("1.20.1"));
+ assert_eq!(detected.mod_loader.as_deref(), Some("fabric"));
+ assert_eq!(detected.mod_loader_version.as_deref(), Some("0.15.11"));
+ assert_eq!(detected.modpack_type, "modrinth");
+
+ let imported = api.import(&workspace.archive).await.unwrap();
+ assert_eq!(imported.info.name, "Demo Pack");
+ assert_eq!(imported.files.len(), 1);
+ assert_eq!(imported.files[0].path, "mods/demo.jar");
+ assert_eq!(imported.files[0].url, "https://example.com/demo.jar");
+ assert_eq!(imported.files[0].size, Some(42));
+ assert_eq!(imported.files[0].sha1.as_deref(), Some("abc123"));
+ assert_eq!(
+ imported.override_prefixes,
+ vec!["client-overrides/".to_string(), "overrides/".to_string()]
+ );
+
+ let mut progress = Vec::new();
+ api.extract_overrides(
+ &workspace.archive,
+ &workspace.game_dir,
+ &imported.override_prefixes,
+ |current, total, name| progress.push((current, total, name.to_string())),
+ )
+ .unwrap();
+
+ assert_eq!(
+ fs::read_to_string(workspace.game_dir.join("config/demo.txt")).unwrap(),
+ "demo-config"
+ );
+ assert_eq!(progress, vec![(1, 1, "config/demo.txt".to_string())]);
+ }
+
+ #[tokio::test]
+ #[ignore = "requires DROPOUT_MODPACK_TEST_PATH"]
+ async fn modpack_api_imports_external_pack_from_env() {
+ let archive =
+ std::env::var("DROPOUT_MODPACK_TEST_PATH").expect("missing DROPOUT_MODPACK_TEST_PATH");
+ let workspace = TestWorkspace::for_archive(archive).unwrap();
+ let api = ModpackApi::new();
+
+ assert!(workspace.archive.is_file(), "archive path is not a file");
+
+ let detected = api.detect(&workspace.archive).unwrap();
+ assert_ne!(detected.modpack_type, "unknown");
+ assert!(!detected.name.trim().is_empty());
+
+ let imported = match api.import(&workspace.archive).await {
+ Ok(imported) => imported,
+ Err(error)
+ if detected.modpack_type == "curseforge"
+ && error.contains("CURSEFORGE_API_KEY") =>
+ {
+ return;
+ }
+ Err(error) => panic!("failed to import modpack: {error}"),
+ };
+
+ assert_eq!(imported.info.modpack_type, detected.modpack_type);
+ assert!(!imported.info.name.trim().is_empty());
+
+ let mut progress_samples = Vec::new();
+ let mut last_progress = None;
+ api.extract_overrides(
+ &workspace.archive,
+ &workspace.game_dir,
+ &imported.override_prefixes,
+ |current, total, name| {
+ last_progress = Some((current, total));
+ if progress_samples.len() < 32 {
+ progress_samples.push((current, total, name.to_string()));
+ }
+ },
+ )
+ .unwrap();
+
+ if let Some((current, total)) = last_progress {
+ assert_eq!(
+ current, total,
+ "override extraction did not finish, samples: {progress_samples:?}"
+ );
+ }
+ }
+}
diff --git a/src-tauri/src/core/modpack/archive.rs b/src-tauri/src/core/modpack/archive.rs
new file mode 100644
index 0000000..89aaef0
--- /dev/null
+++ b/src-tauri/src/core/modpack/archive.rs
@@ -0,0 +1,25 @@
+use std::{fs, io::Read, path::Path};
+
+pub(crate) type Archive = zip::ZipArchive<fs::File>;
+
+pub(crate) fn open(path: &Path) -> Result<Archive, String> {
+ let file = fs::File::open(path).map_err(|e| format!("Failed to open: {e}"))?;
+ zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))
+}
+
+pub(crate) fn read_entry(archive: &mut Archive, name: &str) -> Option<String> {
+ let mut buf = String::new();
+ archive.by_name(name).ok()?.read_to_string(&mut buf).ok()?;
+ Some(buf)
+}
+
+pub(crate) fn read_json(archive: &mut Archive, name: &str) -> Result<serde_json::Value, String> {
+ let content = read_entry(archive, name).ok_or_else(|| format!("{name} not found"))?;
+ serde_json::from_str(&content).map_err(|e| e.to_string())
+}
+
+pub(crate) fn list_names(archive: &mut Archive) -> Vec<String> {
+ (0..archive.len())
+ .filter_map(|index| Some(archive.by_index_raw(index).ok()?.name().to_string()))
+ .collect()
+}
diff --git a/src-tauri/src/core/modpack/curseforge.rs b/src-tauri/src/core/modpack/curseforge.rs
new file mode 100644
index 0000000..44a03da
--- /dev/null
+++ b/src-tauri/src/core/modpack/curseforge.rs
@@ -0,0 +1,436 @@
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de, de::DeserializeOwned};
+
+const CURSEFORGE_API_BASE_URL: &str = "https://api.curseforge.com";
+const CURSEFORGE_API_KEY: Option<&str> = option_env!("CURSEFORGE_API_KEY");
+
+macro_rules! curseforge_int_enum {
+ (
+ $vis:vis enum $name:ident : $repr:ty {
+ $($variant:ident = $value:expr),+ $(,)?
+ }
+ ) => {
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+ #[repr($repr)]
+ $vis enum $name {
+ $($variant = $value),+
+ }
+
+ impl TryFrom<$repr> for $name {
+ type Error = $repr;
+
+ fn try_from(value: $repr) -> Result<Self, Self::Error> {
+ match value {
+ $($value => Ok(Self::$variant),)+
+ _ => Err(value),
+ }
+ }
+ }
+
+ impl Serialize for $name {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ (*self as $repr).serialize(serializer)
+ }
+ }
+
+ impl<'de> Deserialize<'de> for $name {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = <$repr>::deserialize(deserializer)?;
+ Self::try_from(value).map_err(|value| {
+ de::Error::custom(format!("invalid {} value: {value}", stringify!($name)))
+ })
+ }
+ }
+ };
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct CurseForgeApi {
+ client: reqwest::Client,
+}
+
+impl CurseForgeApi {
+ pub(crate) fn new(client: reqwest::Client) -> Self {
+ Self { client }
+ }
+
+ pub(crate) async fn get_files(
+ &self,
+ request: &CurseForgeGetModFilesRequestBody,
+ ) -> Result<CurseForgeGetFilesResponse, String> {
+ if request.file_ids.is_empty() {
+ return Ok(CurseForgeGetFilesResponse::default());
+ }
+
+ self.post("/v1/mods/files", request).await
+ }
+
+ pub(crate) async fn get_mods(
+ &self,
+ request: &CurseForgeGetModsByIdsListRequestBody,
+ ) -> Result<CurseForgeGetModsResponse, String> {
+ if request.mod_ids.is_empty() {
+ return Ok(CurseForgeGetModsResponse::default());
+ }
+
+ self.post("/v1/mods", request).await
+ }
+
+ async fn post<TRequest, TResponse>(
+ &self,
+ endpoint: &str,
+ body: &TRequest,
+ ) -> Result<TResponse, String>
+ where
+ TRequest: Serialize + ?Sized,
+ TResponse: DeserializeOwned,
+ {
+ let api_key = CURSEFORGE_API_KEY
+ .ok_or("CurseForge modpack support requires CURSEFORGE_API_KEY set at build time")?;
+ let response = self
+ .client
+ .post(format!("{CURSEFORGE_API_BASE_URL}{endpoint}"))
+ .header("x-api-key", api_key)
+ .json(body)
+ .send()
+ .await
+ .map_err(|e| format!("CurseForge API error: {e}"))?;
+
+ if !response.status().is_success() {
+ return Err(format!("CurseForge API returned {}", response.status()));
+ }
+
+ response.json().await.map_err(|e| e.to_string())
+ }
+}
+
+impl Default for CurseForgeApi {
+ fn default() -> Self {
+ Self::new(reqwest::Client::new())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeGetModFilesRequestBody {
+ pub(crate) file_ids: Vec<u64>,
+}
+
+impl CurseForgeGetModFilesRequestBody {
+ pub(crate) fn new(file_ids: Vec<u64>) -> Self {
+ Self { file_ids }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeGetModsByIdsListRequestBody {
+ pub(crate) mod_ids: Vec<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub(crate) filter_pc_only: Option<bool>,
+}
+
+impl CurseForgeGetModsByIdsListRequestBody {
+ pub(crate) fn new(mod_ids: Vec<u64>) -> Self {
+ Self {
+ mod_ids,
+ filter_pc_only: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub(crate) struct CurseForgeGetFilesResponse {
+ #[serde(default)]
+ pub(crate) data: Vec<CurseForgeFile>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub(crate) struct CurseForgeGetModsResponse {
+ #[serde(default)]
+ pub(crate) data: Vec<CurseForgeMod>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeCategory {
+ pub(crate) id: u64,
+ pub(crate) game_id: u64,
+ pub(crate) name: String,
+ pub(crate) slug: String,
+ pub(crate) url: String,
+ pub(crate) icon_url: String,
+ pub(crate) date_modified: String,
+ pub(crate) is_class: Option<bool>,
+ pub(crate) class_id: Option<u64>,
+ pub(crate) parent_category_id: Option<u64>,
+ pub(crate) display_index: Option<u64>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeModLinks {
+ pub(crate) website_url: String,
+ pub(crate) wiki_url: String,
+ pub(crate) issues_url: String,
+ pub(crate) source_url: String,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeModAuthor {
+ pub(crate) id: u64,
+ pub(crate) name: String,
+ pub(crate) url: String,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeModAsset {
+ pub(crate) id: u64,
+ pub(crate) mod_id: u64,
+ pub(crate) title: String,
+ pub(crate) description: String,
+ pub(crate) thumbnail_url: String,
+ pub(crate) url: String,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeFileHash {
+ pub(crate) value: String,
+ pub(crate) algo: CurseForgeHashAlgo,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeSortableGameVersion {
+ pub(crate) game_version_name: String,
+ pub(crate) game_version_padded: String,
+ pub(crate) game_version: String,
+ pub(crate) game_version_release_date: String,
+ pub(crate) game_version_type_id: Option<u64>,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeFileDependency {
+ pub(crate) mod_id: u64,
+ pub(crate) relation_type: CurseForgeFileRelationType,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeFileModule {
+ pub(crate) name: String,
+ pub(crate) fingerprint: u64,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeFileIndex {
+ pub(crate) game_version: String,
+ pub(crate) file_id: u64,
+ pub(crate) filename: String,
+ pub(crate) release_type: CurseForgeFileReleaseType,
+ pub(crate) game_version_type_id: Option<u64>,
+ pub(crate) mod_loader: CurseForgeModLoaderType,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeFile {
+ pub(crate) id: u64,
+ pub(crate) game_id: u64,
+ pub(crate) mod_id: u64,
+ pub(crate) is_available: bool,
+ pub(crate) display_name: String,
+ pub(crate) file_name: String,
+ pub(crate) release_type: CurseForgeFileReleaseType,
+ pub(crate) file_status: CurseForgeFileStatus,
+ #[serde(default)]
+ pub(crate) hashes: Vec<CurseForgeFileHash>,
+ pub(crate) file_date: String,
+ pub(crate) file_length: u64,
+ pub(crate) download_count: u64,
+ pub(crate) file_size_on_disk: Option<u64>,
+ #[serde(default)]
+ pub(crate) download_url: Option<String>,
+ #[serde(default)]
+ pub(crate) game_versions: Vec<String>,
+ #[serde(default)]
+ pub(crate) sortable_game_versions: Vec<CurseForgeSortableGameVersion>,
+ #[serde(default)]
+ pub(crate) dependencies: Vec<CurseForgeFileDependency>,
+ pub(crate) expose_as_alternative: Option<bool>,
+ pub(crate) parent_project_file_id: Option<u64>,
+ pub(crate) alternate_file_id: Option<u64>,
+ pub(crate) is_server_pack: Option<bool>,
+ pub(crate) server_pack_file_id: Option<u64>,
+ pub(crate) is_early_access_content: Option<bool>,
+ pub(crate) early_access_end_date: Option<String>,
+ pub(crate) file_fingerprint: u64,
+ #[serde(default)]
+ pub(crate) modules: Vec<CurseForgeFileModule>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct CurseForgeMod {
+ pub(crate) id: u64,
+ pub(crate) game_id: u64,
+ pub(crate) name: String,
+ pub(crate) slug: String,
+ #[serde(default)]
+ pub(crate) links: CurseForgeModLinks,
+ pub(crate) summary: String,
+ pub(crate) status: CurseForgeModStatus,
+ pub(crate) download_count: u64,
+ pub(crate) is_featured: bool,
+ pub(crate) primary_category_id: u64,
+ #[serde(default)]
+ pub(crate) categories: Vec<CurseForgeCategory>,
+ pub(crate) class_id: Option<u64>,
+ #[serde(default)]
+ pub(crate) authors: Vec<CurseForgeModAuthor>,
+ pub(crate) logo: Option<CurseForgeModAsset>,
+ #[serde(default)]
+ pub(crate) screenshots: Vec<CurseForgeModAsset>,
+ pub(crate) main_file_id: u64,
+ #[serde(default)]
+ pub(crate) latest_files: Vec<CurseForgeFile>,
+ #[serde(default)]
+ pub(crate) latest_files_indexes: Vec<CurseForgeFileIndex>,
+ #[serde(default)]
+ pub(crate) latest_early_access_files_indexes: Vec<CurseForgeFileIndex>,
+ pub(crate) date_created: String,
+ pub(crate) date_modified: String,
+ pub(crate) date_released: String,
+ pub(crate) allow_mod_distribution: Option<bool>,
+ pub(crate) game_popularity_rank: u64,
+ pub(crate) is_available: bool,
+ pub(crate) thumbs_up_count: u64,
+ pub(crate) rating: Option<f64>,
+}
+
+curseforge_int_enum! {
+ pub(crate) enum CurseForgeHashAlgo: u8 {
+ Sha1 = 1,
+ Md5 = 2
+ }
+}
+
+impl Default for CurseForgeHashAlgo {
+ fn default() -> Self {
+ Self::Sha1
+ }
+}
+
+curseforge_int_enum! {
+ pub(crate) enum CurseForgeFileRelationType: u8 {
+ EmbeddedLibrary = 1,
+ OptionalDependency = 2,
+ RequiredDependency = 3,
+ Tool = 4,
+ Incompatible = 5,
+ Include = 6
+ }
+}
+
+impl Default for CurseForgeFileRelationType {
+ fn default() -> Self {
+ Self::RequiredDependency
+ }
+}
+
+curseforge_int_enum! {
+ pub(crate) enum CurseForgeFileReleaseType: u8 {
+ Release = 1,
+ Beta = 2,
+ Alpha = 3
+ }
+}
+
+impl Default for CurseForgeFileReleaseType {
+ fn default() -> Self {
+ Self::Release
+ }
+}
+
+curseforge_int_enum! {
+ pub(crate) enum CurseForgeFileStatus: u8 {
+ Processing = 1,
+ ChangesRequired = 2,
+ UnderReview = 3,
+ Approved = 4,
+ Rejected = 5,
+ MalwareDetected = 6,
+ Deleted = 7,
+ Archived = 8,
+ Testing = 9,
+ Released = 10,
+ ReadyForReview = 11,
+ Deprecated = 12,
+ Baking = 13,
+ AwaitingPublishing = 14,
+ FailedPublishing = 15,
+ Cooking = 16,
+ Cooked = 17,
+ UnderManualReview = 18,
+ ScanningForMalware = 19,
+ ProcessingFile = 20,
+ PendingRelease = 21,
+ ReadyForCooking = 22,
+ PostProcessing = 23
+ }
+}
+
+impl Default for CurseForgeFileStatus {
+ fn default() -> Self {
+ Self::Released
+ }
+}
+
+curseforge_int_enum! {
+ pub(crate) enum CurseForgeModLoaderType: u8 {
+ Any = 0,
+ Forge = 1,
+ Cauldron = 2,
+ LiteLoader = 3,
+ Fabric = 4,
+ Quilt = 5,
+ NeoForge = 6
+ }
+}
+
+impl Default for CurseForgeModLoaderType {
+ fn default() -> Self {
+ Self::Any
+ }
+}
+
+curseforge_int_enum! {
+ pub(crate) enum CurseForgeModStatus: u8 {
+ New = 1,
+ ChangesRequired = 2,
+ UnderSoftReview = 3,
+ Approved = 4,
+ Rejected = 5,
+ ChangesMade = 6,
+ Inactive = 7,
+ Abandoned = 8,
+ Deleted = 9,
+ UnderReview = 10
+ }
+}
+
+impl Default for CurseForgeModStatus {
+ fn default() -> Self {
+ Self::Approved
+ }
+}
diff --git a/src-tauri/src/core/modpack/extractor.rs b/src-tauri/src/core/modpack/extractor.rs
new file mode 100644
index 0000000..b8d4ff2
--- /dev/null
+++ b/src-tauri/src/core/modpack/extractor.rs
@@ -0,0 +1,96 @@
+use std::{fs, path::Path};
+
+use super::archive;
+
+pub(crate) trait ProgressReporter {
+ fn report(&mut self, current: usize, total: usize, name: &str);
+}
+
+impl<F> ProgressReporter for F
+where
+ F: FnMut(usize, usize, &str),
+{
+ fn report(&mut self, current: usize, total: usize, name: &str) {
+ self(current, total, name);
+ }
+}
+
+pub(crate) trait OverrideExtractor: Send + Sync {
+ fn extract(
+ &self,
+ path: &Path,
+ game_dir: &Path,
+ override_prefixes: &[String],
+ reporter: &mut dyn ProgressReporter,
+ ) -> Result<(), String>;
+}
+
+#[derive(Debug, Default, Clone, Copy)]
+pub(crate) struct ZipOverrideExtractor;
+
+impl ZipOverrideExtractor {
+ pub(crate) fn new() -> Self {
+ Self
+ }
+}
+
+impl OverrideExtractor for ZipOverrideExtractor {
+ fn extract(
+ &self,
+ path: &Path,
+ game_dir: &Path,
+ override_prefixes: &[String],
+ reporter: &mut dyn ProgressReporter,
+ ) -> Result<(), String> {
+ let mut archive = archive::open(path)?;
+ let all_names = archive::list_names(&mut archive);
+ let prefixes: Vec<&str> = override_prefixes
+ .iter()
+ .filter(|prefix| {
+ all_names
+ .iter()
+ .any(|name| name.starts_with(prefix.as_str()))
+ })
+ .map(String::as_str)
+ .collect();
+ let strip = |name: &str| -> Option<String> {
+ prefixes.iter().find_map(|prefix| {
+ let relative = name.strip_prefix(*prefix)?;
+ (!relative.is_empty()).then(|| relative.to_string())
+ })
+ };
+ let total = all_names
+ .iter()
+ .filter(|name| strip(name).is_some())
+ .count();
+ let mut current = 0;
+
+ for index in 0..archive.len() {
+ let mut entry = archive.by_index(index).map_err(|e| e.to_string())?;
+ let name = entry.name().to_string();
+ let Some(relative) = strip(&name) else {
+ continue;
+ };
+
+ let outpath = game_dir.join(&relative);
+ if !outpath.starts_with(game_dir) {
+ continue;
+ }
+
+ if entry.is_dir() {
+ fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
+ } else {
+ if let Some(parent) = outpath.parent() {
+ fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+ }
+ let mut file = fs::File::create(&outpath).map_err(|e| e.to_string())?;
+ std::io::copy(&mut entry, &mut file).map_err(|e| e.to_string())?;
+ }
+
+ current += 1;
+ reporter.report(current, total, &relative);
+ }
+
+ Ok(())
+ }
+}
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)
+}
diff --git a/src-tauri/src/core/modpack/mod.rs b/src-tauri/src/core/modpack/mod.rs
new file mode 100644
index 0000000..6e7f744
--- /dev/null
+++ b/src-tauri/src/core/modpack/mod.rs
@@ -0,0 +1,11 @@
+#![allow(dead_code)]
+
+pub mod api;
+pub(crate) mod curseforge;
+
+mod archive;
+mod extractor;
+mod formats;
+mod parser;
+mod resolver;
+mod types;
diff --git a/src-tauri/src/core/modpack/parser.rs b/src-tauri/src/core/modpack/parser.rs
new file mode 100644
index 0000000..397d696
--- /dev/null
+++ b/src-tauri/src/core/modpack/parser.rs
@@ -0,0 +1,23 @@
+use std::path::Path;
+
+use super::{archive, formats, types::ParsedModpack};
+
+pub(crate) trait ModpackParser: Send + Sync {
+ fn parse(&self, path: &Path) -> Result<ParsedModpack, String>;
+}
+
+#[derive(Debug, Default, Clone, Copy)]
+pub(crate) struct ZipModpackParser;
+
+impl ZipModpackParser {
+ pub(crate) fn new() -> Self {
+ Self
+ }
+}
+
+impl ModpackParser for ZipModpackParser {
+ fn parse(&self, path: &Path) -> Result<ParsedModpack, String> {
+ let mut archive = archive::open(path)?;
+ Ok(formats::parse(&mut archive).unwrap_or_else(|_| ParsedModpack::unknown(path)))
+ }
+}
diff --git a/src-tauri/src/core/modpack/resolver.rs b/src-tauri/src/core/modpack/resolver.rs
new file mode 100644
index 0000000..6f5f4aa
--- /dev/null
+++ b/src-tauri/src/core/modpack/resolver.rs
@@ -0,0 +1,177 @@
+use std::collections::{HashMap, HashSet};
+
+use futures::future::BoxFuture;
+
+use super::{
+ curseforge::{
+ CurseForgeApi, CurseForgeFile, CurseForgeGetModFilesRequestBody,
+ CurseForgeGetModsByIdsListRequestBody, CurseForgeMod,
+ },
+ types::{ModpackFile, ParsedModpack},
+};
+
+const CURSEFORGE_RESOURCE_PACK_CLASS_ID: u64 = 12;
+const CURSEFORGE_SHADER_PACK_CLASS_ID: u64 = 6552;
+
+pub(crate) trait ModpackFileResolver: Send + Sync {
+ fn resolve<'a>(
+ &'a self,
+ modpack: ParsedModpack,
+ ) -> BoxFuture<'a, Result<ParsedModpack, String>>;
+}
+
+pub(crate) struct ResolverChain {
+ resolvers: Vec<Box<dyn ModpackFileResolver>>,
+}
+
+impl ResolverChain {
+ pub(crate) fn new(resolvers: Vec<Box<dyn ModpackFileResolver>>) -> Self {
+ Self { resolvers }
+ }
+
+ pub(crate) fn push<R>(&mut self, resolver: R)
+ where
+ R: ModpackFileResolver + 'static,
+ {
+ self.resolvers.push(Box::new(resolver));
+ }
+}
+
+impl Default for ResolverChain {
+ fn default() -> Self {
+ Self::new(vec![Box::new(CurseForgeFileResolver::default())])
+ }
+}
+
+impl ModpackFileResolver for ResolverChain {
+ fn resolve<'a>(
+ &'a self,
+ mut modpack: ParsedModpack,
+ ) -> BoxFuture<'a, Result<ParsedModpack, String>> {
+ Box::pin(async move {
+ for resolver in &self.resolvers {
+ modpack = resolver.resolve(modpack).await?;
+ }
+ Ok(modpack)
+ })
+ }
+}
+
+pub(crate) struct CurseForgeFileResolver {
+ api: CurseForgeApi,
+}
+
+impl CurseForgeFileResolver {
+ pub(crate) fn new(api: CurseForgeApi) -> Self {
+ Self { api }
+ }
+
+ async fn resolve_files(&self, files: &[ModpackFile]) -> Result<Vec<ModpackFile>, String> {
+ let file_ids: Vec<u64> = files.iter().filter_map(file_id).collect();
+ if file_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let file_items = self
+ .api
+ .get_files(&CurseForgeGetModFilesRequestBody::new(file_ids))
+ .await?
+ .data;
+ let mod_ids: Vec<u64> = file_items
+ .iter()
+ .map(|item| item.mod_id)
+ .collect::<HashSet<_>>()
+ .into_iter()
+ .collect();
+ let class_ids = self.class_ids(&mod_ids).await;
+
+ Ok(file_items
+ .into_iter()
+ .map(|item| {
+ let class_id = class_ids.get(&item.mod_id).copied();
+ map_curseforge_file(item, class_id)
+ })
+ .collect())
+ }
+
+ async fn class_ids(&self, mod_ids: &[u64]) -> HashMap<u64, u64> {
+ let Ok(mods) = self
+ .api
+ .get_mods(&CurseForgeGetModsByIdsListRequestBody::new(
+ mod_ids.to_vec(),
+ ))
+ .await
+ .map(|response| response.data)
+ else {
+ return HashMap::new();
+ };
+
+ mods.into_iter().filter_map(mod_class_entry).collect()
+ }
+}
+
+impl Default for CurseForgeFileResolver {
+ fn default() -> Self {
+ Self::new(CurseForgeApi::default())
+ }
+}
+
+impl ModpackFileResolver for CurseForgeFileResolver {
+ fn resolve<'a>(
+ &'a self,
+ mut modpack: ParsedModpack,
+ ) -> BoxFuture<'a, Result<ParsedModpack, String>> {
+ Box::pin(async move {
+ if modpack.info.modpack_type != "curseforge" {
+ return Ok(modpack);
+ }
+
+ let files = self.resolve_files(&modpack.files).await?;
+ modpack.files = files;
+ Ok(modpack)
+ })
+ }
+}
+
+fn file_id(file: &ModpackFile) -> Option<u64> {
+ file.url
+ .strip_prefix("curseforge://")?
+ .split(':')
+ .nth(1)?
+ .parse()
+ .ok()
+}
+
+fn map_curseforge_file(file: CurseForgeFile, class_id: Option<u64>) -> ModpackFile {
+ let CurseForgeFile {
+ id,
+ file_name,
+ download_url,
+ file_length,
+ ..
+ } = file;
+ let url = download_url.unwrap_or_else(|| {
+ format!(
+ "https://edge.forgecdn.net/files/{}/{}/{}",
+ id / 1000,
+ id % 1000,
+ file_name
+ )
+ });
+ let path = match class_id {
+ Some(CURSEFORGE_RESOURCE_PACK_CLASS_ID) => format!("resourcepacks/{file_name}"),
+ Some(CURSEFORGE_SHADER_PACK_CLASS_ID) => format!("shaderpacks/{file_name}"),
+ _ => format!("mods/{file_name}"),
+ };
+
+ ModpackFile {
+ url,
+ path,
+ size: Some(file_length),
+ sha1: None,
+ }
+}
+
+fn mod_class_entry(item: CurseForgeMod) -> Option<(u64, u64)> {
+ Some((item.id, item.class_id?))
+}
diff --git a/src-tauri/src/core/modpack/types.rs b/src-tauri/src/core/modpack/types.rs
new file mode 100644
index 0000000..246b759
--- /dev/null
+++ b/src-tauri/src/core/modpack/types.rs
@@ -0,0 +1,50 @@
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModpackInfo {
+ pub name: String,
+ pub minecraft_version: Option<String>,
+ pub mod_loader: Option<String>,
+ pub mod_loader_version: Option<String>,
+ pub modpack_type: String,
+ #[serde(default)]
+ pub instance_id: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ModpackFile {
+ pub url: String,
+ pub path: String,
+ pub size: Option<u64>,
+ pub sha1: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ParsedModpack {
+ pub info: ModpackInfo,
+ pub files: Vec<ModpackFile>,
+ pub override_prefixes: Vec<String>,
+}
+
+impl ParsedModpack {
+ pub(crate) fn unknown(path: &Path) -> Self {
+ Self {
+ info: ModpackInfo {
+ name: path
+ .file_stem()
+ .and_then(|name| name.to_str())
+ .unwrap_or("Imported Modpack")
+ .to_string(),
+ minecraft_version: None,
+ mod_loader: None,
+ mod_loader_version: None,
+ modpack_type: "unknown".into(),
+ instance_id: None,
+ },
+ files: Vec::new(),
+ override_prefixes: Vec::new(),
+ }
+ }
+}