diff options
Diffstat (limited to 'src-tauri/src/core/modpack')
| -rw-r--r-- | src-tauri/src/core/modpack/api.rs | 287 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/archive.rs | 25 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/curseforge.rs | 436 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/extractor.rs | 96 | ||||
| -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 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/mod.rs | 11 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/parser.rs | 23 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/resolver.rs | 177 | ||||
| -rw-r--r-- | src-tauri/src/core/modpack/types.rs | 50 |
12 files changed, 1399 insertions, 0 deletions
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(), + } + } +} |