Skip to main content

hexomc_lib/install/
forge.rs

1use serde::{Deserialize, Serialize};
2use std::{
3    collections::HashMap,
4    path::{Path, PathBuf},
5};
6use tokio::fs;
7
8use crate::{
9    download::{download_batch, download_file, DownloadTask},
10    error::{HexoError, Result},
11    install::vanilla::{InstanceConfig, LibEntry, LoaderType},
12};
13
14const FORGE_META_URL: &str =
15    "https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json";
16const FORGE_MAVEN_BASE: &str =
17    "https://maven.minecraftforge.net/net/minecraftforge/forge";
18
19#[derive(Debug, Deserialize, Serialize, Clone)]
20pub struct ForgeInstallProfile {
21    pub libraries: Vec<ForgeLibrary>,
22    pub processors: Vec<ForgeProcessor>,
23    pub data: HashMap<String, ForgeDataEntry>,
24}
25
26#[derive(Debug, Deserialize, Serialize, Clone)]
27pub struct ForgeLibrary {
28    pub name: String,
29    pub downloads: ForgeLibraryDownloads,
30}
31
32#[derive(Debug, Deserialize, Serialize, Clone)]
33pub struct ForgeLibraryDownloads {
34    pub artifact: ForgeArtifact,
35}
36
37#[derive(Debug, Deserialize, Serialize, Clone)]
38pub struct ForgeArtifact {
39    pub path: String,
40    pub sha1: String,
41    pub url: String,
42}
43
44#[derive(Debug, Deserialize, Serialize, Clone)]
45pub struct ForgeProcessor {
46    pub jar: String,
47    pub classpath: Vec<String>,
48    pub args: Vec<String>,
49    pub sides: Option<Vec<String>>,
50}
51
52#[derive(Debug, Deserialize, Serialize, Clone)]
53pub struct ForgeDataEntry {
54    pub client: String,
55    pub server: String,
56}
57
58#[derive(Debug, Deserialize, Serialize, Clone)]
59pub struct ForgeVersionJson {
60    #[serde(rename = "mainClass")]
61    pub main_class: String,
62    pub libraries: Vec<ForgeLibrary>,
63    pub arguments: ForgeVersionArgs,
64}
65
66#[derive(Debug, Deserialize, Serialize, Clone)]
67pub struct ForgeVersionArgs {
68    pub jvm: Option<Vec<String>>,
69    pub game: Option<Vec<String>>,
70}
71
72/// Old installer `install_profile.json` (pre-1.13): no processors,
73/// made of an `install` and a `versionInfo` block.
74#[derive(Debug, Deserialize)]
75pub struct LegacyInstallProfile {
76    pub install: LegacyInstall,
77    #[serde(rename = "versionInfo")]
78    pub version_info: LegacyVersionInfo,
79}
80
81#[derive(Debug, Deserialize)]
82pub struct LegacyInstall {
83    /// Maven name of the forge universal jar.
84    pub path: String,
85    /// Universal jar's filename inside the installer zip.
86    #[serde(rename = "filePath")]
87    pub file_path: String,
88}
89
90#[derive(Debug, Deserialize)]
91pub struct LegacyVersionInfo {
92    #[serde(rename = "mainClass")]
93    pub main_class: String,
94    #[serde(rename = "minecraftArguments")]
95    pub minecraft_arguments: String,
96    pub libraries: Vec<LegacyLibrary>,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct LegacyLibrary {
101    pub name: String,
102    /// Download from this maven if present, otherwise from Mojang libraries.
103    #[serde(default)]
104    pub url: Option<String>,
105    /// Whether the client needs it (None = required).
106    #[serde(default)]
107    pub clientreq: Option<bool>,
108}
109
110/// Available Forge versions for the given MC version.
111pub async fn get_forge_versions(mc_version: &str) -> Result<Vec<String>> {
112    let client = reqwest::Client::new();
113    let data: HashMap<String, Vec<String>> =
114        client.get(FORGE_META_URL).send().await?.json().await?;
115
116    Ok(data.get(mc_version).cloned().unwrap_or_default())
117}
118
119/// Install Forge. `forge_version` = None uses the newest version.
120pub async fn install_forge(
121    mc_version: &str,
122    forge_version: Option<&str>,
123    instance_name: &str,
124    java_path: &Path,
125    base_dir: &Path,
126) -> Result<()> {
127    let versions = get_forge_versions(mc_version).await?;
128    let forge_ver = if let Some(v) = forge_version {
129        v.to_string()
130    } else {
131        // List is ordered oldest -> newest.
132        versions
133            .into_iter()
134            .last()
135            .ok_or_else(|| HexoError::VersionNotFound(format!("Forge for {}", mc_version)))?
136    };
137
138    // Version strings already include the mc prefix (e.g. "1.7.10-10.13.4.1614-1.7.10").
139    let installer_url = format!(
140        "{}/{}/forge-{}-installer.jar",
141        FORGE_MAVEN_BASE, forge_ver, forge_ver
142    );
143
144    install_forge_like(
145        mc_version,
146        &forge_ver,
147        &installer_url,
148        instance_name,
149        java_path,
150        base_dir,
151        LoaderType::Forge,
152    )
153    .await
154}
155
156
157pub async fn install_forge_like(
158    mc_version: &str,
159    loader_version: &str,
160    installer_url: &str,
161    instance_name: &str,
162    java_path: &Path,
163    base_dir: &Path,
164    loader_type: LoaderType,
165) -> Result<()> {
166    let instance_dir = base_dir.join("instance").join(instance_name);
167    let lib_dir = base_dir.join("libraries");
168    let temp_dir = base_dir.join("temp").join(loader_version);
169    fs::create_dir_all(&temp_dir).await?;
170
171    let installer_path = temp_dir.join("forge-installer.jar");
172    download_file(&DownloadTask::new(installer_url, &installer_path)).await?;
173
174    // Detect installer format: pre-1.13 has no processors and uses install + versionInfo.
175    let profile_value: serde_json::Value =
176        read_json_from_zip(&installer_path, "install_profile.json").await?;
177    if profile_value.get("versionInfo").is_some() {
178        let legacy: LegacyInstallProfile = serde_json::from_value(profile_value)?;
179        let result =
180            install_forge_legacy(&installer_path, &legacy, instance_name, base_dir).await;
181        let _ = fs::remove_dir_all(&temp_dir).await;
182        return result;
183    }
184    let install_profile: ForgeInstallProfile = serde_json::from_value(profile_value)?;
185
186    let mut lib_tasks: Vec<DownloadTask> = Vec::new();
187    for lib in &install_profile.libraries {
188        let art = &lib.downloads.artifact;
189        if art.url.is_empty() {
190            continue;
191        }
192        let path = lib_dir.join(&art.path);
193        lib_tasks.push(DownloadTask::new(&art.url, &path).with_sha1(art.sha1.clone()));
194    }
195    download_batch(lib_tasks, 8, |_, _| {}).await?;
196
197    let lzma_path = temp_dir.join("client.lzma");
198    extract_file_from_zip(&installer_path, "data/client.lzma", &lzma_path).await?;
199
200    let mc_jar = instance_dir.join(format!("{}.jar", mc_version));
201    run_processors(
202        &install_profile.processors,
203        &install_profile.data,
204        &installer_path,
205        &lzma_path,
206        &mc_jar,
207        &lib_dir,
208        java_path,
209        &temp_dir,
210    )
211    .await?;
212
213    let version_json: ForgeVersionJson =
214        read_json_from_zip(&installer_path, "version.json").await?;
215
216    let mut new_lib_entries: Vec<LibEntry> = Vec::new();
217    let mut ver_lib_tasks: Vec<DownloadTask> = Vec::new();
218    for lib in &version_json.libraries {
219        let art = &lib.downloads.artifact;
220        if art.url.is_empty() {
221            continue;
222        }
223        let path = lib_dir.join(&art.path);
224        ver_lib_tasks.push(DownloadTask::new(&art.url, &path).with_sha1(art.sha1.clone()));
225        new_lib_entries.push(LibEntry {
226            name: lib.name.clone(),
227            path: path.clone(),
228            sha1: art.sha1.clone(),
229        });
230    }
231    download_batch(ver_lib_tasks, 8, |_, _| {}).await?;
232
233    let mut config = InstanceConfig::load(&instance_dir).await?;
234
235    // Insert Forge JVM args at the -cp position.
236    if let Some(jvm_args) = &version_json.arguments.jvm {
237        let cp_pos = config
238            .start_args
239            .iter()
240            .position(|a| a == "-cp")
241            .unwrap_or(0);
242        for (i, arg) in jvm_args.iter().enumerate() {
243            config.start_args.insert(cp_pos + i, arg.clone());
244        }
245    }
246
247    // Insert Forge game args right after the main class.
248    if let Some(game_args) = &version_json.arguments.game {
249        if let Some(mc_pos) = config.start_args.iter().position(|a| a == "${mainClass}") {
250            for (i, arg) in game_args.iter().enumerate() {
251                config.start_args.insert(mc_pos + 1 + i, arg.clone());
252            }
253        }
254    }
255
256    config.main_class = version_json.main_class.clone();
257    config.loader_type = loader_type;
258
259    // Prepend the new libs so Forge takes precedence over vanilla.
260    new_lib_entries.extend(config.lib_list.drain(..));
261    config.lib_list = new_lib_entries;
262
263    config.save(&instance_dir).await?;
264
265    fs::remove_dir_all(&temp_dir).await?;
266
267    Ok(())
268}
269
270/// Legacy Forge install (pre-1.13): no processors. Extract the universal jar,
271/// download the libraries, then overlay mainClass / args / libs onto the
272/// already-installed vanilla instance config.
273async fn install_forge_legacy(
274    installer_path: &Path,
275    profile: &LegacyInstallProfile,
276    instance_name: &str,
277    base_dir: &Path,
278) -> Result<()> {
279    let instance_dir = base_dir.join("instance").join(instance_name);
280    let lib_dir = base_dir.join("libraries");
281
282    // Extract the forge universal jar from the installer into its maven path.
283    let universal_dest = resolve_maven_path(&lib_dir, &profile.install.path);
284    if let Some(parent) = universal_dest.parent() {
285        fs::create_dir_all(parent).await?;
286    }
287    extract_file_from_zip(installer_path, &profile.install.file_path, &universal_dest).await?;
288
289    let mut lib_tasks: Vec<DownloadTask> = Vec::new();
290    let mut new_lib_entries: Vec<LibEntry> = Vec::new();
291    for lib in &profile.version_info.libraries {
292        if lib.clientreq == Some(false) {
293            continue;
294        }
295        let path = resolve_maven_path(&lib_dir, &lib.name);
296        new_lib_entries.push(LibEntry {
297            name: lib.name.clone(),
298            path: path.clone(),
299            sha1: String::new(),
300        });
301
302        // Forge universal already came from the installer.
303        if lib.name == profile.install.path {
304            continue;
305        }
306
307        let base = lib
308            .url
309            .clone()
310            .unwrap_or_else(|| "https://libraries.minecraft.net/".to_string());
311        let sep = if base.ends_with('/') { "" } else { "/" };
312        let url = format!("{}{}{}", base, sep, maven_relative_path(&lib.name));
313        lib_tasks.push(DownloadTask::new(url, &path));
314    }
315    download_batch(lib_tasks, 8, |_, _| {}).await?;
316
317    let mut config = InstanceConfig::load(&instance_dir).await?;
318    config.main_class = profile.version_info.main_class.clone();
319    config.loader_type = LoaderType::Forge;
320    // Old game args are a single string; replace vanilla's game args outright.
321    config.start_args = profile
322        .version_info
323        .minecraft_arguments
324        .split_whitespace()
325        .map(|s| s.to_string())
326        .collect();
327    // Prepend new libs (launchwrapper / forge must load before vanilla libs).
328    new_lib_entries.extend(config.lib_list.drain(..));
329    config.lib_list = new_lib_entries;
330    config.save(&instance_dir).await?;
331
332    Ok(())
333}
334
335#[allow(clippy::too_many_arguments)]
336async fn run_processors(
337    processors: &[ForgeProcessor],
338    data: &HashMap<String, ForgeDataEntry>,
339    installer_path: &Path,
340    lzma_path: &Path,
341    mc_jar: &Path,
342    lib_dir: &Path,
343    java_path: &Path,
344    temp_dir: &Path,
345) -> Result<()> {
346    let mut rargs: HashMap<String, String> = HashMap::new();
347
348    for (key, entry) in data {
349        let value = &entry.client;
350        let resolved = if value.starts_with('[') && value.ends_with(']') {
351            path_str(&resolve_maven_path(lib_dir, &value[1..value.len() - 1]))
352        } else {
353            value.clone()
354        };
355        rargs.insert(format!("{{{}}}", key), resolved);
356    }
357
358    // Insert hardcoded values after the data loop so they aren't overwritten by a
359    // same-named key in data (e.g. NeoForge's BINPATCH is "/data/client.lzma" in data).
360    rargs.insert("{INSTALLER}".to_string(), path_str(installer_path));
361    rargs.insert("{ROOT}".to_string(), path_str(temp_dir));
362    rargs.insert("{SIDE}".to_string(), "client".to_string());
363    rargs.insert("{MINECRAFT_JAR}".to_string(), path_str(mc_jar));
364    rargs.insert("{BINPATCH}".to_string(), path_str(lzma_path));
365
366    for proc in processors {
367        if let Some(sides) = &proc.sides {
368            if !sides.contains(&"client".to_string()) {
369                continue;
370            }
371        }
372
373        let proc_jar = resolve_maven_path_from_name(lib_dir, &proc.jar)?;
374        let main_class = get_jar_main_class(&proc_jar)?;
375
376        let mut classpath = proc
377            .classpath
378            .iter()
379            .map(|n| resolve_maven_path_from_name(lib_dir, n).map(|p| path_str(&p)))
380            .collect::<Result<Vec<_>>>()?;
381        classpath.push(path_str(&proc_jar));
382
383        let cp_sep = classpath_separator();
384        let cp = classpath.join(cp_sep);
385
386        let resolved_args: Vec<String> = proc
387            .args
388            .iter()
389            .map(|arg| {
390                if arg.starts_with('[') && arg.ends_with(']') {
391                    let name = &arg[1..arg.len() - 1];
392                    path_str(&resolve_maven_path(lib_dir, name))
393                } else {
394                    let mut s = arg.clone();
395                    for (k, v) in &rargs {
396                        s = s.replace(k.as_str(), v.as_str());
397                    }
398                    s
399                }
400            })
401            .collect();
402
403        let status = std::process::Command::new(java_path)
404            .args(["-cp", &cp, &main_class])
405            .args(&resolved_args)
406            .status()
407            .map_err(|e| HexoError::ProcessorFailed(e.to_string()))?;
408
409        if !status.success() {
410            return Err(HexoError::ProcessorFailed(format!(
411                "processor {} exited with a non-zero status",
412                proc.jar
413            )));
414        }
415    }
416
417    Ok(())
418}
419
420async fn read_json_from_zip<T: serde::de::DeserializeOwned + Send + 'static>(
421    zip_path: &Path,
422    entry_name: &str,
423) -> Result<T> {
424    let zip_path = zip_path.to_owned();
425    let entry_name = entry_name.to_owned();
426    tokio::task::spawn_blocking(move || {
427        let file = std::fs::File::open(&zip_path)?;
428        let mut archive = zip::ZipArchive::new(file)?;
429        let entry = archive.by_name(&entry_name)?;
430        let value: T = serde_json::from_reader(entry)?;
431        Ok(value)
432    })
433    .await
434    .map_err(|e| HexoError::Other(e.to_string()))?
435}
436
437async fn extract_file_from_zip(
438    zip_path: &Path,
439    entry_name: &str,
440    out_path: &Path,
441) -> Result<()> {
442    let zip_path = zip_path.to_owned();
443    let entry_name = entry_name.to_owned();
444    let out_path = out_path.to_owned();
445    tokio::task::spawn_blocking(move || {
446        let file = std::fs::File::open(&zip_path)?;
447        let mut archive = zip::ZipArchive::new(file)?;
448        let mut entry = archive.by_name(&entry_name)?;
449        let mut out = std::fs::File::create(&out_path)?;
450        std::io::copy(&mut entry, &mut out)?;
451        Ok(())
452    })
453    .await
454    .map_err(|e| HexoError::Other(e.to_string()))?
455}
456
457/// Resolve `group:artifact:version[:classifier][@ext]` to a path under `lib_dir`.
458fn resolve_maven_path(lib_dir: &Path, name: &str) -> PathBuf {
459    let (base, ext) = if let Some((b, e)) = name.split_once('@') {
460        (b, e)
461    } else {
462        (name, "jar")
463    };
464
465    let parts: Vec<&str> = base.split(':').collect();
466    let group = parts[0].replace('.', "/");
467    let artifact = parts[1];
468    let version = parts[2];
469    let classifier = parts.get(3).copied().unwrap_or("");
470
471    let file_name = if classifier.is_empty() {
472        format!("{}-{}.{}", artifact, version, ext)
473    } else {
474        format!("{}-{}-{}.{}", artifact, version, classifier, ext)
475    };
476
477    lib_dir.join(group).join(artifact).join(version).join(file_name)
478}
479
480/// Like `resolve_maven_path` but returns a `/`-separated relative path for URLs.
481fn maven_relative_path(name: &str) -> String {
482    let (base, ext) = if let Some((b, e)) = name.split_once('@') {
483        (b, e)
484    } else {
485        (name, "jar")
486    };
487
488    let parts: Vec<&str> = base.split(':').collect();
489    let group = parts[0].replace('.', "/");
490    let artifact = parts[1];
491    let version = parts[2];
492    let classifier = parts.get(3).copied().unwrap_or("");
493
494    let file_name = if classifier.is_empty() {
495        format!("{}-{}.{}", artifact, version, ext)
496    } else {
497        format!("{}-{}-{}.{}", artifact, version, classifier, ext)
498    };
499
500    format!("{}/{}/{}/{}", group, artifact, version, file_name)
501}
502
503fn resolve_maven_path_from_name(lib_dir: &Path, name: &str) -> Result<PathBuf> {
504    let path = resolve_maven_path(lib_dir, name);
505    if !path.exists() {
506        return Err(HexoError::Other(format!(
507            "jar does not exist: {}",
508            path.display()
509        )));
510    }
511    Ok(path)
512}
513
514fn get_jar_main_class(jar_path: &Path) -> Result<String> {
515    let file = std::fs::File::open(jar_path)?;
516    let mut archive =
517        zip::ZipArchive::new(file).map_err(|e| HexoError::Other(e.to_string()))?;
518    let manifest = archive
519        .by_name("META-INF/MANIFEST.MF")
520        .map_err(|e| HexoError::Other(e.to_string()))?;
521    use std::io::Read;
522    let mut content = String::new();
523    let mut r = manifest;
524    r.read_to_string(&mut content)?;
525    for line in content.lines() {
526        if line.starts_with("Main-Class:") {
527            return Ok(line["Main-Class:".len()..].trim().to_string());
528        }
529    }
530    Err(HexoError::Other(format!(
531        "no Main-Class found in {}",
532        jar_path.display()
533    )))
534}
535
536fn path_str(p: &Path) -> String {
537    p.to_string_lossy().replace('\\', "/")
538}
539
540fn classpath_separator() -> &'static str {
541    if cfg!(target_os = "windows") { ";" } else { ":" }
542}