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