1use std::collections::HashMap;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12 download::{download_batch, DownloadTask},
13 error::{HexoError, Result},
14 install::{loader::ProgressFn, vanilla::LoaderType},
15 modpack::{
16 extract_zip_dir, install_pack_loader, instance_game_dir, read_zip_json, safe_join,
17 ModpackInfo, ModpackInstallResult,
18 },
19};
20
21pub const INDEX_FILE: &str = "modrinth.index.json";
22
23#[derive(Debug, Deserialize, Serialize, Clone)]
24#[serde(rename_all = "camelCase")]
25pub struct MrpackIndex {
26 pub format_version: u32,
27 pub game: String,
28 pub version_id: String,
29 pub name: String,
30 #[serde(default)]
31 pub summary: Option<String>,
32 pub files: Vec<MrpackFile>,
33 pub dependencies: HashMap<String, String>,
35}
36
37#[derive(Debug, Deserialize, Serialize, Clone)]
38#[serde(rename_all = "camelCase")]
39pub struct MrpackFile {
40 pub path: String,
42 pub hashes: HashMap<String, String>,
43 #[serde(default)]
44 pub env: Option<MrpackEnv>,
45 pub downloads: Vec<String>,
46 #[serde(default)]
47 pub file_size: u64,
48}
49
50#[derive(Debug, Deserialize, Serialize, Clone)]
51pub struct MrpackEnv {
52 pub client: EnvSupport,
53 pub server: EnvSupport,
54}
55
56#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum EnvSupport {
59 Required,
60 Optional,
61 Unsupported,
62}
63
64impl MrpackFile {
65 pub fn is_client_supported(&self) -> bool {
66 self.env
67 .as_ref()
68 .is_none_or(|e| e.client != EnvSupport::Unsupported)
69 }
70}
71
72impl MrpackIndex {
73 pub fn info(&self) -> Result<ModpackInfo> {
74 let mc_version = self
75 .dependencies
76 .get("minecraft")
77 .cloned()
78 .ok_or_else(|| HexoError::Other("mrpack has no minecraft dependency".into()))?;
79
80 let deps = &self.dependencies;
81 let (loader, loader_version) = if let Some(v) = deps.get("neoforge") {
82 (LoaderType::NeoForge, Some(v.clone()))
83 } else if let Some(v) = deps.get("forge") {
84 (LoaderType::Forge, Some(v.clone()))
85 } else if let Some(v) = deps.get("fabric-loader") {
86 (LoaderType::Fabric, Some(v.clone()))
87 } else if let Some(v) = deps.get("quilt-loader") {
88 return Err(HexoError::UnsupportedLoader(format!("quilt-loader {}", v)));
89 } else {
90 (LoaderType::Vanilla, None)
91 };
92
93 ModpackInfo {
94 name: self.name.clone(),
95 version: Some(self.version_id.clone()),
96 mc_version,
97 loader,
98 loader_version,
99 }.validated()
100 }
101}
102
103pub async fn read_mrpack_index(pack_path: &Path) -> Result<MrpackIndex> {
105 read_zip_json(pack_path, INDEX_FILE).await
106}
107
108pub async fn install_mrpack(
113 pack_path: &Path,
114 instance_name: &str,
115 base_dir: &Path,
116 java_path: Option<&Path>,
117 progress: ProgressFn,
118) -> Result<ModpackInstallResult> {
119 let index = read_mrpack_index(pack_path).await?;
120 if index.game != "minecraft" {
121 return Err(HexoError::Other(format!("unsupported game: {}", index.game)));
122 }
123 let info = index.info()?;
124
125 install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
126
127 install_mrpack_files(pack_path, instance_name, base_dir, progress).await
128}
129
130pub async fn install_mrpack_files(
134 pack_path: &Path,
135 instance_name: &str,
136 base_dir: &Path,
137 progress: ProgressFn,
138) -> Result<ModpackInstallResult> {
139 let index = read_mrpack_index(pack_path).await?;
140 if index.game != "minecraft" {
141 return Err(HexoError::Other(format!("unsupported game: {}", index.game)));
142 }
143 let info = index.info()?;
144
145 let game_dir = instance_game_dir(base_dir, instance_name);
146 tokio::fs::create_dir_all(&game_dir).await?;
147
148 progress(0, 0, "Extracting overrides");
149 extract_zip_dir(pack_path, "overrides", &game_dir).await?;
150 extract_zip_dir(pack_path, "client-overrides", &game_dir).await?;
151
152 let mut tasks = Vec::new();
153 for file in index.files.iter().filter(|f| f.is_client_supported()) {
154 let url = file.downloads.first().ok_or_else(|| HexoError::DownloadFailed {
155 url: format!("no download URL for {}", file.path),
156 })?;
157 let mut task = DownloadTask::new(url, safe_join(&game_dir, &file.path)?);
158 if let Some(sha1) = file.hashes.get("sha1") {
159 task = task.with_sha1(sha1);
160 }
161 tasks.push(task);
162 }
163
164 let p = progress.clone();
165 download_batch(tasks, 8, move |d, t| p(d, t, "Downloading modpack files")).await?;
166
167 Ok(ModpackInstallResult { info, manual_downloads: Vec::new(), skipped: Vec::new() })
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 const SAMPLE: &str = r#"{
175 "formatVersion": 1,
176 "game": "minecraft",
177 "versionId": "1.0.0",
178 "name": "Test Pack",
179 "files": [
180 {
181 "path": "mods/sodium.jar",
182 "hashes": { "sha1": "abc", "sha512": "def" },
183 "env": { "client": "required", "server": "unsupported" },
184 "downloads": ["https://cdn.modrinth.com/data/x/sodium.jar"],
185 "fileSize": 123
186 },
187 {
188 "path": "mods/server-only.jar",
189 "hashes": { "sha1": "123" },
190 "env": { "client": "unsupported", "server": "required" },
191 "downloads": ["https://cdn.modrinth.com/data/y/server.jar"],
192 "fileSize": 1
193 }
194 ],
195 "dependencies": { "minecraft": "1.21.1", "fabric-loader": "0.16.5" }
196 }"#;
197
198 #[test]
199 fn parse_index() {
200 let index: MrpackIndex = serde_json::from_str(SAMPLE).unwrap();
201 let info = index.info().unwrap();
202 assert_eq!(info.mc_version, "1.21.1");
203 assert_eq!(info.loader, LoaderType::Fabric);
204 assert_eq!(info.loader_version.as_deref(), Some("0.16.5"));
205
206 let client: Vec<_> = index.files.iter().filter(|f| f.is_client_supported()).collect();
207 assert_eq!(client.len(), 1);
208 assert_eq!(client[0].path, "mods/sodium.jar");
209 }
210
211 #[test]
212 fn quilt_is_unsupported() {
213 let mut index: MrpackIndex = serde_json::from_str(SAMPLE).unwrap();
214 index.dependencies.remove("fabric-loader");
215 index.dependencies.insert("quilt-loader".into(), "0.26.0".into());
216 assert!(matches!(index.info(), Err(HexoError::UnsupportedLoader(_))));
217 }
218}