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 ModpackInfo {
261 name: pack_name.to_string(),
262 version: Some(self.version.clone()),
263 mc_version: mc.clone(),
264 loader,
265 loader_version,
266 }.validated()
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 install_config_files(&config, safe_name, version, instance_name, base_dir, include_optional, progress).await
331}
332
333pub async fn install_atlauncher_pack_files(
338 safe_name: &str,
339 version: &str,
340 instance_name: &str,
341 base_dir: &Path,
342 include_optional: bool,
343 progress: ProgressFn,
344) -> Result<ModpackInstallResult> {
345 let config = fetch_atlauncher_pack_config(safe_name, version).await?;
346 install_config_files(&config, safe_name, version, instance_name, base_dir, include_optional, progress).await
347}
348
349async fn install_config_files(
350 config: &AtPackConfig,
351 safe_name: &str,
352 version: &str,
353 instance_name: &str,
354 base_dir: &Path,
355 include_optional: bool,
356 progress: ProgressFn,
357) -> Result<ModpackInstallResult> {
358 let info = config.info(safe_name)?;
359
360 let game_dir = instance_game_dir(base_dir, instance_name);
361 tokio::fs::create_dir_all(&game_dir).await?;
362 let temp_dir = safe_join(&base_dir.join("temp").join("atlauncher"), &format!("{}-{}", safe_name, version))?;
363 tokio::fs::create_dir_all(&temp_dir).await?;
364
365 let selected = config
366 .mods
367 .iter()
368 .filter(|m| m.client && (!m.optional || (include_optional && m.recommended)));
369
370 let mut tasks = Vec::new();
371 let mut md5_checks: Vec<(PathBuf, String)> = Vec::new();
372 let mut unzips: Vec<(PathBuf, PathBuf, String)> = Vec::new();
373 let mut manual_downloads = Vec::new();
374 let mut skipped = Vec::new();
375
376 for m in selected {
377 let dest = match m.placement(&config.minecraft) {
378 Placement::Copy(dir) => safe_join(&safe_join(&game_dir, &dir)?, &m.file)?,
379 Placement::Unzip { dir, folder } => {
380 let archive = safe_join(&temp_dir, &m.file)?;
381 unzips.push((archive.clone(), safe_join(&game_dir, &dir)?, folder));
382 archive
383 }
384 Placement::Ignore => continue,
385 Placement::Unsupported => {
386 skipped.push(m.name.clone());
387 continue;
388 }
389 };
390
391 if m.download == AtDownloadType::Browser {
392 manual_downloads.push(ManualDownload {
393 name: m.name.clone(),
394 file_name: m.file.clone(),
395 website: m.website.clone().or_else(|| Some(m.url.clone())),
396 dest,
397 });
398 continue;
399 }
400
401 let mut task = DownloadTask::new(m.download_url(), &dest);
402 match (&m.sha1, &m.md5) {
403 (Some(sha1), _) => task = task.with_sha1(sha1),
404 (None, Some(md5)) => md5_checks.push((dest.clone(), md5.to_lowercase())),
405 (None, None) => {}
406 }
407 tasks.push(task);
408 }
409
410 let p = progress.clone();
411 download_batch(tasks, 8, move |d, t| p(d, t, "Downloading pack files")).await?;
412
413 for (path, expected) in &md5_checks {
414 if file_md5(path).await? != *expected {
415 let _ = tokio::fs::remove_file(path).await;
416 return Err(HexoError::ChecksumMismatch { path: path.display().to_string() });
417 }
418 }
419
420 progress(0, 0, "Extracting pack files");
421 let unzipped: Vec<PathBuf> = unzips
422 .iter()
423 .map(|(archive, _, _)| archive.clone())
424 .collect();
425 for (archive, dest, folder) in unzips {
426 if manual_downloads.iter().any(|d| d.dest == archive) {
427 continue;
428 }
429 extract_zip_dir(&archive, &folder, &dest).await?;
430 }
431
432 if let Some(configs) = config.configs.as_ref().filter(|c| !config.no_configs && c.filesize != 0) {
433 progress(0, 0, "Downloading configs");
434 let zip_path = temp_dir.join("Configs.zip");
435 let mut task = DownloadTask::new(
436 format!("{}/packs/{}/versions/{}/Configs.zip", DOWNLOAD_SERVER, safe_name, version),
437 &zip_path,
438 );
439 if let Some(sha1) = &configs.sha1 {
440 task = task.with_sha1(sha1);
441 }
442 crate::download::download_file(&task).await?;
443 extract_zip_dir(&zip_path, "", &game_dir).await?;
444 }
445
446 let has_manual_extract = manual_downloads.iter().any(|d| unzipped.contains(&d.dest));
447 if !has_manual_extract {
448 let _ = tokio::fs::remove_dir_all(&temp_dir).await;
449 }
450
451 Ok(ModpackInstallResult { info, manual_downloads, skipped })
452}
453
454async fn file_md5(path: &Path) -> Result<String> {
455 let data = tokio::fs::read(path).await?;
456 Ok(hex::encode(Md5::digest(&data)))
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[tokio::test]
464 async fn files_only_preserves_jar_placement_without_installing_loader() {
465 let config: AtPackConfig = serde_json::from_value(serde_json::json!({
466 "version": "1", "minecraft": "1.20.1",
467 "loader": {"type": "forge", "metadata": {"version": "47.2.0"}},
468 "mods": [{"name": "Patch", "file": "patch.jar", "type": "jar", "download": "browser"},
469 {"name": "Forge patch", "file": "forge.jar", "type": "forge", "download": "browser"}]
470 })).unwrap();
471 let temp = tempfile::tempdir().unwrap();
472 let result = install_config_files(&config, "Test", "1", "test", temp.path(), false, crate::no_progress()).await.unwrap();
473 let game = temp.path().join("instance/test/.minecraft");
474 assert!(game.is_dir());
475 assert_eq!(result.info.loader, LoaderType::Forge);
476 assert_eq!(result.manual_downloads.len(), 2);
477 assert_eq!(result.manual_downloads[0].dest, game.join("jarmods/patch.jar"));
478 assert_eq!(result.manual_downloads[1].dest, game.join("jarmods/forge.jar"));
479 assert!(!temp.path().join("instance/test/instance_config.json").exists());
480 assert!(!temp.path().join("libraries").exists());
481 assert!(!temp.path().join("assets").exists());
482 }
483
484 const SAMPLE: &str = r#"{
485 "enableCurseIntegration": true,
486 "version": "4.2.4",
487 "minecraft": "1.12.2",
488 "loader": {
489 "type": "forge",
490 "choose": false,
491 "metadata": {
492 "minecraft": "1.12.2",
493 "version": "14.23.5.2860",
494 "rawVersion": "1.12.2-14.23.5.2860",
495 "installerSize": 4598857
496 },
497 "className": "com.atlauncher.data.loaders.forge.ForgeLoader"
498 },
499 "mods": [
500 {
501 "name": "SF4 Server Configs",
502 "url": "packs/SkyFactory4/files/4.2.4/SF4-Server-Config.zip",
503 "file": "SF4-Server-Config.zip",
504 "download": "server",
505 "md5": "b2588953d8825703579f9c0ee45285d2",
506 "type": "extract",
507 "extractTo": "root",
508 "extractFolder": "/",
509 "client": false,
510 "optional": false
511 },
512 {
513 "name": "Bed Patch",
514 "url": "packs/SkyFactory4/files/4.2.4/bedpatch-2.2-1.12.2.jar",
515 "file": "bedpatch-2.2-1.12.2.jar",
516 "download": "server",
517 "type": "mods",
518 "client": true,
519 "optional": false
520 },
521 {
522 "name": "Weird",
523 "url": "x",
524 "file": "x.jar",
525 "type": "somethingnew"
526 }
527 ],
528 "configs": { "filesize": 23061665, "sha1": "73dbececeb20f59a96843fdb3b9b4d2560ab8fa6" }
529 }"#;
530
531 #[test]
532 fn parse_config() {
533 let config: AtPackConfig = serde_json::from_str(SAMPLE).unwrap();
534 let info = config.info("SkyFactory4").unwrap();
535 assert_eq!(info.mc_version, "1.12.2");
536 assert_eq!(info.loader, LoaderType::Forge);
537 assert_eq!(info.loader_version.as_deref(), Some("14.23.5.2860"));
538
539 assert_eq!(config.mods.len(), 3);
540 assert_eq!(config.mods[2].mod_type, AtModType::Unknown);
541 assert!(!config.mods[0].client);
542 assert_eq!(
543 config.mods[1].download_url(),
544 "https://download.nodecdn.net/containers/atl/packs/SkyFactory4/files/4.2.4/bedpatch-2.2-1.12.2.jar"
545 );
546 assert!(matches!(config.mods[1].placement("1.12.2"), Placement::Copy(d) if d == "mods"));
547 }
548}