1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use md5::{Digest, Md5};
10use serde::{Deserialize, Serialize};
11
12use crate::{
13 download::{download_batch, DownloadTask},
14 error::{HexoError, Result},
15 install::{loader::ProgressFn, vanilla::LoaderType},
16 modpack::{
17 extract_zip_dir, install_pack_loader, instance_game_dir, safe_join, ManualDownload,
18 ModpackInfo, ModpackInstallResult,
19 },
20};
21
22const API_BASE: &str = "https://api.atlauncher.com/v1";
23const DOWNLOAD_SERVER: &str = "https://download.nodecdn.net/containers/atl";
24const USER_AGENT: &str = concat!("hexomc-lib/", env!("CARGO_PKG_VERSION"));
25
26fn http_client() -> reqwest::Client {
28 reqwest::Client::builder()
29 .user_agent(USER_AGENT)
30 .build()
31 .unwrap_or_default()
32}
33
34#[derive(Debug, Deserialize, Serialize, Clone)]
36pub struct AtPackVersion {
37 pub version: String,
38 pub minecraft: String,
39 #[serde(default)]
40 pub published: i64,
41}
42
43#[derive(Debug, Deserialize, Serialize, Clone)]
45#[serde(rename_all = "camelCase")]
46pub struct AtPackConfig {
47 pub version: String,
48 pub minecraft: String,
49 #[serde(default)]
50 pub no_configs: bool,
51 #[serde(default)]
52 pub loader: Option<AtLoader>,
53 #[serde(default)]
54 pub mods: Vec<AtMod>,
55 #[serde(default)]
56 pub configs: Option<AtConfigs>,
57}
58
59#[derive(Debug, Deserialize, Serialize, Clone)]
60#[serde(rename_all = "camelCase")]
61pub struct AtLoader {
62 #[serde(rename = "type", default)]
63 pub loader_type: String,
64 #[serde(default)]
65 pub metadata: HashMap<String, serde_json::Value>,
66 #[serde(default)]
67 pub class_name: Option<String>,
68}
69
70#[derive(Debug, Deserialize, Serialize, Clone)]
71pub struct AtConfigs {
72 #[serde(default)]
73 pub filesize: i64,
74 #[serde(default)]
75 pub sha1: Option<String>,
76}
77
78#[derive(Debug, Deserialize, Serialize, Clone)]
79#[serde(rename_all = "camelCase")]
80pub struct AtMod {
81 pub name: String,
82 #[serde(default)]
83 pub version: Option<String>,
84 #[serde(default)]
85 pub url: String,
86 pub file: String,
87 #[serde(default)]
89 pub path: Option<String>,
90 #[serde(default)]
91 pub md5: Option<String>,
92 #[serde(default)]
93 pub sha1: Option<String>,
94 #[serde(default)]
95 pub download: AtDownloadType,
96 #[serde(default)]
97 pub website: Option<String>,
98 #[serde(rename = "type")]
99 pub mod_type: AtModType,
100 #[serde(default)]
101 pub extract_to: Option<AtExtractTo>,
102 #[serde(default)]
103 pub extract_folder: Option<String>,
104 #[serde(default = "default_true")]
105 pub client: bool,
106 #[serde(default)]
107 pub optional: bool,
108 #[serde(default = "default_true")]
109 pub recommended: bool,
110}
111
112fn default_true() -> bool {
113 true
114}
115
116#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
117#[serde(rename_all = "lowercase")]
118pub enum AtDownloadType {
119 #[default]
121 Server,
122 Direct,
124 Browser,
126}
127
128#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
129#[serde(rename_all = "lowercase")]
130pub enum AtModType {
131 Jar,
132 Dependency,
133 Depandency,
134 Forge,
135 Mcpc,
136 Mods,
137 Plugins,
138 Ic2lib,
139 Denlib,
140 Flan,
141 Coremods,
142 Extract,
143 Decomp,
144 Millenaire,
145 Texturepack,
146 Resourcepack,
147 Texturepackextract,
148 Resourcepackextract,
149 Shaderpack,
150 Datapack,
151 #[serde(other)]
152 Unknown,
153}
154
155#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
156#[serde(rename_all = "lowercase")]
157pub enum AtExtractTo {
158 Root,
159 Mods,
160 Coremods,
161 #[serde(other)]
162 Unknown,
163}
164
165enum Placement {
167 Copy(String),
169 Unzip { dir: String, folder: String },
171 Ignore,
173 Unsupported,
174}
175
176impl AtMod {
177 pub fn download_url(&self) -> String {
178 match self.download {
179 AtDownloadType::Server => format!("{}/{}", DOWNLOAD_SERVER, self.url.trim_start_matches('/')),
180 AtDownloadType::Direct | AtDownloadType::Browser => self.url.clone(),
181 }
182 }
183
184 fn placement(&self, mc_version: &str) -> Placement {
185 let copy = |dir: &str| Placement::Copy(dir.to_string());
186 match self.mod_type {
187 AtModType::Mods => copy(self.path.as_deref().unwrap_or("mods")),
188 AtModType::Resourcepack => copy("resourcepacks"),
189 AtModType::Texturepack => copy("texturepacks"),
190 AtModType::Datapack => copy("datapacks"),
191 AtModType::Shaderpack => copy("shaderpacks"),
192 AtModType::Coremods => copy("coremods"),
193 AtModType::Plugins => copy("plugins"),
194 AtModType::Ic2lib => copy("mods/ic2"),
195 AtModType::Denlib => copy("mods/denlib"),
196 AtModType::Flan => copy("Flan"),
197 AtModType::Dependency | AtModType::Depandency => copy(&format!("mods/{}", mc_version)),
198 AtModType::Jar | AtModType::Forge => copy("jarmods"),
199 AtModType::Texturepackextract => Placement::Unzip {
200 dir: "texturepacks/extracted".into(),
201 folder: String::new(),
202 },
203 AtModType::Resourcepackextract => Placement::Unzip {
204 dir: "resourcepacks/extracted".into(),
205 folder: String::new(),
206 },
207 AtModType::Extract => {
208 let dir = match self.extract_to {
209 Some(AtExtractTo::Root) => "",
210 Some(AtExtractTo::Mods) => "mods",
211 Some(AtExtractTo::Coremods) => "coremods",
212 _ => return Placement::Unsupported,
213 };
214 Placement::Unzip {
215 dir: dir.into(),
216 folder: self.extract_folder.clone().unwrap_or_default(),
217 }
218 }
219 AtModType::Mcpc => Placement::Ignore,
220 AtModType::Decomp | AtModType::Millenaire | AtModType::Unknown => Placement::Unsupported,
221 }
222 }
223}
224
225impl AtLoader {
226 fn meta_str(&self, key: &str) -> Option<String> {
227 self.metadata.get(key).and_then(|v| v.as_str()).map(str::to_string)
228 }
229}
230
231impl AtPackConfig {
232 pub fn info(&self, pack_name: &str) -> Result<ModpackInfo> {
233 let mc = &self.minecraft;
234 let (loader, loader_version) = match &self.loader {
235 None => (LoaderType::Vanilla, None),
236 Some(l) => {
237 let kind = if l.loader_type.is_empty() {
238 l.class_name.clone().unwrap_or_default()
239 } else {
240 l.loader_type.clone()
241 }
242 .to_lowercase();
243
244 if kind.contains("neoforge") {
245 (LoaderType::NeoForge, l.meta_str("version"))
246 } else if kind.contains("forge") {
247 let version = l.meta_str("version").or_else(|| {
248 l.meta_str("rawVersion")
249 .map(|raw| raw.trim_start_matches(&format!("{}-", mc)).to_string())
250 });
251 (LoaderType::Forge, version)
252 } else if kind.contains("fabric") && !kind.contains("legacy") {
253 (LoaderType::Fabric, l.meta_str("loader"))
254 } else {
255 return Err(HexoError::UnsupportedLoader(kind));
256 }
257 }
258 };
259
260 Ok(ModpackInfo {
261 name: pack_name.to_string(),
262 version: Some(self.version.clone()),
263 mc_version: mc.clone(),
264 loader,
265 loader_version,
266 })
267 }
268}
269
270pub async fn get_atlauncher_pack_versions(safe_name: &str) -> Result<Vec<AtPackVersion>> {
273 #[derive(Deserialize)]
274 struct Resp {
275 #[serde(default)]
276 error: bool,
277 data: Option<Data>,
278 }
279 #[derive(Deserialize)]
280 struct Data {
281 versions: Vec<AtPackVersion>,
282 }
283
284 let resp = http_client()
285 .get(format!("{}/pack/{}", API_BASE, safe_name))
286 .send()
287 .await?;
288 if resp.status() == reqwest::StatusCode::NOT_FOUND {
289 return Err(HexoError::VersionNotFound(safe_name.to_string()));
290 }
291 let resp: Resp = resp.error_for_status()?.json().await?;
292 match resp.data {
293 Some(data) if !resp.error => Ok(data.versions),
294 _ => Err(HexoError::VersionNotFound(safe_name.to_string())),
295 }
296}
297
298pub async fn fetch_atlauncher_pack_config(safe_name: &str, version: &str) -> Result<AtPackConfig> {
300 let resp = http_client()
301 .get(format!("{}/packs/{}/versions/{}/Configs.json", DOWNLOAD_SERVER, safe_name, version))
302 .send()
303 .await?;
304 if resp.status() == reqwest::StatusCode::NOT_FOUND {
305 return Err(HexoError::VersionNotFound(format!("{} {}", safe_name, version)));
306 }
307 Ok(resp.error_for_status()?.json().await?)
308}
309
310pub async fn install_atlauncher_pack(
317 safe_name: &str,
318 version: &str,
319 instance_name: &str,
320 base_dir: &Path,
321 java_path: Option<&Path>,
322 include_optional: bool,
323 progress: ProgressFn,
324) -> Result<ModpackInstallResult> {
325 let config = fetch_atlauncher_pack_config(safe_name, version).await?;
326 let info = config.info(safe_name)?;
327
328 install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
329
330 let game_dir = instance_game_dir(base_dir, instance_name);
331 let temp_dir = safe_join(&base_dir.join("temp").join("atlauncher"), &format!("{}-{}", safe_name, version))?;
332 tokio::fs::create_dir_all(&temp_dir).await?;
333
334 let selected = config
335 .mods
336 .iter()
337 .filter(|m| m.client && (!m.optional || (include_optional && m.recommended)));
338
339 let mut tasks = Vec::new();
340 let mut md5_checks: Vec<(PathBuf, String)> = Vec::new();
341 let mut unzips: Vec<(PathBuf, PathBuf, String)> = Vec::new();
342 let mut manual_downloads = Vec::new();
343 let mut skipped = Vec::new();
344
345 for m in selected {
346 let dest = match m.placement(&config.minecraft) {
347 Placement::Copy(dir) => safe_join(&safe_join(&game_dir, &dir)?, &m.file)?,
348 Placement::Unzip { dir, folder } => {
349 let archive = safe_join(&temp_dir, &m.file)?;
350 unzips.push((archive.clone(), safe_join(&game_dir, &dir)?, folder));
351 archive
352 }
353 Placement::Ignore => continue,
354 Placement::Unsupported => {
355 skipped.push(m.name.clone());
356 continue;
357 }
358 };
359
360 if m.download == AtDownloadType::Browser {
361 manual_downloads.push(ManualDownload {
362 name: m.name.clone(),
363 file_name: m.file.clone(),
364 website: m.website.clone().or_else(|| Some(m.url.clone())),
365 dest,
366 });
367 continue;
368 }
369
370 let mut task = DownloadTask::new(m.download_url(), &dest);
371 match (&m.sha1, &m.md5) {
372 (Some(sha1), _) => task = task.with_sha1(sha1),
373 (None, Some(md5)) => md5_checks.push((dest.clone(), md5.to_lowercase())),
374 (None, None) => {}
375 }
376 tasks.push(task);
377 }
378
379 let p = progress.clone();
380 download_batch(tasks, 8, move |d, t| p(d, t, "Downloading pack files")).await?;
381
382 for (path, expected) in &md5_checks {
383 if file_md5(path).await? != *expected {
384 let _ = tokio::fs::remove_file(path).await;
385 return Err(HexoError::ChecksumMismatch { path: path.display().to_string() });
386 }
387 }
388
389 progress(0, 0, "Extracting pack files");
390 let unzipped: Vec<PathBuf> = unzips
391 .iter()
392 .map(|(archive, _, _)| archive.clone())
393 .collect();
394 for (archive, dest, folder) in unzips {
395 if manual_downloads.iter().any(|d| d.dest == archive) {
396 continue;
397 }
398 extract_zip_dir(&archive, &folder, &dest).await?;
399 }
400
401 if let Some(configs) = config.configs.as_ref().filter(|c| !config.no_configs && c.filesize != 0) {
402 progress(0, 0, "Downloading configs");
403 let zip_path = temp_dir.join("Configs.zip");
404 let mut task = DownloadTask::new(
405 format!("{}/packs/{}/versions/{}/Configs.zip", DOWNLOAD_SERVER, safe_name, version),
406 &zip_path,
407 );
408 if let Some(sha1) = &configs.sha1 {
409 task = task.with_sha1(sha1);
410 }
411 crate::download::download_file(&task).await?;
412 extract_zip_dir(&zip_path, "", &game_dir).await?;
413 }
414
415 let has_manual_extract = manual_downloads.iter().any(|d| unzipped.contains(&d.dest));
416 if !has_manual_extract {
417 let _ = tokio::fs::remove_dir_all(&temp_dir).await;
418 }
419
420 Ok(ModpackInstallResult { info, manual_downloads, skipped })
421}
422
423async fn file_md5(path: &Path) -> Result<String> {
424 let data = tokio::fs::read(path).await?;
425 Ok(hex::encode(Md5::digest(&data)))
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 const SAMPLE: &str = r#"{
433 "enableCurseIntegration": true,
434 "version": "4.2.4",
435 "minecraft": "1.12.2",
436 "loader": {
437 "type": "forge",
438 "choose": false,
439 "metadata": {
440 "minecraft": "1.12.2",
441 "version": "14.23.5.2860",
442 "rawVersion": "1.12.2-14.23.5.2860",
443 "installerSize": 4598857
444 },
445 "className": "com.atlauncher.data.loaders.forge.ForgeLoader"
446 },
447 "mods": [
448 {
449 "name": "SF4 Server Configs",
450 "url": "packs/SkyFactory4/files/4.2.4/SF4-Server-Config.zip",
451 "file": "SF4-Server-Config.zip",
452 "download": "server",
453 "md5": "b2588953d8825703579f9c0ee45285d2",
454 "type": "extract",
455 "extractTo": "root",
456 "extractFolder": "/",
457 "client": false,
458 "optional": false
459 },
460 {
461 "name": "Bed Patch",
462 "url": "packs/SkyFactory4/files/4.2.4/bedpatch-2.2-1.12.2.jar",
463 "file": "bedpatch-2.2-1.12.2.jar",
464 "download": "server",
465 "type": "mods",
466 "client": true,
467 "optional": false
468 },
469 {
470 "name": "Weird",
471 "url": "x",
472 "file": "x.jar",
473 "type": "somethingnew"
474 }
475 ],
476 "configs": { "filesize": 23061665, "sha1": "73dbececeb20f59a96843fdb3b9b4d2560ab8fa6" }
477 }"#;
478
479 #[test]
480 fn parse_config() {
481 let config: AtPackConfig = serde_json::from_str(SAMPLE).unwrap();
482 let info = config.info("SkyFactory4").unwrap();
483 assert_eq!(info.mc_version, "1.12.2");
484 assert_eq!(info.loader, LoaderType::Forge);
485 assert_eq!(info.loader_version.as_deref(), Some("14.23.5.2860"));
486
487 assert_eq!(config.mods.len(), 3);
488 assert_eq!(config.mods[2].mod_type, AtModType::Unknown);
489 assert!(!config.mods[0].client);
490 assert_eq!(
491 config.mods[1].download_url(),
492 "https://download.nodecdn.net/containers/atl/packs/SkyFactory4/files/4.2.4/bedpatch-2.2-1.12.2.jar"
493 );
494 assert!(matches!(config.mods[1].placement("1.12.2"), Placement::Copy(d) if d == "mods"));
495 }
496}