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
374        let (path, sha1, size, url) = resolve_library_entry(loader_base, lib);
375        items.push(AssetItem::Asset { path, sha1, size, url });
376    }
377
378    items
379}
380
381fn resolve_library_entry(
382    loader_base: &Path,
383    lib: &LoaderLibrary,
384) -> (String, String, u64, String) {
385    let libs_dir = loader_base.join("libraries");
386
387    let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
388
389    let rel_path = artifact
390        .and_then(|a| a.path.clone())
391        .or_else(|| {
392            get_path_libraries(&lib.name, None, None)
393                .ok()
394                .map(|info| format!("{}/{}", info.path, info.name))
395        })
396        .unwrap_or_default();
397
398    let abs_path = libs_dir.join(&rel_path);
399
400    let sha1 = artifact.and_then(|a| a.sha1.clone()).unwrap_or_default();
401    let size = artifact.and_then(|a| a.size).unwrap_or(0);
402    let url = artifact.map(|a| a.url.clone()).unwrap_or_default();
403
404    (abs_path.to_string_lossy().into_owned(), sha1, size, url)
405}
406
407// ── Build resolution helpers ──────────────────────────────────────────────────
408
409async fn resolve_forge_build(
410    build: &str,
411    mc_version: &str,
412    versions: &[String],
413    client: &reqwest::Client,
414) -> Result<String, LoaderError> {
415    match build {
416        "latest" => {
417            if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
418                if let Ok(p) = promos.json::<Promotions>().await {
419                    let key = format!("{mc_version}-latest");
420                    if let Some(ver) = p.promos.get(&key) {
421                        return Ok(format!("{mc_version}-{ver}"));
422                    }
423                }
424            }
425            versions
426                .last()
427                .cloned()
428                .ok_or_else(|| LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}")))
429        }
430        "recommended" => {
431            if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
432                if let Ok(p) = promos.json::<Promotions>().await {
433                    let rec_key = format!("{mc_version}-recommended");
434                    let lat_key = format!("{mc_version}-latest");
435                    let ver = p.promos.get(&rec_key).or_else(|| p.promos.get(&lat_key));
436                    if let Some(v) = ver {
437                        return Ok(format!("{mc_version}-{v}"));
438                    }
439                }
440            }
441            versions
442                .last()
443                .cloned()
444                .ok_or_else(|| LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}")))
445        }
446        specific => Ok(specific.to_owned()),
447    }
448}
449
450// ── Intermediate-path patcher ─────────────────────────────────────────────────
451
452/// Try to patch Forge manually instead of running `--installClient`.
453///
454/// Returns `true` if patching succeeded (version JSON written, processors ran).
455/// Returns `false` if patching cannot proceed (no processors, old format) or
456/// if any step failed — caller should fall back to `--installClient`.
457pub(crate) async fn try_patcher_install(
458    installer_path: &str,
459    loader_base: &Path,
460    version_json_path: &Path,
461    mc_jar: &str,
462    mc_json: &str,
463    java_path: &str,
464    game_path: &Path,
465    options: &LaunchOptions,
466    loader_type: LoaderType,
467    neo_forge_old: bool,
468    event_tx: &Sender<LaunchEvent>,
469) -> bool {
470    match try_patcher_install_inner(
471        installer_path,
472        loader_base,
473        version_json_path,
474        mc_jar,
475        mc_json,
476        java_path,
477        game_path,
478        options,
479        loader_type,
480        neo_forge_old,
481        event_tx,
482    )
483    .await
484    {
485        Ok(result) => result,
486        Err(e) => {
487            let _ = event_tx
488                .send(LaunchEvent::Patch(format!(
489                    "[patcher] Manual patch failed ({e}); falling back to --installClient"
490                )))
491                .await;
492            false
493        }
494    }
495}
496
497async fn try_patcher_install_inner(
498    installer_path: &str,
499    loader_base: &Path,
500    version_json_path: &Path,
501    mc_jar: &str,
502    mc_json: &str,
503    java_path: &str,
504    game_path: &Path,
505    options: &LaunchOptions,
506    loader_type: LoaderType,
507    neo_forge_old: bool,
508    event_tx: &Sender<LaunchEvent>,
509) -> Result<bool, LoaderError> {
510    // 1. Deserialize install_profile.json from the installer JAR.
511    let profile = read_install_profile(installer_path).await?;
512
513    // 2. New-format profiles have processors; old-format ones don't.
514    //    Without processors the patcher has nothing to do.
515    let has_processors = profile.processors.as_ref().map_or(false, |p| !p.is_empty());
516    if !has_processors {
517        return Ok(false);
518    }
519
520    // 3. Extract version.json from the installer JAR.
521    if !version_json_path.exists() {
522        extract_version_json(installer_path, version_json_path).await?;
523    }
524
525    // 4. Extract bundled processor JARs from the installer's maven/ tree.
526    let libs_dir = loader_base.join("libraries");
527    extract_maven_entries(installer_path, &libs_dir).await?;
528
529    // 5. Download any processor JARs that weren't bundled in the installer.
530    download_profile_libraries(&profile, &libs_dir, options, event_tx).await?;
531
532    // 6. Extract data files embedded in the installer (BINPATCH, etc.).
533    extract_data_files(installer_path, &profile, &libs_dir, &loader_type, neo_forge_old).await?;
534
535    // 7. Skip patching if all processor outputs already exist on disk.
536    let patcher = ForgePatcher::new(loader_base.to_path_buf(), loader_type);
537    if patcher.check(&profile) {
538        let _ = event_tx
539            .send(LaunchEvent::Patch("[patcher] Already patched, skipping".into()))
540            .await;
541        return Ok(true);
542    }
543
544    // 8. Run processors sequentially.
545    let config = PatchConfig {
546        java_path,
547        minecraft_jar: mc_jar,
548        minecraft_json: mc_json,
549        game_path,
550    };
551    patcher.patch(&profile, &config, neo_forge_old, event_tx).await?;
552    Ok(true)
553}
554
555/// Deserialize `install_profile.json` from inside the installer JAR.
556///
557/// New-format profiles (1.13+) have a top-level `"version": "<id>"` string
558/// that conflicts with `ForgeProfile.version: Option<ForgeVersionSection>`.
559/// We strip it before deserializing; the version ID is obtained separately
560/// via `read_installer_version_id`.
561async fn read_install_profile(installer_path: &str) -> Result<ForgeProfile, LoaderError> {
562    let result = get_file_from_archive(
563        PathBuf::from(installer_path),
564        Some("install_profile.json".into()),
565        None,
566        false,
567    )
568    .await
569    .map_err(|e| LoaderError::Archive(e.to_string()))?;
570
571    let bytes = match result {
572        ArchiveQueryResult::FileData(b) => b,
573        _ => return Err(LoaderError::ProfileNotFound),
574    };
575
576    let mut raw: serde_json::Value = serde_json::from_slice(&bytes)?;
577    if let Some(obj) = raw.as_object_mut() {
578        if obj.get("version").and_then(|v| v.as_str()).is_some() {
579            obj.remove("version");
580        }
581    }
582
583    let profile: ForgeProfile = serde_json::from_value(raw)?;
584    Ok(profile)
585}
586
587/// Extract `version.json` from the installer JAR to `dest_path`.
588async fn extract_version_json(installer_path: &str, dest_path: &Path) -> Result<(), LoaderError> {
589    let result = get_file_from_archive(
590        PathBuf::from(installer_path),
591        Some("version.json".into()),
592        None,
593        false,
594    )
595    .await
596    .map_err(|e| LoaderError::Archive(e.to_string()))?;
597
598    let bytes = match result {
599        ArchiveQueryResult::FileData(b) => b,
600        _ => {
601            return Err(LoaderError::ApiError(
602                "version.json not found in installer JAR".into(),
603            ))
604        }
605    };
606
607    if let Some(parent) = dest_path.parent() {
608        tokio::fs::create_dir_all(parent).await?;
609    }
610    tokio::fs::write(dest_path, &bytes).await?;
611    Ok(())
612}
613
614/// Extract all `maven/` entries from the installer JAR into `libs_dir`.
615///
616/// Forge installers bundle processor JARs under `maven/` using Maven layout.
617/// We extract them so the patcher can find them without extra downloads.
618async fn extract_maven_entries(installer_path: &str, libs_dir: &Path) -> Result<(), LoaderError> {
619    let installer = PathBuf::from(installer_path);
620
621    let names =
622        match get_file_from_archive(installer.clone(), None, Some("maven/".into()), false)
623            .await
624            .map_err(|e| LoaderError::Archive(e.to_string()))?
625        {
626            ArchiveQueryResult::Names(n) => n,
627            _ => return Ok(()),
628        };
629
630    for name in names {
631        let rel = match name.strip_prefix("maven/") {
632            Some(r) if !r.is_empty() => r.to_owned(),
633            _ => continue,
634        };
635
636        let dest = libs_dir.join(&rel);
637        if dest.exists() {
638            continue;
639        }
640
641        let bytes =
642            match get_file_from_archive(installer.clone(), Some(name), None, false)
643                .await
644                .map_err(|e| LoaderError::Archive(e.to_string()))?
645            {
646                ArchiveQueryResult::FileData(b) => b,
647                _ => continue,
648            };
649
650        if let Some(parent) = dest.parent() {
651            tokio::fs::create_dir_all(parent).await?;
652        }
653        tokio::fs::write(&dest, &bytes).await?;
654    }
655
656    Ok(())
657}
658
659/// Download processor JARs from `profile.libraries` that aren't already on disk.
660///
661/// JARs bundled inside the installer (extracted by `extract_maven_entries`) are
662/// skipped; only entries with a non-empty URL that are still missing are fetched.
663async fn download_profile_libraries(
664    profile: &ForgeProfile,
665    libs_dir: &Path,
666    options: &LaunchOptions,
667    event_tx: &Sender<LaunchEvent>,
668) -> Result<(), LoaderError> {
669    let libs = match profile.libraries.as_deref() {
670        Some(l) if !l.is_empty() => l,
671        _ => return Ok(()),
672    };
673
674    let mut items: Vec<DownloadItem> = Vec::new();
675
676    for lib in libs {
677        let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
678        let url = match artifact {
679            Some(a) if !a.url.is_empty() => a.url.clone(),
680            _ => continue,
681        };
682
683        let rel_path = artifact
684            .and_then(|a| a.path.clone())
685            .or_else(|| {
686                get_path_libraries(&lib.name, None, None)
687                    .ok()
688                    .map(|info| format!("{}/{}", info.path, info.name))
689            })
690            .unwrap_or_default();
691
692        if rel_path.is_empty() {
693            continue;
694        }
695
696        let dest = libs_dir.join(&rel_path);
697        if dest.exists() {
698            continue;
699        }
700
701        let folder = dest.parent().unwrap_or(libs_dir).to_path_buf();
702        let name = dest
703            .file_name()
704            .map(|n| n.to_string_lossy().into_owned())
705            .unwrap_or_default();
706
707        items.push(DownloadItem {
708            url,
709            path: dest,
710            folder,
711            name,
712            size: artifact.and_then(|a| a.size).unwrap_or(0),
713            r#type: Some("forge-lib".into()),
714            sha1: artifact.and_then(|a| a.sha1.clone()),
715        });
716    }
717
718    if !items.is_empty() {
719        let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
720        downloader
721            .download_multiple(items, event_tx.clone())
722            .await
723            .map_err(|e| {
724                LoaderError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
725            })?;
726    }
727
728    Ok(())
729}
730
731/// Extract data files embedded in the installer JAR (values starting with `/`).
732///
733/// The key case is `BINPATCH`: its client value (`"/data/client.lzma"`) points
734/// to a file inside the installer. We extract it to the Maven path the patcher
735/// expects: `libs_dir/<coord-path>/<name>-clientdata.lzma`.
736async fn extract_data_files(
737    installer_path: &str,
738    profile: &ForgeProfile,
739    libs_dir: &Path,
740    loader_type: &LoaderType,
741    neo_forge_old: bool,
742) -> Result<(), LoaderError> {
743    let data = match &profile.data {
744        Some(d) => d,
745        None => return Ok(()),
746    };
747
748    let universal_name: Option<String> = profile.libraries.as_deref().and_then(|libs| {
749        libs.iter()
750            .find(|lib| match loader_type {
751                LoaderType::Forge => lib.name.starts_with("net.minecraftforge:forge"),
752                LoaderType::NeoForge => {
753                    if neo_forge_old {
754                        lib.name.starts_with("net.neoforged:forge")
755                    } else {
756                        lib.name.starts_with("net.neoforged:neoforge")
757                    }
758                }
759                _ => false,
760            })
761            .map(|lib| lib.name.clone())
762    });
763
764    for (key, entry) in data {
765        let client_val = entry.client.trim();
766
767        // Only extract files that are embedded in the installer (path starts with '/').
768        if !client_val.starts_with('/') {
769            continue;
770        }
771        let in_jar_path = &client_val[1..]; // strip leading '/'
772
773        let dest: PathBuf = if key == "BINPATCH" {
774            let coord = profile
775                .path
776                .as_deref()
777                .or_else(|| profile.install.as_ref().and_then(|i| i.path.as_deref()))
778                .or(universal_name.as_deref())
779                .unwrap_or("");
780
781            if coord.is_empty() {
782                continue;
783            }
784
785            let info = match get_path_libraries(coord, None, None) {
786                Ok(i) => i,
787                Err(_) => continue,
788            };
789            let lzma_name = info.name.replace(".jar", "-clientdata.lzma");
790            libs_dir.join(&info.path).join(lzma_name)
791        } else {
792            libs_dir.join(in_jar_path)
793        };
794
795        if dest.exists() {
796            continue;
797        }
798
799        let result = get_file_from_archive(
800            PathBuf::from(installer_path),
801            Some(in_jar_path.to_owned()),
802            None,
803            false,
804        )
805        .await
806        .map_err(|e| LoaderError::Archive(e.to_string()))?;
807
808        let bytes = match result {
809            ArchiveQueryResult::FileData(b) => b,
810            _ => continue,
811        };
812
813        if let Some(parent) = dest.parent() {
814            tokio::fs::create_dir_all(parent).await?;
815        }
816        tokio::fs::write(&dest, &bytes).await?;
817    }
818
819    Ok(())
820}
821
822// ── Tests ─────────────────────────────────────────────────────────────────────
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    #[test]
829    fn fallback_metadata_is_valid_json() {
830        let parsed: serde_json::Value = serde_json::from_slice(FALLBACK_META).unwrap();
831        assert!(parsed.is_object(), "forge metadata should be a JSON object");
832    }
833
834    #[test]
835    fn fallback_metadata_contains_versions() {
836        let parsed: HashMap<String, Vec<String>> =
837            serde_json::from_slice(FALLBACK_META).unwrap();
838        assert!(!parsed.is_empty());
839    }
840
841    #[test]
842    fn forge_mc_constructs() {
843        let _f = ForgeMC::new();
844    }
845
846    #[test]
847    fn build_library_assets_uses_explicit_artifact_path() {
848        let version = ForgeVersionSection {
849            id: Some("1.20.1-forge-47.4.20".into()),
850            libraries: Some(vec![LoaderLibrary {
851                name: "cpw.mods:bootstraplauncher:1.1.2".into(),
852                url: None,
853                downloads: Some(crate::models::loader::LoaderLibraryDownloads {
854                    artifact: Some(crate::models::loader::LoaderArtifact {
855                        sha1: Some("abc".into()),
856                        size: Some(123),
857                        path: Some("cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar".into()),
858                        url: "https://example.com/x.jar".into(),
859                    }),
860                }),
861                rules: None,
862            }]),
863            main_class: None,
864            minecraft_arguments: None,
865            arguments: None,
866            extra: HashMap::new(),
867        };
868        let base = PathBuf::from("/mc/loader/forge");
869        let items = build_library_assets(&base, &version);
870        assert_eq!(items.len(), 1);
871        match &items[0] {
872            AssetItem::Asset { path, .. } => {
873                assert!(path.ends_with("bootstraplauncher-1.1.2.jar"), "got {path}");
874                assert!(path.contains("loader/forge/libraries/cpw/mods"));
875            }
876            _ => panic!("expected Asset"),
877        }
878    }
879
880    #[test]
881    fn build_library_assets_skips_rule_restricted() {
882        let version = ForgeVersionSection {
883            id: None,
884            libraries: Some(vec![LoaderLibrary {
885                name: "x:y:1".into(),
886                url: None,
887                downloads: None,
888                rules: Some(vec![serde_json::json!({"action":"disallow"})]),
889            }]),
890            main_class: None,
891            minecraft_arguments: None,
892            arguments: None,
893            extra: HashMap::new(),
894        };
895        let items = build_library_assets(Path::new("/mc/loader/forge"), &version);
896        assert!(items.is_empty());
897    }
898}