minecraft_java_rs_core/loader/
neoforge.rs1use 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
18const 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
30fn 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
47pub struct NeoForgeMC;
50
51impl NeoForgeMC {
52 pub fn new() -> Self {
53 Self
54 }
55
56 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 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 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
208async 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
412fn 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 format!("{minor}.{patch}.")
422 } else {
423 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#[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 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}