Skip to main content

minecraft_java_rs_core/loader/
forge.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use serde::Deserialize;
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::sync::mpsc::Sender;
8
9use crate::error::LoaderError;
10use crate::launcher::events::LaunchEvent;
11use crate::launcher::options::LaunchOptions;
12use crate::loader::forge_patcher::{ForgePatcher, PatchConfig};
13use crate::models::loader::{
14    ForgeProfile, ForgeVersionSection, InstallerInfo, LoaderLibrary, LoaderType,
15};
16use crate::models::minecraft::AssetItem;
17use crate::net::downloader::{DownloadItem, Downloader};
18use crate::utils::archive::{get_file_from_archive, ArchiveQueryResult};
19use crate::utils::paths::get_path_libraries;
20
21// ── Constants ─────────────────────────────────────────────────────────────────
22
23const META_URL: &str =
24    "https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json";
25const PROMOTIONS_URL: &str =
26    "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
27const MAVEN_BASE: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge";
28
29static FALLBACK_META: &[u8] = include_bytes!("../../assets/forge/maven-metadata.json");
30
31#[derive(Deserialize)]
32struct Promotions {
33    promos: HashMap<String, String>,
34}
35
36// ── Public API ────────────────────────────────────────────────────────────────
37
38pub struct ForgeMC;
39
40impl ForgeMC {
41    pub fn new() -> Self {
42        Self
43    }
44
45    /// Install Forge by downloading and running the installer JAR in headless
46    /// (`--installClient`) mode. The installer writes libraries to
47    /// `<loader_base>/libraries/` and the version JSON to
48    /// `<loader_base>/versions/<id>/<id>.json`.
49    pub async fn install(
50        &self,
51        options: &LaunchOptions,
52        mc_version: &str,
53        java_path: &str,
54        mc_jar: &str,
55        mc_json: &str,
56        build: &str,
57        client: &reqwest::Client,
58        event_tx: &Sender<LaunchEvent>,
59    ) -> Result<
60        (
61            String,
62            Option<String>,
63            Vec<AssetItem>,
64            Vec<String>,
65            Vec<String>,
66        ),
67        LoaderError,
68    > {
69        let loader_base = options.loader_dir("forge");
70        tokio::fs::create_dir_all(&loader_base).await?;
71
72        // 1. Download the installer JAR.
73        let installer = self
74            .download_installer(options, mc_version, build, client, event_tx)
75            .await?;
76
77        // 2. Determine the version ID the installer will create.
78        let version_id = read_installer_version_id(&installer.file_path).await?;
79        let version_json_path = loader_base
80            .join("versions")
81            .join(&version_id)
82            .join(format!("{version_id}.json"));
83
84        // 3. Install: try the manual patcher first; fall back to --installClient.
85        if !version_json_path.exists() {
86            let used_patcher = try_patcher_install(
87                &installer.file_path,
88                &loader_base,
89                &version_json_path,
90                mc_jar,
91                mc_json,
92                java_path,
93                &options.path,
94                options,
95                LoaderType::Forge,
96                false, // neo_forge_old: not applicable for Forge
97                event_tx,
98            )
99            .await;
100
101            if !used_patcher {
102                prepare_install_dir(&loader_base, mc_version, mc_jar, mc_json).await?;
103                run_installer(java_path, &installer.file_path, &loader_base, event_tx).await?;
104            }
105
106            if !version_json_path.exists() {
107                return Err(LoaderError::ApiError(format!(
108                    "Forge install finished but no version JSON found at {}",
109                    version_json_path.display()
110                )));
111            }
112        }
113
114        // 4. Parse the version JSON and build the loader result.
115        let version_json = read_version_json(&version_json_path).await?;
116        let libraries = build_library_assets(&loader_base, &version_json);
117        let extra_game_args = extract_game_args(&version_json);
118        let extra_jvm_args = extract_jvm_args(&loader_base, &version_id, &version_json);
119        let main_class = version_json.main_class;
120
121        Ok((
122            version_id,
123            main_class,
124            libraries,
125            extra_game_args,
126            extra_jvm_args,
127        ))
128    }
129
130    /// Download the Forge installer JAR for the given Minecraft version and build.
131    pub async fn download_installer(
132        &self,
133        options: &LaunchOptions,
134        mc_version: &str,
135        build: &str,
136        client: &reqwest::Client,
137        event_tx: &Sender<LaunchEvent>,
138    ) -> Result<InstallerInfo, LoaderError> {
139        let all_versions: HashMap<String, Vec<String>> = match client.get(META_URL).send().await {
140            Ok(r) if r.status().is_success() => r.json().await?,
141            _ => serde_json::from_slice(FALLBACK_META)?,
142        };
143
144        let versions = all_versions.get(mc_version).ok_or_else(|| {
145            LoaderError::VersionNotFound(format!("Forge doesn't support Minecraft {mc_version}"))
146        })?;
147
148        let forge_build = resolve_forge_build(build, mc_version, versions, client).await?;
149
150        if !versions.iter().any(|v| v == &forge_build) {
151            let available = versions.join(", ");
152            return Err(LoaderError::VersionNotFound(format!(
153                "Forge build {forge_build} not found for {mc_version}. Available: {available}"
154            )));
155        }
156
157        let installer_name = format!("forge-{forge_build}-installer.jar");
158        let installer_folder = options.loader_dir("forge").join("installer");
159        let installer_path = installer_folder.join(&installer_name);
160
161        if !installer_path.exists() {
162            let url = format!("{MAVEN_BASE}/{forge_build}/{installer_name}");
163            let item = DownloadItem {
164                url: url.clone(),
165                path: installer_path.clone(),
166                folder: installer_folder.clone(),
167                name: installer_name.clone(),
168                size: 0,
169                r#type: Some("forge".into()),
170                sha1: None,
171            };
172            let downloader =
173                Downloader::new(options.timeout_secs, 1, options.force_ipv4, options.dns);
174            downloader
175                .download_multiple(vec![item], event_tx.clone())
176                .await
177                .map_err(|e| {
178                    LoaderError::Io(std::io::Error::new(
179                        std::io::ErrorKind::Other,
180                        e.to_string(),
181                    ))
182                })?;
183        }
184
185        Ok(InstallerInfo {
186            file_path: installer_path.to_string_lossy().into_owned(),
187            meta_data: forge_build.clone(),
188            ext: "jar".into(),
189            id: format!("forge-{forge_build}"),
190            old_api: false,
191        })
192    }
193}
194
195impl Default for ForgeMC {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201// ── Installer driver ──────────────────────────────────────────────────────────
202
203/// Read the `version` field from `install_profile.json` inside the installer.
204///
205/// This is the directory name the installer will create under `versions/`.
206async fn read_installer_version_id(installer_path: &str) -> Result<String, LoaderError> {
207    let result = get_file_from_archive(
208        PathBuf::from(installer_path),
209        Some("install_profile.json".into()),
210        None,
211        false,
212    )
213    .await
214    .map_err(|e| LoaderError::Archive(e.to_string()))?;
215
216    let bytes = match result {
217        ArchiveQueryResult::FileData(b) => b,
218        _ => return Err(LoaderError::ProfileNotFound),
219    };
220
221    let raw: serde_json::Value = serde_json::from_slice(&bytes)?;
222
223    // New format (1.13+): top-level `version` string.
224    if let Some(v) = raw.get("version").and_then(|v| v.as_str()) {
225        return Ok(v.to_owned());
226    }
227    // Old format: install.version or versionInfo.id.
228    if let Some(v) = raw
229        .get("install")
230        .and_then(|i| i.get("version"))
231        .and_then(|v| v.as_str())
232    {
233        return Ok(v.to_owned());
234    }
235    if let Some(v) = raw
236        .get("versionInfo")
237        .and_then(|i| i.get("id"))
238        .and_then(|v| v.as_str())
239    {
240        return Ok(v.to_owned());
241    }
242
243    Err(LoaderError::ApiError(
244        "Could not determine version ID from install_profile.json".into(),
245    ))
246}
247
248/// The Forge installer refuses to run unless `launcher_profiles.json` exists
249/// and the base Minecraft version is present under `versions/<mc>/`.
250async fn prepare_install_dir(
251    loader_base: &Path,
252    mc_version: &str,
253    mc_jar: &str,
254    mc_json: &str,
255) -> Result<(), LoaderError> {
256    let profiles_path = loader_base.join("launcher_profiles.json");
257    if !profiles_path.exists() {
258        tokio::fs::write(&profiles_path, b"{\"profiles\":{}}\n").await?;
259    }
260
261    let dest_dir = loader_base.join("versions").join(mc_version);
262    tokio::fs::create_dir_all(&dest_dir).await?;
263
264    let dest_jar = dest_dir.join(format!("{mc_version}.jar"));
265    if !dest_jar.exists() {
266        tokio::fs::copy(mc_jar, &dest_jar).await?;
267    }
268    let dest_json = dest_dir.join(format!("{mc_version}.json"));
269    if !dest_json.exists() {
270        tokio::fs::copy(mc_json, &dest_json).await?;
271    }
272
273    Ok(())
274}
275
276/// Spawn `java -jar <installer> --installClient <loader_base>` and stream
277/// its stdout/stderr as `LaunchEvent::Patch` events.
278async fn run_installer(
279    java_path: &str,
280    installer_path: &str,
281    loader_base: &Path,
282    event_tx: &Sender<LaunchEvent>,
283) -> Result<(), LoaderError> {
284    let _ = event_tx
285        .send(LaunchEvent::Patch(format!(
286            "Running Forge installer: {}",
287            installer_path
288        )))
289        .await;
290
291    let mut child = tokio::process::Command::new(java_path)
292        .arg("-jar")
293        .arg(installer_path)
294        .arg("--installClient")
295        .arg(loader_base.as_os_str())
296        .stdout(Stdio::piped())
297        .stderr(Stdio::piped())
298        .spawn()
299        .map_err(LoaderError::Io)?;
300
301    if let Some(stdout) = child.stdout.take() {
302        let tx = event_tx.clone();
303        let mut lines = BufReader::new(stdout).lines();
304        tokio::spawn(async move {
305            while let Ok(Some(line)) = lines.next_line().await {
306                let _ = tx.send(LaunchEvent::Patch(line)).await;
307            }
308        });
309    }
310    if let Some(stderr) = child.stderr.take() {
311        let tx = event_tx.clone();
312        let mut lines = BufReader::new(stderr).lines();
313        tokio::spawn(async move {
314            while let Ok(Some(line)) = lines.next_line().await {
315                let _ = tx.send(LaunchEvent::Patch(line)).await;
316            }
317        });
318    }
319
320    let status = child.wait().await.map_err(LoaderError::Io)?;
321    if !status.success() {
322        // Known issue: Forge jarsplitter produces non-deterministic checksums under
323        // Java 17.0.10+, causing exit code 1 even though the version JSON was written
324        // successfully. Treat as a warning; the caller checks for the version JSON.
325        let _ = event_tx
326            .send(LaunchEvent::Patch(format!(
327                "Forge installer exited with code {:?} (checking for version JSON)",
328                status.code()
329            )))
330            .await;
331    }
332    Ok(())
333}
334
335async fn read_version_json(path: &Path) -> Result<ForgeVersionSection, LoaderError> {
336    let bytes = tokio::fs::read(path).await?;
337    let version: ForgeVersionSection = serde_json::from_slice(&bytes)?;
338    Ok(version)
339}
340
341/// Extract plain-string game args from the Forge version JSON.
342/// Handles both legacy `minecraftArguments` (string) and modern `arguments.game` (array).
343fn extract_game_args(version: &ForgeVersionSection) -> Vec<String> {
344    let mut args: Vec<String> = Vec::new();
345    if let Some(mc_args) = &version.minecraft_arguments {
346        for token in mc_args.split_whitespace() {
347            args.push(token.to_owned());
348        }
349    }
350    if let Some(forge_args) = &version.arguments {
351        for entry in &forge_args.game {
352            if let Some(s) = entry.as_str() {
353                args.push(s.to_owned());
354            }
355        }
356    }
357    args
358}
359
360/// Extract and resolve JVM args from the Forge version JSON (`arguments.jvm`).
361///
362/// `${library_directory}` is resolved to `loader_base/libraries` (the Forge-local
363/// library path, not the vanilla MC path). `${classpath_separator}` and
364/// `${version_name}` are also substituted so the strings are ready to use as-is.
365fn extract_jvm_args(
366    loader_base: &Path,
367    version_id: &str,
368    version: &ForgeVersionSection,
369) -> Vec<String> {
370    let lib_dir = loader_base.join("libraries").to_string_lossy().into_owned();
371    let sep = if cfg!(target_os = "windows") {
372        ";"
373    } else {
374        ":"
375    };
376    let mut args = Vec::new();
377    if let Some(forge_args) = &version.arguments {
378        for entry in &forge_args.jvm {
379            if let Some(s) = entry.as_str() {
380                args.push(
381                    s.replace("${library_directory}", &lib_dir)
382                        .replace("${classpath_separator}", sep)
383                        .replace("${version_name}", version_id),
384                );
385            }
386        }
387    }
388    args
389}
390
391/// Build classpath entries for the loader libraries the installer wrote.
392fn build_library_assets(loader_base: &Path, version: &ForgeVersionSection) -> Vec<AssetItem> {
393    let libs = version.libraries.as_deref().unwrap_or(&[]);
394    let mut items: Vec<AssetItem> = Vec::with_capacity(libs.len());
395
396    for lib in libs {
397        if lib.rules.is_some() {
398            continue;
399        }
400        // Old-format Forge marks server-only libraries with clientreq: false.
401        if lib.clientreq == Some(false) {
402            continue;
403        }
404
405        let (path, sha1, size, url) = resolve_library_entry(loader_base, lib);
406        items.push(AssetItem::Asset {
407            path,
408            sha1,
409            size,
410            url,
411        });
412    }
413
414    items
415}
416
417fn resolve_library_entry(loader_base: &Path, lib: &LoaderLibrary) -> (String, String, u64, String) {
418    let libs_dir = loader_base.join("libraries");
419
420    let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
421
422    let rel_path = artifact
423        .and_then(|a| a.path.clone())
424        .or_else(|| {
425            get_path_libraries(&lib.name, None, None)
426                .ok()
427                .map(|info| format!("{}/{}", info.path, info.name))
428        })
429        .unwrap_or_default();
430
431    let abs_path = libs_dir.join(&rel_path);
432
433    let sha1 = artifact.and_then(|a| a.sha1.clone()).unwrap_or_default();
434    let size = artifact.and_then(|a| a.size).unwrap_or(0);
435    // For old-format Forge libs that have no downloads.artifact, construct the
436    // URL from the lib's base `url` + relative Maven path, or fall back to the
437    // standard Minecraft library repo (used by launchwrapper, asm-all, etc.).
438    let url = artifact
439        .map(|a| a.url.clone())
440        .filter(|u| !u.is_empty())
441        .or_else(|| {
442            lib.url
443                .as_ref()
444                .filter(|u| !u.is_empty())
445                .map(|base| format!("{}/{}", base.trim_end_matches('/'), &rel_path))
446        })
447        .or_else(|| {
448            if !rel_path.is_empty() {
449                Some(format!("https://libraries.minecraft.net/{rel_path}"))
450            } else {
451                None
452            }
453        })
454        .unwrap_or_default();
455
456    (abs_path.to_string_lossy().into_owned(), sha1, size, url)
457}
458
459// ── Build resolution helpers ──────────────────────────────────────────────────
460
461/// Older Forge builds append `-{mc_version}` to the build number in the Maven
462/// artifact ID (e.g. `1.8.9-11.15.1.2318-1.8.9`), while the promotions API
463/// returns only the bare build number. Try the plain candidate first; if it
464/// isn't in the versions list, try the suffixed form.
465fn match_promo_in_versions(candidate: &str, mc_version: &str, versions: &[String]) -> String {
466    if versions.iter().any(|v| v == candidate) {
467        return candidate.to_owned();
468    }
469    let with_suffix = format!("{candidate}-{mc_version}");
470    if versions.iter().any(|v| v == &with_suffix) {
471        return with_suffix;
472    }
473    candidate.to_owned()
474}
475
476async fn resolve_forge_build(
477    build: &str,
478    mc_version: &str,
479    versions: &[String],
480    client: &reqwest::Client,
481) -> Result<String, LoaderError> {
482    match build {
483        "latest" => {
484            if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
485                if let Ok(p) = promos.json::<Promotions>().await {
486                    let key = format!("{mc_version}-latest");
487                    if let Some(ver) = p.promos.get(&key) {
488                        let candidate = format!("{mc_version}-{ver}");
489                        return Ok(match_promo_in_versions(&candidate, mc_version, versions));
490                    }
491                }
492            }
493            versions.last().cloned().ok_or_else(|| {
494                LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}"))
495            })
496        }
497        "recommended" => {
498            if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
499                if let Ok(p) = promos.json::<Promotions>().await {
500                    let rec_key = format!("{mc_version}-recommended");
501                    let lat_key = format!("{mc_version}-latest");
502                    let ver = p.promos.get(&rec_key).or_else(|| p.promos.get(&lat_key));
503                    if let Some(v) = ver {
504                        let candidate = format!("{mc_version}-{v}");
505                        return Ok(match_promo_in_versions(&candidate, mc_version, versions));
506                    }
507                }
508            }
509            versions.last().cloned().ok_or_else(|| {
510                LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}"))
511            })
512        }
513        specific => Ok(specific.to_owned()),
514    }
515}
516
517// ── Intermediate-path patcher ─────────────────────────────────────────────────
518
519/// Try to patch Forge manually instead of running `--installClient`.
520///
521/// Returns `true` if patching succeeded (version JSON written, processors ran).
522/// Returns `false` if patching cannot proceed (no processors, old format) or
523/// if any step failed — caller should fall back to `--installClient`.
524pub(crate) async fn try_patcher_install(
525    installer_path: &str,
526    loader_base: &Path,
527    version_json_path: &Path,
528    mc_jar: &str,
529    mc_json: &str,
530    java_path: &str,
531    game_path: &Path,
532    options: &LaunchOptions,
533    loader_type: LoaderType,
534    neo_forge_old: bool,
535    event_tx: &Sender<LaunchEvent>,
536) -> bool {
537    match try_patcher_install_inner(
538        installer_path,
539        loader_base,
540        version_json_path,
541        mc_jar,
542        mc_json,
543        java_path,
544        game_path,
545        options,
546        loader_type,
547        neo_forge_old,
548        event_tx,
549    )
550    .await
551    {
552        Ok(result) => result,
553        Err(e) => {
554            let _ = event_tx
555                .send(LaunchEvent::Patch(format!(
556                    "[patcher] Manual patch failed ({e}); falling back to --installClient"
557                )))
558                .await;
559            false
560        }
561    }
562}
563
564async fn try_patcher_install_inner(
565    installer_path: &str,
566    loader_base: &Path,
567    version_json_path: &Path,
568    mc_jar: &str,
569    mc_json: &str,
570    java_path: &str,
571    game_path: &Path,
572    options: &LaunchOptions,
573    loader_type: LoaderType,
574    neo_forge_old: bool,
575    event_tx: &Sender<LaunchEvent>,
576) -> Result<bool, LoaderError> {
577    // 1. Deserialize install_profile.json from the installer JAR.
578    let profile = read_install_profile(installer_path).await?;
579
580    // 2. New-format profiles have processors; old-format ones don't.
581    let has_processors = profile.processors.as_ref().map_or(false, |p| !p.is_empty());
582    if !has_processors {
583        // Old-format (pre-1.13): versionInfo is inline in install_profile.json.
584        if profile.version_info.is_some() {
585            install_old_forge_legacy(
586                installer_path,
587                loader_base,
588                version_json_path,
589                &profile,
590                event_tx,
591            )
592            .await?;
593            return Ok(true);
594        }
595        return Ok(false);
596    }
597
598    // 3. Extract version.json from the installer JAR.
599    if !version_json_path.exists() {
600        extract_version_json(installer_path, version_json_path).await?;
601    }
602
603    // 4. Extract bundled processor JARs from the installer's maven/ tree.
604    let libs_dir = loader_base.join("libraries");
605    extract_maven_entries(installer_path, &libs_dir).await?;
606
607    // 5. Download any processor JARs that weren't bundled in the installer.
608    download_profile_libraries(&profile, &libs_dir, options, event_tx).await?;
609
610    // 6. Extract data files embedded in the installer (BINPATCH, etc.).
611    extract_data_files(
612        installer_path,
613        &profile,
614        &libs_dir,
615        &loader_type,
616        neo_forge_old,
617    )
618    .await?;
619
620    // 7. Skip patching if all processor outputs already exist on disk.
621    let patcher = ForgePatcher::new(loader_base.to_path_buf(), loader_type);
622    if patcher.check(&profile) {
623        let _ = event_tx
624            .send(LaunchEvent::Patch(
625                "[patcher] Already patched, skipping".into(),
626            ))
627            .await;
628        return Ok(true);
629    }
630
631    // 8. Run processors sequentially.
632    let config = PatchConfig {
633        java_path,
634        minecraft_jar: mc_jar,
635        minecraft_json: mc_json,
636        game_path,
637    };
638    patcher
639        .patch(&profile, &config, neo_forge_old, event_tx)
640        .await?;
641    Ok(true)
642}
643
644/// Deserialize `install_profile.json` from inside the installer JAR.
645///
646/// New-format profiles (1.13+) have a top-level `"version": "<id>"` string
647/// that conflicts with `ForgeProfile.version: Option<ForgeVersionSection>`.
648/// We strip it before deserializing; the version ID is obtained separately
649/// via `read_installer_version_id`.
650async fn read_install_profile(installer_path: &str) -> Result<ForgeProfile, LoaderError> {
651    let result = get_file_from_archive(
652        PathBuf::from(installer_path),
653        Some("install_profile.json".into()),
654        None,
655        false,
656    )
657    .await
658    .map_err(|e| LoaderError::Archive(e.to_string()))?;
659
660    let bytes = match result {
661        ArchiveQueryResult::FileData(b) => b,
662        _ => return Err(LoaderError::ProfileNotFound),
663    };
664
665    let mut raw: serde_json::Value = serde_json::from_slice(&bytes)?;
666    if let Some(obj) = raw.as_object_mut() {
667        if obj.get("version").and_then(|v| v.as_str()).is_some() {
668            obj.remove("version");
669        }
670    }
671
672    let profile: ForgeProfile = serde_json::from_value(raw)?;
673    Ok(profile)
674}
675
676/// Extract `version.json` from the installer JAR to `dest_path`.
677/// Install old-format Forge (pre-1.13): write versionInfo as the version JSON
678/// and extract the bundled universal JAR into the loader's Maven libs tree.
679/// No processors exist in this format; the universal JAR IS the Forge runtime.
680async fn install_old_forge_legacy(
681    installer_path: &str,
682    loader_base: &Path,
683    version_json_path: &Path,
684    profile: &ForgeProfile,
685    event_tx: &Sender<LaunchEvent>,
686) -> Result<(), LoaderError> {
687    let version_info = profile.version_info.as_ref().expect("caller checked Some");
688
689    if let Some(parent) = version_json_path.parent() {
690        tokio::fs::create_dir_all(parent).await?;
691    }
692    tokio::fs::write(version_json_path, serde_json::to_vec_pretty(version_info)?).await?;
693    let _ = event_tx
694        .send(LaunchEvent::Patch(
695            "[patcher] Old-format Forge: wrote version JSON".into(),
696        ))
697        .await;
698
699    if let Some(install) = &profile.install {
700        if let (Some(file_in_zip), Some(maven_coord)) = (&install.file_path, &install.path) {
701            if let Ok(lib_info) = get_path_libraries(maven_coord, None, None) {
702                let dest = loader_base
703                    .join("libraries")
704                    .join(&lib_info.path)
705                    .join(&lib_info.name);
706                if !dest.exists() {
707                    let result = get_file_from_archive(
708                        PathBuf::from(installer_path),
709                        Some(file_in_zip.clone()),
710                        None,
711                        false,
712                    )
713                    .await
714                    .map_err(|e| LoaderError::Archive(e.to_string()))?;
715
716                    if let ArchiveQueryResult::FileData(bytes) = result {
717                        if let Some(parent) = dest.parent() {
718                            tokio::fs::create_dir_all(parent).await?;
719                        }
720                        tokio::fs::write(&dest, bytes).await?;
721                        let _ = event_tx
722                            .send(LaunchEvent::Patch(format!(
723                                "[patcher] Old-format Forge: extracted {}",
724                                lib_info.name
725                            )))
726                            .await;
727                    }
728                }
729            }
730        }
731    }
732
733    Ok(())
734}
735
736async fn extract_version_json(installer_path: &str, dest_path: &Path) -> Result<(), LoaderError> {
737    let result = get_file_from_archive(
738        PathBuf::from(installer_path),
739        Some("version.json".into()),
740        None,
741        false,
742    )
743    .await
744    .map_err(|e| LoaderError::Archive(e.to_string()))?;
745
746    let bytes = match result {
747        ArchiveQueryResult::FileData(b) => b,
748        _ => {
749            return Err(LoaderError::ApiError(
750                "version.json not found in installer JAR".into(),
751            ))
752        }
753    };
754
755    if let Some(parent) = dest_path.parent() {
756        tokio::fs::create_dir_all(parent).await?;
757    }
758    tokio::fs::write(dest_path, &bytes).await?;
759    Ok(())
760}
761
762/// Extract all `maven/` entries from the installer JAR into `libs_dir`.
763///
764/// Forge installers bundle processor JARs under `maven/` using Maven layout.
765/// We extract them so the patcher can find them without extra downloads.
766async fn extract_maven_entries(installer_path: &str, libs_dir: &Path) -> Result<(), LoaderError> {
767    let installer = PathBuf::from(installer_path);
768
769    let names = match get_file_from_archive(installer.clone(), None, Some("maven/".into()), false)
770        .await
771        .map_err(|e| LoaderError::Archive(e.to_string()))?
772    {
773        ArchiveQueryResult::Names(n) => n,
774        _ => return Ok(()),
775    };
776
777    for name in names {
778        let rel = match name.strip_prefix("maven/") {
779            Some(r) if !r.is_empty() => r.to_owned(),
780            _ => continue,
781        };
782
783        let dest = libs_dir.join(&rel);
784        if dest.exists() {
785            continue;
786        }
787
788        let bytes = match get_file_from_archive(installer.clone(), Some(name), None, false)
789            .await
790            .map_err(|e| LoaderError::Archive(e.to_string()))?
791        {
792            ArchiveQueryResult::FileData(b) => b,
793            _ => continue,
794        };
795
796        if let Some(parent) = dest.parent() {
797            tokio::fs::create_dir_all(parent).await?;
798        }
799        tokio::fs::write(&dest, &bytes).await?;
800    }
801
802    Ok(())
803}
804
805/// Download processor JARs from `profile.libraries` that aren't already on disk.
806///
807/// JARs bundled inside the installer (extracted by `extract_maven_entries`) are
808/// skipped; only entries with a non-empty URL that are still missing are fetched.
809async fn download_profile_libraries(
810    profile: &ForgeProfile,
811    libs_dir: &Path,
812    options: &LaunchOptions,
813    event_tx: &Sender<LaunchEvent>,
814) -> Result<(), LoaderError> {
815    let libs = match profile.libraries.as_deref() {
816        Some(l) if !l.is_empty() => l,
817        _ => return Ok(()),
818    };
819
820    let mut items: Vec<DownloadItem> = Vec::new();
821
822    for lib in libs {
823        let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
824        let url = match artifact {
825            Some(a) if !a.url.is_empty() => a.url.clone(),
826            _ => continue,
827        };
828
829        let rel_path = artifact
830            .and_then(|a| a.path.clone())
831            .or_else(|| {
832                get_path_libraries(&lib.name, None, None)
833                    .ok()
834                    .map(|info| format!("{}/{}", info.path, info.name))
835            })
836            .unwrap_or_default();
837
838        if rel_path.is_empty() {
839            continue;
840        }
841
842        let dest = libs_dir.join(&rel_path);
843        if dest.exists() {
844            continue;
845        }
846
847        let folder = dest.parent().unwrap_or(libs_dir).to_path_buf();
848        let name = dest
849            .file_name()
850            .map(|n| n.to_string_lossy().into_owned())
851            .unwrap_or_default();
852
853        items.push(DownloadItem {
854            url,
855            path: dest,
856            folder,
857            name,
858            size: artifact.and_then(|a| a.size).unwrap_or(0),
859            r#type: Some("forge-lib".into()),
860            sha1: artifact.and_then(|a| a.sha1.clone()),
861        });
862    }
863
864    if !items.is_empty() {
865        let downloader = Downloader::new(
866            options.timeout_secs,
867            options.clamped_concurrency(),
868            options.force_ipv4,
869            options.dns,
870        );
871        downloader
872            .download_multiple(items, event_tx.clone())
873            .await
874            .map_err(|e| {
875                LoaderError::Io(std::io::Error::new(
876                    std::io::ErrorKind::Other,
877                    e.to_string(),
878                ))
879            })?;
880    }
881
882    Ok(())
883}
884
885/// Extract data files embedded in the installer JAR (values starting with `/`).
886///
887/// The key case is `BINPATCH`: its client value (`"/data/client.lzma"`) points
888/// to a file inside the installer. We extract it to the Maven path the patcher
889/// expects: `libs_dir/<coord-path>/<name>-clientdata.lzma`.
890async fn extract_data_files(
891    installer_path: &str,
892    profile: &ForgeProfile,
893    libs_dir: &Path,
894    loader_type: &LoaderType,
895    neo_forge_old: bool,
896) -> Result<(), LoaderError> {
897    let data = match &profile.data {
898        Some(d) => d,
899        None => return Ok(()),
900    };
901
902    let universal_name: Option<String> = profile.libraries.as_deref().and_then(|libs| {
903        libs.iter()
904            .find(|lib| match loader_type {
905                LoaderType::Forge => lib.name.starts_with("net.minecraftforge:forge"),
906                LoaderType::NeoForge => {
907                    if neo_forge_old {
908                        lib.name.starts_with("net.neoforged:forge")
909                    } else {
910                        lib.name.starts_with("net.neoforged:neoforge")
911                    }
912                }
913                _ => false,
914            })
915            .map(|lib| lib.name.clone())
916    });
917
918    for (key, entry) in data {
919        let client_val = entry.client.trim();
920
921        // Only extract files that are embedded in the installer (path starts with '/').
922        if !client_val.starts_with('/') {
923            continue;
924        }
925        let in_jar_path = &client_val[1..]; // strip leading '/'
926
927        let dest: PathBuf = if key == "BINPATCH" {
928            let coord = profile
929                .path
930                .as_deref()
931                .or_else(|| profile.install.as_ref().and_then(|i| i.path.as_deref()))
932                .or(universal_name.as_deref())
933                .unwrap_or("");
934
935            if coord.is_empty() {
936                continue;
937            }
938
939            let info = match get_path_libraries(coord, None, None) {
940                Ok(i) => i,
941                Err(_) => continue,
942            };
943            let lzma_name = info.name.replace(".jar", "-clientdata.lzma");
944            libs_dir.join(&info.path).join(lzma_name)
945        } else {
946            libs_dir.join(in_jar_path)
947        };
948
949        if dest.exists() {
950            continue;
951        }
952
953        let result = get_file_from_archive(
954            PathBuf::from(installer_path),
955            Some(in_jar_path.to_owned()),
956            None,
957            false,
958        )
959        .await
960        .map_err(|e| LoaderError::Archive(e.to_string()))?;
961
962        let bytes = match result {
963            ArchiveQueryResult::FileData(b) => b,
964            _ => continue,
965        };
966
967        if let Some(parent) = dest.parent() {
968            tokio::fs::create_dir_all(parent).await?;
969        }
970        tokio::fs::write(&dest, &bytes).await?;
971    }
972
973    Ok(())
974}
975
976// ── Tests ─────────────────────────────────────────────────────────────────────
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    #[test]
983    fn fallback_metadata_is_valid_json() {
984        let parsed: serde_json::Value = serde_json::from_slice(FALLBACK_META).unwrap();
985        assert!(parsed.is_object(), "forge metadata should be a JSON object");
986    }
987
988    #[test]
989    fn fallback_metadata_contains_versions() {
990        let parsed: HashMap<String, Vec<String>> = serde_json::from_slice(FALLBACK_META).unwrap();
991        assert!(!parsed.is_empty());
992    }
993
994    #[test]
995    fn forge_mc_constructs() {
996        let _f = ForgeMC::new();
997    }
998
999    #[test]
1000    fn build_library_assets_uses_explicit_artifact_path() {
1001        let version = ForgeVersionSection {
1002            id: Some("1.20.1-forge-47.4.20".into()),
1003            libraries: Some(vec![LoaderLibrary {
1004                name: "cpw.mods:bootstraplauncher:1.1.2".into(),
1005                url: None,
1006                downloads: Some(crate::models::loader::LoaderLibraryDownloads {
1007                    artifact: Some(crate::models::loader::LoaderArtifact {
1008                        sha1: Some("abc".into()),
1009                        size: Some(123),
1010                        path: Some(
1011                            "cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar".into(),
1012                        ),
1013                        url: "https://example.com/x.jar".into(),
1014                    }),
1015                }),
1016                rules: None,
1017                clientreq: None,
1018            }]),
1019            main_class: None,
1020            minecraft_arguments: None,
1021            arguments: None,
1022            extra: HashMap::new(),
1023        };
1024        let base = PathBuf::from("/mc/loader/forge");
1025        let items = build_library_assets(&base, &version);
1026        assert_eq!(items.len(), 1);
1027        match &items[0] {
1028            AssetItem::Asset { path, .. } => {
1029                assert!(path.ends_with("bootstraplauncher-1.1.2.jar"), "got {path}");
1030                assert!(path.contains("loader/forge/libraries/cpw/mods"));
1031            }
1032            _ => panic!("expected Asset"),
1033        }
1034    }
1035
1036    #[test]
1037    fn build_library_assets_skips_rule_restricted() {
1038        let version = ForgeVersionSection {
1039            id: None,
1040            libraries: Some(vec![LoaderLibrary {
1041                name: "x:y:1".into(),
1042                url: None,
1043                downloads: None,
1044                rules: Some(vec![serde_json::json!({"action":"disallow"})]),
1045                clientreq: None,
1046            }]),
1047            main_class: None,
1048            minecraft_arguments: None,
1049            arguments: None,
1050            extra: HashMap::new(),
1051        };
1052        let items = build_library_assets(Path::new("/mc/loader/forge"), &version);
1053        assert!(items.is_empty());
1054    }
1055}