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