Skip to main content

hexomc_lib/modpack/
cfpack.rs

1//! CurseForge modpacks (a zip holding `manifest.json` and `overrides/`).
2//!
3//! The manifest only lists project/file IDs, so download URLs are resolved through the
4//! CurseForge API (API key required). Files whose authors disallow third-party downloads
5//! have no `downloadUrl` and are returned as [`ManualDownload`]s.
6
7use std::collections::HashMap;
8use std::path::Path;
9
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, read_zip_json, safe_join,
18        ManualDownload, ModpackInfo, ModpackInstallResult,
19    },
20    mods::curseforge::CurseForgeClient,
21};
22
23pub const MANIFEST_FILE: &str = "manifest.json";
24
25/// CurseForge `classId` of resource packs.
26const CLASS_RESOURCE_PACKS: u32 = 12;
27/// CurseForge `classId` of shader packs.
28const CLASS_SHADERS: u32 = 6552;
29
30#[derive(Debug, Deserialize, Serialize, Clone)]
31#[serde(rename_all = "camelCase")]
32pub struct CfManifest {
33    pub minecraft: CfManifestMinecraft,
34    #[serde(default)]
35    pub manifest_type: String,
36    #[serde(default)]
37    pub name: String,
38    #[serde(default)]
39    pub version: Option<String>,
40    #[serde(default)]
41    pub author: Option<String>,
42    #[serde(default)]
43    pub files: Vec<CfManifestFile>,
44    #[serde(default = "default_overrides")]
45    pub overrides: String,
46}
47
48fn default_overrides() -> String {
49    "overrides".into()
50}
51
52#[derive(Debug, Deserialize, Serialize, Clone)]
53#[serde(rename_all = "camelCase")]
54pub struct CfManifestMinecraft {
55    pub version: String,
56    #[serde(default)]
57    pub mod_loaders: Vec<CfManifestLoader>,
58}
59
60#[derive(Debug, Deserialize, Serialize, Clone)]
61pub struct CfManifestLoader {
62    /// e.g. `forge-47.2.0`, `neoforge-21.1.77`, `fabric-0.16.5`.
63    pub id: String,
64    #[serde(default)]
65    pub primary: bool,
66}
67
68#[derive(Debug, Deserialize, Serialize, Clone)]
69pub struct CfManifestFile {
70    #[serde(rename = "projectID")]
71    pub project_id: u64,
72    #[serde(rename = "fileID")]
73    pub file_id: u64,
74    #[serde(default = "default_true")]
75    pub required: bool,
76}
77
78fn default_true() -> bool {
79    true
80}
81
82impl CfManifest {
83    pub fn info(&self) -> Result<ModpackInfo> {
84        let loader = self
85            .minecraft
86            .mod_loaders
87            .iter()
88            .find(|l| l.primary)
89            .or_else(|| self.minecraft.mod_loaders.first());
90
91        let (loader, loader_version) = match loader {
92            None => (LoaderType::Vanilla, None),
93            Some(l) => {
94                let (kind, ver) = l
95                    .id
96                    .split_once('-')
97                    .ok_or_else(|| HexoError::UnsupportedLoader(l.id.clone()))?;
98                let kind = match kind {
99                    "forge" => LoaderType::Forge,
100                    "neoforge" => LoaderType::NeoForge,
101                    "fabric" => LoaderType::Fabric,
102                    _ => return Err(HexoError::UnsupportedLoader(l.id.clone())),
103                };
104                (kind, Some(ver.to_string()))
105            }
106        };
107
108        ModpackInfo {
109            name: self.name.clone(),
110            version: self.version.clone(),
111            mc_version: self.minecraft.version.clone(),
112            loader,
113            loader_version,
114        }.validated()
115    }
116}
117
118/// Read a CurseForge modpack's manifest without installing it.
119pub async fn read_cf_manifest(pack_path: &Path) -> Result<CfManifest> {
120    read_zip_json(pack_path, MANIFEST_FILE).await
121}
122
123/// Install a CurseForge modpack into `instance/{instance_name}`. Only `required` files
124/// are downloaded.
125pub async fn install_cfpack(
126    pack_path: &Path,
127    instance_name: &str,
128    base_dir: &Path,
129    java_path: Option<&Path>,
130    curseforge: &CurseForgeClient,
131    progress: ProgressFn,
132) -> Result<ModpackInstallResult> {
133    let manifest = read_cf_manifest(pack_path).await?;
134    let info = manifest.info()?;
135
136    install_pack_loader(&info, instance_name, base_dir, java_path, progress.clone()).await?;
137
138    install_cfpack_files(pack_path, instance_name, base_dir, curseforge, progress).await
139}
140
141/// Extract overrides and download required files without installing Minecraft or its loader.
142/// No Java is required and `instance_config.json` is not created. Use the returned
143/// [`ModpackInfo`] to install Minecraft and the loader before the first launch.
144pub async fn install_cfpack_files(
145    pack_path: &Path,
146    instance_name: &str,
147    base_dir: &Path,
148    curseforge: &CurseForgeClient,
149    progress: ProgressFn,
150) -> Result<ModpackInstallResult> {
151    let manifest = read_cf_manifest(pack_path).await?;
152    let info = manifest.info()?;
153
154    let game_dir = instance_game_dir(base_dir, instance_name);
155    tokio::fs::create_dir_all(&game_dir).await?;
156
157    progress(0, 0, "Extracting overrides");
158    extract_zip_dir(pack_path, &manifest.overrides, &game_dir).await?;
159
160    let wanted: Vec<&CfManifestFile> = manifest.files.iter().filter(|f| f.required).collect();
161    if wanted.is_empty() {
162        return Ok(ModpackInstallResult { info, manual_downloads: Vec::new(), skipped: Vec::new() });
163    }
164
165    progress(0, 0, "Resolving CurseForge files");
166    let file_ids: Vec<u64> = wanted.iter().map(|f| f.file_id).collect();
167    let project_ids: Vec<u64> = wanted.iter().map(|f| f.project_id).collect();
168    let files = curseforge.get_files(&file_ids).await?;
169    let projects: HashMap<u64, _> = curseforge
170        .get_mods(&project_ids)
171        .await?
172        .into_iter()
173        .map(|m| (m.id, m))
174        .collect();
175
176    let mut tasks = Vec::new();
177    let mut manual_downloads = Vec::new();
178    for file in files {
179        let project = projects.get(&file.mod_id);
180        let subdir = match project.and_then(|p| p.class_id) {
181            Some(CLASS_RESOURCE_PACKS) => "resourcepacks",
182            Some(CLASS_SHADERS) => "shaderpacks",
183            _ => "mods",
184        };
185        let dest = safe_join(&game_dir.join(subdir), &file.file_name)?;
186
187        match &file.download_url {
188            Some(url) => {
189                let mut task = DownloadTask::new(url, dest);
190                if let Some(sha1) = file.sha1() {
191                    task = task.with_sha1(sha1);
192                }
193                tasks.push(task);
194            }
195            None => manual_downloads.push(ManualDownload {
196                name: project.map_or_else(|| file.display_name.clone(), |p| p.name.clone()),
197                file_name: file.file_name.clone(),
198                website: project
199                    .and_then(|p| p.links.as_ref())
200                    .and_then(|l| l.website_url.as_ref())
201                    .map(|u| format!("{}/files/{}", u, file.id)),
202                dest,
203            }),
204        }
205    }
206
207    let p = progress.clone();
208    download_batch(tasks, 8, move |d, t| p(d, t, "Downloading modpack files")).await?;
209
210    Ok(ModpackInstallResult { info, manual_downloads, skipped: Vec::new() })
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn parse_manifest() {
219        let json = r#"{
220            "minecraft": {
221                "version": "1.20.1",
222                "modLoaders": [{ "id": "forge-47.2.0", "primary": true }]
223            },
224            "manifestType": "minecraftModpack",
225            "manifestVersion": 1,
226            "name": "Test",
227            "version": "1.0",
228            "author": "someone",
229            "files": [
230                { "projectID": 1, "fileID": 2, "required": true },
231                { "projectID": 3, "fileID": 4, "required": false }
232            ],
233            "overrides": "overrides"
234        }"#;
235        let manifest: CfManifest = serde_json::from_str(json).unwrap();
236        let info = manifest.info().unwrap();
237        assert_eq!(info.mc_version, "1.20.1");
238        assert_eq!(info.loader, LoaderType::Forge);
239        assert_eq!(info.loader_version.as_deref(), Some("47.2.0"));
240        assert_eq!(manifest.files.iter().filter(|f| f.required).count(), 1);
241    }
242}