Skip to main content

minecraft_java_rs_core/loader/
neoforge.rs

1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3
4use tokio::io::{AsyncBufReadExt, BufReader};
5use tokio::sync::mpsc::Sender;
6
7use crate::error::LoaderError;
8use crate::launcher::events::LaunchEvent;
9use crate::launcher::options::LaunchOptions;
10use crate::loader::forge::try_patcher_install;
11use crate::models::loader::{ForgeVersionSection, InstallerInfo, LoaderLibrary, LoaderType};
12use crate::models::minecraft::AssetItem;
13use crate::net::downloader::{DownloadItem, Downloader};
14use crate::net::http::fetch_text;
15use crate::utils::archive::{get_file_from_archive, ArchiveQueryResult};
16use crate::utils::paths::get_path_libraries;
17
18// ── Constants ─────────────────────────────────────────────────────────────────
19
20const LEGACY_META_URL: &str =
21    "https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml";
22const NEW_META_URL: &str =
23    "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml";
24
25const LEGACY_MAVEN: &str = "https://maven.neoforged.net/releases/net/neoforged/forge";
26const NEW_MAVEN: &str = "https://maven.neoforged.net/releases/net/neoforged/neoforge";
27
28// ── XML maven-metadata.xml parser ─────────────────────────────────────────────
29
30fn parse_maven_xml_versions(xml: &str) -> Vec<String> {
31    let mut versions = Vec::new();
32    let mut rest = xml;
33    while let Some(start) = rest.find("<version>") {
34        rest = &rest[start + 9..];
35        if let Some(end) = rest.find("</version>") {
36            versions.push(rest[..end].trim().to_owned());
37            rest = &rest[end + 10..];
38        } else {
39            break;
40        }
41    }
42    versions
43}
44
45// ── Public API ────────────────────────────────────────────────────────────────
46
47pub struct NeoForgeMC;
48
49impl NeoForgeMC {
50    pub fn new() -> Self {
51        Self
52    }
53
54    /// Install NeoForge by running the installer JAR with `--installClient`.
55    pub async fn install(
56        &self,
57        options: &LaunchOptions,
58        mc_version: &str,
59        java_path: &str,
60        mc_jar: &str,
61        mc_json: &str,
62        build: &str,
63        client: &reqwest::Client,
64        event_tx: &Sender<LaunchEvent>,
65    ) -> Result<
66        (
67            String,
68            Option<String>,
69            Vec<AssetItem>,
70            Vec<String>,
71            Vec<String>,
72        ),
73        LoaderError,
74    > {
75        let loader_base = options.loader_dir("neoforge");
76        tokio::fs::create_dir_all(&loader_base).await?;
77
78        let installer = self
79            .download_installer(options, mc_version, build, client, event_tx)
80            .await?;
81
82        let version_id = read_installer_version_id(&installer.file_path).await?;
83        let version_json_path = loader_base
84            .join("versions")
85            .join(&version_id)
86            .join(format!("{version_id}.json"));
87
88        if !version_json_path.exists() {
89            let used_patcher = try_patcher_install(
90                &installer.file_path,
91                &loader_base,
92                &version_json_path,
93                mc_jar,
94                mc_json,
95                java_path,
96                &options.path,
97                options,
98                LoaderType::NeoForge,
99                installer.old_api,
100                event_tx,
101            )
102            .await;
103
104            if !used_patcher {
105                prepare_install_dir(&loader_base, mc_version, mc_jar, mc_json).await?;
106                run_installer(java_path, &installer.file_path, &loader_base, event_tx).await?;
107            }
108
109            if !version_json_path.exists() {
110                return Err(LoaderError::ApiError(format!(
111                    "NeoForge installer finished but no version JSON was created at {}",
112                    version_json_path.display()
113                )));
114            }
115        }
116
117        let version_json = read_version_json(&version_json_path).await?;
118        let libraries = build_library_assets(&loader_base, &version_json);
119        let extra_game_args = extract_game_args(&version_json);
120        let extra_jvm_args = extract_jvm_args(&loader_base, &version_id, &version_json);
121        let main_class = version_json.main_class;
122
123        Ok((
124            version_id,
125            main_class,
126            libraries,
127            extra_game_args,
128            extra_jvm_args,
129        ))
130    }
131
132    /// Download the NeoForge installer JAR.
133    pub async fn download_installer(
134        &self,
135        options: &LaunchOptions,
136        mc_version: &str,
137        build: &str,
138        client: &reqwest::Client,
139        event_tx: &Sender<LaunchEvent>,
140    ) -> Result<InstallerInfo, LoaderError> {
141        // Try legacy API first.
142        let legacy = client.get(LEGACY_META_URL).send().await.ok();
143        let (legacy_versions, _old_api) =
144            if let Some(r) = legacy.filter(|r| r.status().is_success()) {
145                let text = r.text().await.unwrap_or_default();
146                let prefix = format!("{mc_version}-");
147                let filtered: Vec<String> = parse_maven_xml_versions(&text)
148                    .into_iter()
149                    .filter(|v| v.starts_with(&prefix))
150                    .collect();
151                (filtered, true)
152            } else {
153                (Vec::new(), true)
154            };
155
156        let (versions, old_api) = if legacy_versions.is_empty() {
157            let text = fetch_text(client, NEW_META_URL)
158                .await
159                .map_err(LoaderError::ApiError)?;
160            let short_prefix = make_short_prefix(mc_version);
161            let filtered: Vec<String> = parse_maven_xml_versions(&text)
162                .into_iter()
163                .filter(|v| v.starts_with(&short_prefix))
164                .collect();
165            if filtered.is_empty() {
166                return Err(LoaderError::VersionNotFound(format!(
167                    "NeoForge doesn't support Minecraft {mc_version}"
168                )));
169            }
170            (filtered, false)
171        } else {
172            (legacy_versions, true)
173        };
174
175        let chosen = resolve_neo_build(build, &versions)?;
176        let (maven_base, artifact_prefix) = if old_api {
177            (LEGACY_MAVEN, "forge")
178        } else {
179            (NEW_MAVEN, "neoforge")
180        };
181
182        let installer_name = format!("{artifact_prefix}-{chosen}-installer.jar");
183        let installer_folder = options.loader_dir("neoforge").join("installer");
184        let installer_path = installer_folder.join(&installer_name);
185
186        if !installer_path.exists() {
187            let url = format!("{maven_base}/{chosen}/{installer_name}");
188            let item = DownloadItem {
189                url,
190                path: installer_path.clone(),
191                folder: installer_folder.clone(),
192                name: installer_name.clone(),
193                size: 0,
194                r#type: Some("neoforge".into()),
195                sha1: None,
196            };
197            let downloader = Downloader::new(options.timeout_secs, 1, options.force_ipv4);
198            downloader
199                .download_multiple(vec![item], event_tx.clone())
200                .await
201                .map_err(|e| {
202                    LoaderError::Io(std::io::Error::new(
203                        std::io::ErrorKind::Other,
204                        e.to_string(),
205                    ))
206                })?;
207        }
208
209        Ok(InstallerInfo {
210            file_path: installer_path.to_string_lossy().into_owned(),
211            meta_data: chosen.clone(),
212            ext: "jar".into(),
213            id: format!("neoforge-{chosen}"),
214            old_api,
215        })
216    }
217}
218
219impl Default for NeoForgeMC {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225// ── Installer driver (identical to Forge) ─────────────────────────────────────
226
227async fn read_installer_version_id(installer_path: &str) -> Result<String, LoaderError> {
228    let result = get_file_from_archive(
229        PathBuf::from(installer_path),
230        Some("install_profile.json".into()),
231        None,
232        false,
233    )
234    .await
235    .map_err(|e| LoaderError::Archive(e.to_string()))?;
236
237    let bytes = match result {
238        ArchiveQueryResult::FileData(b) => b,
239        _ => return Err(LoaderError::ProfileNotFound),
240    };
241
242    let raw: serde_json::Value = serde_json::from_slice(&bytes)?;
243
244    if let Some(v) = raw.get("version").and_then(|v| v.as_str()) {
245        return Ok(v.to_owned());
246    }
247    if let Some(v) = raw
248        .get("install")
249        .and_then(|i| i.get("version"))
250        .and_then(|v| v.as_str())
251    {
252        return Ok(v.to_owned());
253    }
254    if let Some(v) = raw
255        .get("versionInfo")
256        .and_then(|i| i.get("id"))
257        .and_then(|v| v.as_str())
258    {
259        return Ok(v.to_owned());
260    }
261
262    Err(LoaderError::ApiError(
263        "Could not determine version ID from install_profile.json".into(),
264    ))
265}
266
267async fn prepare_install_dir(
268    loader_base: &Path,
269    mc_version: &str,
270    mc_jar: &str,
271    mc_json: &str,
272) -> Result<(), LoaderError> {
273    let profiles_path = loader_base.join("launcher_profiles.json");
274    if !profiles_path.exists() {
275        tokio::fs::write(&profiles_path, b"{\"profiles\":{}}\n").await?;
276    }
277
278    let dest_dir = loader_base.join("versions").join(mc_version);
279    tokio::fs::create_dir_all(&dest_dir).await?;
280
281    let dest_jar = dest_dir.join(format!("{mc_version}.jar"));
282    if !dest_jar.exists() {
283        tokio::fs::copy(mc_jar, &dest_jar).await?;
284    }
285    let dest_json = dest_dir.join(format!("{mc_version}.json"));
286    if !dest_json.exists() {
287        tokio::fs::copy(mc_json, &dest_json).await?;
288    }
289
290    Ok(())
291}
292
293async fn run_installer(
294    java_path: &str,
295    installer_path: &str,
296    loader_base: &Path,
297    event_tx: &Sender<LaunchEvent>,
298) -> Result<(), LoaderError> {
299    let _ = event_tx
300        .send(LaunchEvent::Patch(format!(
301            "Running NeoForge installer: {}",
302            installer_path
303        )))
304        .await;
305
306    let mut child = tokio::process::Command::new(java_path)
307        .arg("-jar")
308        .arg(installer_path)
309        .arg("--installClient")
310        .arg(loader_base.as_os_str())
311        .stdout(Stdio::piped())
312        .stderr(Stdio::piped())
313        .spawn()
314        .map_err(LoaderError::Io)?;
315
316    if let Some(stdout) = child.stdout.take() {
317        let tx = event_tx.clone();
318        let mut lines = BufReader::new(stdout).lines();
319        tokio::spawn(async move {
320            while let Ok(Some(line)) = lines.next_line().await {
321                let _ = tx.send(LaunchEvent::Patch(line)).await;
322            }
323        });
324    }
325    if let Some(stderr) = child.stderr.take() {
326        let tx = event_tx.clone();
327        let mut lines = BufReader::new(stderr).lines();
328        tokio::spawn(async move {
329            while let Ok(Some(line)) = lines.next_line().await {
330                let _ = tx.send(LaunchEvent::Patch(line)).await;
331            }
332        });
333    }
334
335    let status = child.wait().await.map_err(LoaderError::Io)?;
336    if !status.success() {
337        let _ = event_tx
338            .send(LaunchEvent::Patch(format!(
339                "NeoForge installer exited with code {:?} (checking for version JSON)",
340                status.code()
341            )))
342            .await;
343    }
344    Ok(())
345}
346
347async fn read_version_json(path: &Path) -> Result<ForgeVersionSection, LoaderError> {
348    let bytes = tokio::fs::read(path).await?;
349    let version: ForgeVersionSection = serde_json::from_slice(&bytes)?;
350    Ok(version)
351}
352
353fn extract_game_args(version: &ForgeVersionSection) -> Vec<String> {
354    let mut args: Vec<String> = Vec::new();
355    if let Some(mc_args) = &version.minecraft_arguments {
356        for token in mc_args.split_whitespace() {
357            args.push(token.to_owned());
358        }
359    }
360    if let Some(forge_args) = &version.arguments {
361        for entry in &forge_args.game {
362            if let Some(s) = entry.as_str() {
363                args.push(s.to_owned());
364            }
365        }
366    }
367    args
368}
369
370fn extract_jvm_args(
371    loader_base: &Path,
372    version_id: &str,
373    version: &ForgeVersionSection,
374) -> Vec<String> {
375    let lib_dir = loader_base.join("libraries").to_string_lossy().into_owned();
376    let sep = if cfg!(target_os = "windows") {
377        ";"
378    } else {
379        ":"
380    };
381    let mut args = Vec::new();
382    if let Some(forge_args) = &version.arguments {
383        for entry in &forge_args.jvm {
384            if let Some(s) = entry.as_str() {
385                args.push(
386                    s.replace("${library_directory}", &lib_dir)
387                        .replace("${classpath_separator}", sep)
388                        .replace("${version_name}", version_id),
389                );
390            }
391        }
392    }
393    args
394}
395
396fn build_library_assets(loader_base: &Path, version: &ForgeVersionSection) -> Vec<AssetItem> {
397    let libs = version.libraries.as_deref().unwrap_or(&[]);
398    let mut items: Vec<AssetItem> = Vec::with_capacity(libs.len());
399
400    for lib in libs {
401        if lib.rules.is_some() {
402            continue;
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    let url = artifact.map(|a| a.url.clone()).unwrap_or_default();
435
436    (abs_path.to_string_lossy().into_owned(), sha1, size, url)
437}
438
439// ── Helpers ───────────────────────────────────────────────────────────────────
440
441fn make_short_prefix(mc_version: &str) -> String {
442    let parts: Vec<&str> = mc_version.splitn(3, '.').collect();
443    let major = parts.first().copied().unwrap_or("1");
444    let minor = parts.get(1).copied().unwrap_or("0");
445    let patch = parts.get(2).copied().unwrap_or("0");
446    if major == "1" {
447        // Old Minecraft versioning: "1.20.4" → NeoForge "20.4."
448        format!("{minor}.{patch}.")
449    } else {
450        // New Minecraft versioning: "26.1.2" → NeoForge "26.1."
451        format!("{major}.{minor}.")
452    }
453}
454
455fn resolve_neo_build(build: &str, versions: &[String]) -> Result<String, LoaderError> {
456    match build {
457        "latest" => versions
458            .last()
459            .cloned()
460            .ok_or_else(|| LoaderError::VersionNotFound("No NeoForge builds available".into())),
461        "recommended" => versions
462            .iter()
463            .rev()
464            .find(|v| !v.contains("beta"))
465            .cloned()
466            .or_else(|| versions.last().cloned())
467            .ok_or_else(|| LoaderError::VersionNotFound("No stable NeoForge build found".into())),
468        specific => versions
469            .iter()
470            .find(|v| v.as_str() == specific)
471            .cloned()
472            .ok_or_else(|| {
473                let available = versions.join(", ");
474                LoaderError::VersionNotFound(format!(
475                    "NeoForge build {specific} not found. Available: {available}"
476                ))
477            }),
478    }
479}
480
481// ── Tests ─────────────────────────────────────────────────────────────────────
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn make_short_prefix_splits_correctly() {
489        assert_eq!(make_short_prefix("1.20.4"), "20.4.");
490        assert_eq!(make_short_prefix("1.21.0"), "21.0.");
491        assert_eq!(make_short_prefix("1.21"), "21.0.");
492        // New Minecraft versioning (post-1.x era)
493        assert_eq!(make_short_prefix("26.1.2"), "26.1.");
494        assert_eq!(make_short_prefix("26.2.0"), "26.2.");
495    }
496
497    #[test]
498    fn resolve_neo_build_latest() {
499        let versions = vec!["20.4.1".into(), "20.4.2".into(), "20.4.3-beta".into()];
500        let result = resolve_neo_build("latest", &versions).unwrap();
501        assert_eq!(result, "20.4.3-beta");
502    }
503
504    #[test]
505    fn resolve_neo_build_recommended_skips_beta() {
506        let versions = vec!["20.4.1".into(), "20.4.2".into(), "20.4.3-beta".into()];
507        let result = resolve_neo_build("recommended", &versions).unwrap();
508        assert_eq!(result, "20.4.2");
509    }
510
511    #[test]
512    fn resolve_neo_build_specific() {
513        let versions = vec!["20.4.1".into(), "20.4.2".into()];
514        let result = resolve_neo_build("20.4.1", &versions).unwrap();
515        assert_eq!(result, "20.4.1");
516    }
517
518    #[test]
519    fn resolve_neo_build_specific_not_found() {
520        let versions = vec!["20.4.1".into()];
521        assert!(resolve_neo_build("99.9.9", &versions).is_err());
522    }
523
524    #[test]
525    fn parse_maven_xml_versions_extracts_all() {
526        let xml = "<metadata><versioning><versions>\
527                   <version>1.0</version><version>1.1</version>\
528                   </versions></versioning></metadata>";
529        let v = parse_maven_xml_versions(xml);
530        assert_eq!(v, vec!["1.0", "1.1"]);
531    }
532
533    #[test]
534    fn neo_forge_mc_constructs() {
535        let _n = NeoForgeMC::new();
536    }
537}