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 = 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
225async 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
439fn 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 format!("{minor}.{patch}.")
449 } else {
450 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#[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 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}