Skip to main content

hexomc_lib/modpack/
atpack.rs

1//! ATLauncher packs, installed from ATLauncher's online API.
2//!
3//! ATLauncher has no pack file format: a pack version is described by a remote
4//! `Configs.json` (loader and file list) plus an optional `Configs.zip` of config files.
5
6use 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
26/// ATLauncher's API sits behind Cloudflare, which rejects requests without a User-Agent.
27fn http_client() -> reqwest::Client {
28    reqwest::Client::builder()
29        .user_agent(USER_AGENT)
30        .build()
31        .unwrap_or_default()
32}
33
34/// One published version of an ATLauncher pack.
35#[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/// A pack version's `Configs.json`.
44#[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    /// Overrides the target directory for `mods` entries.
88    #[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    /// Relative URL on ATLauncher's download server.
120    #[default]
121    Server,
122    /// Absolute URL.
123    Direct,
124    /// Must be downloaded by the user in a browser.
125    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
165/// How a pack entry is placed into the game directory.
166enum Placement {
167    /// Copy the file into this directory (relative to the game dir).
168    Copy(String),
169    /// Unzip `folder` inside the archive into this directory.
170    Unzip { dir: String, folder: String },
171    /// Server-only entry, ignored on the client.
172    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
270/// List the published versions of an ATLauncher pack. `safe_name` is the pack's
271/// URL-safe name, e.g. `SkyFactory4`.
272pub 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
298/// Fetch a pack version's `Configs.json`.
299pub 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
310/// Install an ATLauncher pack version into `instance/{instance_name}`.
311///
312/// Client-side, non-optional entries are always installed; optional entries are
313/// installed only when `include_optional` is set and the pack marks them recommended.
314/// `browser` downloads are returned as [`ManualDownload`]s, and entry types that need
315/// ATLauncher-specific handling (`decomp`, `millenaire`) are returned in `skipped`.
316pub 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}