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
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 = "https://maven.neoforged.net/releases/net/neoforged/forge";
26const NEW_MAVEN: &str = "https://maven.neoforged.net/releases/net/neoforged/neoforge";
27
28fn 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
45pub struct NeoForgeMC;
48
49impl NeoForgeMC {
50 pub fn new() -> Self {
51 Self
52 }
53
54 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 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 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 =
198 Downloader::new(options.timeout_secs, 1, options.force_ipv4, options.dns);
199 downloader
200 .download_multiple(vec![item], event_tx.clone())
201 .await
202 .map_err(|e| {
203 LoaderError::Io(std::io::Error::new(
204 std::io::ErrorKind::Other,
205 e.to_string(),
206 ))
207 })?;
208 }
209
210 Ok(InstallerInfo {
211 file_path: installer_path.to_string_lossy().into_owned(),
212 meta_data: chosen.clone(),
213 ext: "jar".into(),
214 id: format!("neoforge-{chosen}"),
215 old_api,
216 })
217 }
218}
219
220impl Default for NeoForgeMC {
221 fn default() -> Self {
222 Self::new()
223 }
224}
225
226async fn read_installer_version_id(installer_path: &str) -> Result<String, LoaderError> {
229 let result = get_file_from_archive(
230 PathBuf::from(installer_path),
231 Some("install_profile.json".into()),
232 None,
233 false,
234 )
235 .await
236 .map_err(|e| LoaderError::Archive(e.to_string()))?;
237
238 let bytes = match result {
239 ArchiveQueryResult::FileData(b) => b,
240 _ => return Err(LoaderError::ProfileNotFound),
241 };
242
243 let raw: serde_json::Value = serde_json::from_slice(&bytes)?;
244
245 if let Some(v) = raw.get("version").and_then(|v| v.as_str()) {
246 return Ok(v.to_owned());
247 }
248 if let Some(v) = raw
249 .get("install")
250 .and_then(|i| i.get("version"))
251 .and_then(|v| v.as_str())
252 {
253 return Ok(v.to_owned());
254 }
255 if let Some(v) = raw
256 .get("versionInfo")
257 .and_then(|i| i.get("id"))
258 .and_then(|v| v.as_str())
259 {
260 return Ok(v.to_owned());
261 }
262
263 Err(LoaderError::ApiError(
264 "Could not determine version ID from install_profile.json".into(),
265 ))
266}
267
268async fn prepare_install_dir(
269 loader_base: &Path,
270 mc_version: &str,
271 mc_jar: &str,
272 mc_json: &str,
273) -> Result<(), LoaderError> {
274 let profiles_path = loader_base.join("launcher_profiles.json");
275 if !profiles_path.exists() {
276 tokio::fs::write(&profiles_path, b"{\"profiles\":{}}\n").await?;
277 }
278
279 let dest_dir = loader_base.join("versions").join(mc_version);
280 tokio::fs::create_dir_all(&dest_dir).await?;
281
282 let dest_jar = dest_dir.join(format!("{mc_version}.jar"));
283 if !dest_jar.exists() {
284 tokio::fs::copy(mc_jar, &dest_jar).await?;
285 }
286 let dest_json = dest_dir.join(format!("{mc_version}.json"));
287 if !dest_json.exists() {
288 tokio::fs::copy(mc_json, &dest_json).await?;
289 }
290
291 Ok(())
292}
293
294async fn run_installer(
295 java_path: &str,
296 installer_path: &str,
297 loader_base: &Path,
298 event_tx: &Sender<LaunchEvent>,
299) -> Result<(), LoaderError> {
300 let _ = event_tx
301 .send(LaunchEvent::Patch(format!(
302 "Running NeoForge installer: {}",
303 installer_path
304 )))
305 .await;
306
307 let mut child = tokio::process::Command::new(java_path)
308 .arg("-jar")
309 .arg(installer_path)
310 .arg("--installClient")
311 .arg(loader_base.as_os_str())
312 .stdout(Stdio::piped())
313 .stderr(Stdio::piped())
314 .spawn()
315 .map_err(LoaderError::Io)?;
316
317 if let Some(stdout) = child.stdout.take() {
318 let tx = event_tx.clone();
319 let mut lines = BufReader::new(stdout).lines();
320 tokio::spawn(async move {
321 while let Ok(Some(line)) = lines.next_line().await {
322 let _ = tx.send(LaunchEvent::Patch(line)).await;
323 }
324 });
325 }
326 if let Some(stderr) = child.stderr.take() {
327 let tx = event_tx.clone();
328 let mut lines = BufReader::new(stderr).lines();
329 tokio::spawn(async move {
330 while let Ok(Some(line)) = lines.next_line().await {
331 let _ = tx.send(LaunchEvent::Patch(line)).await;
332 }
333 });
334 }
335
336 let status = child.wait().await.map_err(LoaderError::Io)?;
337 if !status.success() {
338 let _ = event_tx
339 .send(LaunchEvent::Patch(format!(
340 "NeoForge installer exited with code {:?} (checking for version JSON)",
341 status.code()
342 )))
343 .await;
344 }
345 Ok(())
346}
347
348async fn read_version_json(path: &Path) -> Result<ForgeVersionSection, LoaderError> {
349 let bytes = tokio::fs::read(path).await?;
350 let version: ForgeVersionSection = serde_json::from_slice(&bytes)?;
351 Ok(version)
352}
353
354fn extract_game_args(version: &ForgeVersionSection) -> Vec<String> {
355 let mut args: Vec<String> = Vec::new();
356 if let Some(mc_args) = &version.minecraft_arguments {
357 for token in mc_args.split_whitespace() {
358 args.push(token.to_owned());
359 }
360 }
361 if let Some(forge_args) = &version.arguments {
362 for entry in &forge_args.game {
363 if let Some(s) = entry.as_str() {
364 args.push(s.to_owned());
365 }
366 }
367 }
368 args
369}
370
371fn extract_jvm_args(
372 loader_base: &Path,
373 version_id: &str,
374 version: &ForgeVersionSection,
375) -> Vec<String> {
376 let lib_dir = loader_base.join("libraries").to_string_lossy().into_owned();
377 let sep = if cfg!(target_os = "windows") {
378 ";"
379 } else {
380 ":"
381 };
382 let mut args = Vec::new();
383 if let Some(forge_args) = &version.arguments {
384 for entry in &forge_args.jvm {
385 if let Some(s) = entry.as_str() {
386 args.push(
387 s.replace("${library_directory}", &lib_dir)
388 .replace("${classpath_separator}", sep)
389 .replace("${version_name}", version_id),
390 );
391 }
392 }
393 }
394 args
395}
396
397fn build_library_assets(loader_base: &Path, version: &ForgeVersionSection) -> Vec<AssetItem> {
398 let libs = version.libraries.as_deref().unwrap_or(&[]);
399 let mut items: Vec<AssetItem> = Vec::with_capacity(libs.len());
400
401 for lib in libs {
402 if lib.rules.is_some() {
403 continue;
404 }
405 let (path, sha1, size, url) = resolve_library_entry(loader_base, lib);
406 items.push(AssetItem::Asset {
407 path,
408 sha1,
409 size,
410 url,
411 });
412 }
413
414 items
415}
416
417fn resolve_library_entry(loader_base: &Path, lib: &LoaderLibrary) -> (String, String, u64, String) {
418 let libs_dir = loader_base.join("libraries");
419
420 let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
421
422 let rel_path = artifact
423 .and_then(|a| a.path.clone())
424 .or_else(|| {
425 get_path_libraries(&lib.name, None, None)
426 .ok()
427 .map(|info| format!("{}/{}", info.path, info.name))
428 })
429 .unwrap_or_default();
430
431 let abs_path = libs_dir.join(&rel_path);
432
433 let sha1 = artifact.and_then(|a| a.sha1.clone()).unwrap_or_default();
434 let size = artifact.and_then(|a| a.size).unwrap_or(0);
435 let url = artifact.map(|a| a.url.clone()).unwrap_or_default();
436
437 (abs_path.to_string_lossy().into_owned(), sha1, size, url)
438}
439
440fn make_short_prefix(mc_version: &str) -> String {
443 let parts: Vec<&str> = mc_version.splitn(3, '.').collect();
444 let major = parts.first().copied().unwrap_or("1");
445 let minor = parts.get(1).copied().unwrap_or("0");
446 let patch = parts.get(2).copied().unwrap_or("0");
447 if major == "1" {
448 format!("{minor}.{patch}.")
450 } else {
451 format!("{major}.{minor}.")
453 }
454}
455
456fn resolve_neo_build(build: &str, versions: &[String]) -> Result<String, LoaderError> {
457 match build {
458 "latest" => versions
459 .last()
460 .cloned()
461 .ok_or_else(|| LoaderError::VersionNotFound("No NeoForge builds available".into())),
462 "recommended" => versions
463 .iter()
464 .rev()
465 .find(|v| !v.contains("beta"))
466 .cloned()
467 .or_else(|| versions.last().cloned())
468 .ok_or_else(|| LoaderError::VersionNotFound("No stable NeoForge build found".into())),
469 specific => versions
470 .iter()
471 .find(|v| v.as_str() == specific)
472 .cloned()
473 .ok_or_else(|| {
474 let available = versions.join(", ");
475 LoaderError::VersionNotFound(format!(
476 "NeoForge build {specific} not found. Available: {available}"
477 ))
478 }),
479 }
480}
481
482#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn make_short_prefix_splits_correctly() {
490 assert_eq!(make_short_prefix("1.20.4"), "20.4.");
491 assert_eq!(make_short_prefix("1.21.0"), "21.0.");
492 assert_eq!(make_short_prefix("1.21"), "21.0.");
493 assert_eq!(make_short_prefix("26.1.2"), "26.1.");
495 assert_eq!(make_short_prefix("26.2.0"), "26.2.");
496 }
497
498 #[test]
499 fn resolve_neo_build_latest() {
500 let versions = vec!["20.4.1".into(), "20.4.2".into(), "20.4.3-beta".into()];
501 let result = resolve_neo_build("latest", &versions).unwrap();
502 assert_eq!(result, "20.4.3-beta");
503 }
504
505 #[test]
506 fn resolve_neo_build_recommended_skips_beta() {
507 let versions = vec!["20.4.1".into(), "20.4.2".into(), "20.4.3-beta".into()];
508 let result = resolve_neo_build("recommended", &versions).unwrap();
509 assert_eq!(result, "20.4.2");
510 }
511
512 #[test]
513 fn resolve_neo_build_specific() {
514 let versions = vec!["20.4.1".into(), "20.4.2".into()];
515 let result = resolve_neo_build("20.4.1", &versions).unwrap();
516 assert_eq!(result, "20.4.1");
517 }
518
519 #[test]
520 fn resolve_neo_build_specific_not_found() {
521 let versions = vec!["20.4.1".into()];
522 assert!(resolve_neo_build("99.9.9", &versions).is_err());
523 }
524
525 #[test]
526 fn parse_maven_xml_versions_extracts_all() {
527 let xml = "<metadata><versioning><versions>\
528 <version>1.0</version><version>1.1</version>\
529 </versions></versioning></metadata>";
530 let v = parse_maven_xml_versions(xml);
531 assert_eq!(v, vec!["1.0", "1.1"]);
532 }
533
534 #[test]
535 fn neo_forge_mc_constructs() {
536 let _n = NeoForgeMC::new();
537 }
538}