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