1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use serde::Deserialize;
6use tokio::io::{AsyncBufReadExt, BufReader};
7use tokio::sync::mpsc::Sender;
8
9use crate::error::LoaderError;
10use crate::launcher::events::LaunchEvent;
11use crate::launcher::options::LaunchOptions;
12use crate::loader::forge_patcher::{ForgePatcher, PatchConfig};
13use crate::models::loader::{ForgeProfile, ForgeVersionSection, InstallerInfo, LoaderLibrary, LoaderType};
14use crate::models::minecraft::AssetItem;
15use crate::net::downloader::{DownloadItem, Downloader};
16use crate::utils::archive::{get_file_from_archive, ArchiveQueryResult};
17use crate::utils::paths::get_path_libraries;
18
19const META_URL: &str =
22 "https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json";
23const PROMOTIONS_URL: &str =
24 "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
25const MAVEN_BASE: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge";
26
27static FALLBACK_META: &[u8] =
28 include_bytes!("../../assets/forge/maven-metadata.json");
29
30#[derive(Deserialize)]
31struct Promotions {
32 promos: HashMap<String, String>,
33}
34
35pub struct ForgeMC;
38
39impl ForgeMC {
40 pub fn new() -> Self {
41 Self
42 }
43
44 pub async fn install(
49 &self,
50 options: &LaunchOptions,
51 mc_version: &str,
52 java_path: &str,
53 mc_jar: &str,
54 mc_json: &str,
55 build: &str,
56 client: &reqwest::Client,
57 event_tx: &Sender<LaunchEvent>,
58 ) -> Result<(String, Option<String>, Vec<AssetItem>, Vec<String>, Vec<String>), LoaderError> {
59 let loader_base = options.loader_dir("forge");
60 tokio::fs::create_dir_all(&loader_base).await?;
61
62 let installer = self
64 .download_installer(options, mc_version, build, client, event_tx)
65 .await?;
66
67 let version_id = read_installer_version_id(&installer.file_path).await?;
69 let version_json_path = loader_base
70 .join("versions")
71 .join(&version_id)
72 .join(format!("{version_id}.json"));
73
74 if !version_json_path.exists() {
76 let used_patcher = try_patcher_install(
77 &installer.file_path,
78 &loader_base,
79 &version_json_path,
80 mc_jar,
81 mc_json,
82 java_path,
83 &options.path,
84 options,
85 LoaderType::Forge,
86 false, event_tx,
88 )
89 .await;
90
91 if !used_patcher {
92 prepare_install_dir(&loader_base, mc_version, mc_jar, mc_json).await?;
93 run_installer(java_path, &installer.file_path, &loader_base, event_tx).await?;
94 }
95
96 if !version_json_path.exists() {
97 return Err(LoaderError::ApiError(format!(
98 "Forge install finished but no version JSON found at {}",
99 version_json_path.display()
100 )));
101 }
102 }
103
104 let version_json = read_version_json(&version_json_path).await?;
106 let libraries = build_library_assets(&loader_base, &version_json);
107 let extra_game_args = extract_game_args(&version_json);
108 let extra_jvm_args = extract_jvm_args(&loader_base, &version_id, &version_json);
109 let main_class = version_json.main_class;
110
111 Ok((version_id, main_class, libraries, extra_game_args, extra_jvm_args))
112 }
113
114 pub async fn download_installer(
116 &self,
117 options: &LaunchOptions,
118 mc_version: &str,
119 build: &str,
120 client: &reqwest::Client,
121 event_tx: &Sender<LaunchEvent>,
122 ) -> Result<InstallerInfo, LoaderError> {
123 let all_versions: HashMap<String, Vec<String>> =
124 match client.get(META_URL).send().await {
125 Ok(r) if r.status().is_success() => r.json().await?,
126 _ => serde_json::from_slice(FALLBACK_META)?,
127 };
128
129 let versions = all_versions.get(mc_version).ok_or_else(|| {
130 LoaderError::VersionNotFound(format!("Forge doesn't support Minecraft {mc_version}"))
131 })?;
132
133 let forge_build = resolve_forge_build(build, mc_version, versions, client).await?;
134
135 if !versions.iter().any(|v| v == &forge_build) {
136 let available = versions.join(", ");
137 return Err(LoaderError::VersionNotFound(format!(
138 "Forge build {forge_build} not found for {mc_version}. Available: {available}"
139 )));
140 }
141
142 let installer_name = format!("forge-{forge_build}-installer.jar");
143 let installer_folder = options
144 .loader_dir("forge")
145 .join("installer");
146 let installer_path = installer_folder.join(&installer_name);
147
148 if !installer_path.exists() {
149 let url = format!("{MAVEN_BASE}/{forge_build}/{installer_name}");
150 let item = DownloadItem {
151 url: url.clone(),
152 path: installer_path.clone(),
153 folder: installer_folder.clone(),
154 name: installer_name.clone(),
155 size: 0,
156 r#type: Some("forge".into()),
157 sha1: None,
158 };
159 let downloader = Downloader::new(options.timeout_secs, 1);
160 downloader
161 .download_multiple(vec![item], event_tx.clone())
162 .await
163 .map_err(|e| LoaderError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))?;
164 }
165
166 Ok(InstallerInfo {
167 file_path: installer_path.to_string_lossy().into_owned(),
168 meta_data: forge_build.clone(),
169 ext: "jar".into(),
170 id: format!("forge-{forge_build}"),
171 old_api: false,
172 })
173 }
174}
175
176impl Default for ForgeMC {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182async fn read_installer_version_id(installer_path: &str) -> Result<String, LoaderError> {
188 let result = get_file_from_archive(
189 PathBuf::from(installer_path),
190 Some("install_profile.json".into()),
191 None,
192 false,
193 )
194 .await
195 .map_err(|e| LoaderError::Archive(e.to_string()))?;
196
197 let bytes = match result {
198 ArchiveQueryResult::FileData(b) => b,
199 _ => return Err(LoaderError::ProfileNotFound),
200 };
201
202 let raw: serde_json::Value = serde_json::from_slice(&bytes)?;
203
204 if let Some(v) = raw.get("version").and_then(|v| v.as_str()) {
206 return Ok(v.to_owned());
207 }
208 if let Some(v) = raw
210 .get("install")
211 .and_then(|i| i.get("version"))
212 .and_then(|v| v.as_str())
213 {
214 return Ok(v.to_owned());
215 }
216 if let Some(v) = raw
217 .get("versionInfo")
218 .and_then(|i| i.get("id"))
219 .and_then(|v| v.as_str())
220 {
221 return Ok(v.to_owned());
222 }
223
224 Err(LoaderError::ApiError(
225 "Could not determine version ID from install_profile.json".into(),
226 ))
227}
228
229async fn prepare_install_dir(
232 loader_base: &Path,
233 mc_version: &str,
234 mc_jar: &str,
235 mc_json: &str,
236) -> Result<(), LoaderError> {
237 let profiles_path = loader_base.join("launcher_profiles.json");
238 if !profiles_path.exists() {
239 tokio::fs::write(&profiles_path, b"{\"profiles\":{}}\n").await?;
240 }
241
242 let dest_dir = loader_base.join("versions").join(mc_version);
243 tokio::fs::create_dir_all(&dest_dir).await?;
244
245 let dest_jar = dest_dir.join(format!("{mc_version}.jar"));
246 if !dest_jar.exists() {
247 tokio::fs::copy(mc_jar, &dest_jar).await?;
248 }
249 let dest_json = dest_dir.join(format!("{mc_version}.json"));
250 if !dest_json.exists() {
251 tokio::fs::copy(mc_json, &dest_json).await?;
252 }
253
254 Ok(())
255}
256
257async fn run_installer(
260 java_path: &str,
261 installer_path: &str,
262 loader_base: &Path,
263 event_tx: &Sender<LaunchEvent>,
264) -> Result<(), LoaderError> {
265 let _ = event_tx
266 .send(LaunchEvent::Patch(format!(
267 "Running Forge installer: {}",
268 installer_path
269 )))
270 .await;
271
272 let mut child = tokio::process::Command::new(java_path)
273 .arg("-jar")
274 .arg(installer_path)
275 .arg("--installClient")
276 .arg(loader_base.as_os_str())
277 .stdout(Stdio::piped())
278 .stderr(Stdio::piped())
279 .spawn()
280 .map_err(LoaderError::Io)?;
281
282 if let Some(stdout) = child.stdout.take() {
283 let tx = event_tx.clone();
284 let mut lines = BufReader::new(stdout).lines();
285 tokio::spawn(async move {
286 while let Ok(Some(line)) = lines.next_line().await {
287 let _ = tx.send(LaunchEvent::Patch(line)).await;
288 }
289 });
290 }
291 if let Some(stderr) = child.stderr.take() {
292 let tx = event_tx.clone();
293 let mut lines = BufReader::new(stderr).lines();
294 tokio::spawn(async move {
295 while let Ok(Some(line)) = lines.next_line().await {
296 let _ = tx.send(LaunchEvent::Patch(line)).await;
297 }
298 });
299 }
300
301 let status = child.wait().await.map_err(LoaderError::Io)?;
302 if !status.success() {
303 let _ = event_tx
307 .send(LaunchEvent::Patch(format!(
308 "Forge installer exited with code {:?} (checking for version JSON)",
309 status.code()
310 )))
311 .await;
312 }
313 Ok(())
314}
315
316async fn read_version_json(path: &Path) -> Result<ForgeVersionSection, LoaderError> {
317 let bytes = tokio::fs::read(path).await?;
318 let version: ForgeVersionSection = serde_json::from_slice(&bytes)?;
319 Ok(version)
320}
321
322fn extract_game_args(version: &ForgeVersionSection) -> Vec<String> {
325 let mut args: Vec<String> = Vec::new();
326 if let Some(mc_args) = &version.minecraft_arguments {
327 for token in mc_args.split_whitespace() {
328 args.push(token.to_owned());
329 }
330 }
331 if let Some(forge_args) = &version.arguments {
332 for entry in &forge_args.game {
333 if let Some(s) = entry.as_str() {
334 args.push(s.to_owned());
335 }
336 }
337 }
338 args
339}
340
341fn extract_jvm_args(loader_base: &Path, version_id: &str, version: &ForgeVersionSection) -> Vec<String> {
347 let lib_dir = loader_base.join("libraries").to_string_lossy().into_owned();
348 let sep = if cfg!(target_os = "windows") { ";" } else { ":" };
349 let mut args = Vec::new();
350 if let Some(forge_args) = &version.arguments {
351 for entry in &forge_args.jvm {
352 if let Some(s) = entry.as_str() {
353 args.push(
354 s.replace("${library_directory}", &lib_dir)
355 .replace("${classpath_separator}", sep)
356 .replace("${version_name}", version_id),
357 );
358 }
359 }
360 }
361 args
362}
363
364fn build_library_assets(loader_base: &Path, version: &ForgeVersionSection) -> Vec<AssetItem> {
366 let libs = version.libraries.as_deref().unwrap_or(&[]);
367 let mut items: Vec<AssetItem> = Vec::with_capacity(libs.len());
368
369 for lib in libs {
370 if lib.rules.is_some() {
371 continue;
372 }
373 if lib.clientreq == Some(false) {
375 continue;
376 }
377
378 let (path, sha1, size, url) = resolve_library_entry(loader_base, lib);
379 items.push(AssetItem::Asset { path, sha1, size, url });
380 }
381
382 items
383}
384
385fn resolve_library_entry(
386 loader_base: &Path,
387 lib: &LoaderLibrary,
388) -> (String, String, u64, String) {
389 let libs_dir = loader_base.join("libraries");
390
391 let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
392
393 let rel_path = artifact
394 .and_then(|a| a.path.clone())
395 .or_else(|| {
396 get_path_libraries(&lib.name, None, None)
397 .ok()
398 .map(|info| format!("{}/{}", info.path, info.name))
399 })
400 .unwrap_or_default();
401
402 let abs_path = libs_dir.join(&rel_path);
403
404 let sha1 = artifact.and_then(|a| a.sha1.clone()).unwrap_or_default();
405 let size = artifact.and_then(|a| a.size).unwrap_or(0);
406 let url = artifact
410 .map(|a| a.url.clone())
411 .filter(|u| !u.is_empty())
412 .or_else(|| {
413 lib.url.as_ref().filter(|u| !u.is_empty()).map(|base| {
414 format!("{}/{}", base.trim_end_matches('/'), &rel_path)
415 })
416 })
417 .or_else(|| {
418 if !rel_path.is_empty() {
419 Some(format!("https://libraries.minecraft.net/{rel_path}"))
420 } else {
421 None
422 }
423 })
424 .unwrap_or_default();
425
426 (abs_path.to_string_lossy().into_owned(), sha1, size, url)
427}
428
429fn match_promo_in_versions(candidate: &str, mc_version: &str, versions: &[String]) -> String {
436 if versions.iter().any(|v| v == candidate) {
437 return candidate.to_owned();
438 }
439 let with_suffix = format!("{candidate}-{mc_version}");
440 if versions.iter().any(|v| v == &with_suffix) {
441 return with_suffix;
442 }
443 candidate.to_owned()
444}
445
446async fn resolve_forge_build(
447 build: &str,
448 mc_version: &str,
449 versions: &[String],
450 client: &reqwest::Client,
451) -> Result<String, LoaderError> {
452 match build {
453 "latest" => {
454 if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
455 if let Ok(p) = promos.json::<Promotions>().await {
456 let key = format!("{mc_version}-latest");
457 if let Some(ver) = p.promos.get(&key) {
458 let candidate = format!("{mc_version}-{ver}");
459 return Ok(match_promo_in_versions(&candidate, mc_version, versions));
460 }
461 }
462 }
463 versions
464 .last()
465 .cloned()
466 .ok_or_else(|| LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}")))
467 }
468 "recommended" => {
469 if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
470 if let Ok(p) = promos.json::<Promotions>().await {
471 let rec_key = format!("{mc_version}-recommended");
472 let lat_key = format!("{mc_version}-latest");
473 let ver = p.promos.get(&rec_key).or_else(|| p.promos.get(&lat_key));
474 if let Some(v) = ver {
475 let candidate = format!("{mc_version}-{v}");
476 return Ok(match_promo_in_versions(&candidate, mc_version, versions));
477 }
478 }
479 }
480 versions
481 .last()
482 .cloned()
483 .ok_or_else(|| LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}")))
484 }
485 specific => Ok(specific.to_owned()),
486 }
487}
488
489pub(crate) async fn try_patcher_install(
497 installer_path: &str,
498 loader_base: &Path,
499 version_json_path: &Path,
500 mc_jar: &str,
501 mc_json: &str,
502 java_path: &str,
503 game_path: &Path,
504 options: &LaunchOptions,
505 loader_type: LoaderType,
506 neo_forge_old: bool,
507 event_tx: &Sender<LaunchEvent>,
508) -> bool {
509 match try_patcher_install_inner(
510 installer_path,
511 loader_base,
512 version_json_path,
513 mc_jar,
514 mc_json,
515 java_path,
516 game_path,
517 options,
518 loader_type,
519 neo_forge_old,
520 event_tx,
521 )
522 .await
523 {
524 Ok(result) => result,
525 Err(e) => {
526 let _ = event_tx
527 .send(LaunchEvent::Patch(format!(
528 "[patcher] Manual patch failed ({e}); falling back to --installClient"
529 )))
530 .await;
531 false
532 }
533 }
534}
535
536async fn try_patcher_install_inner(
537 installer_path: &str,
538 loader_base: &Path,
539 version_json_path: &Path,
540 mc_jar: &str,
541 mc_json: &str,
542 java_path: &str,
543 game_path: &Path,
544 options: &LaunchOptions,
545 loader_type: LoaderType,
546 neo_forge_old: bool,
547 event_tx: &Sender<LaunchEvent>,
548) -> Result<bool, LoaderError> {
549 let profile = read_install_profile(installer_path).await?;
551
552 let has_processors = profile.processors.as_ref().map_or(false, |p| !p.is_empty());
554 if !has_processors {
555 if profile.version_info.is_some() {
557 install_old_forge_legacy(installer_path, loader_base, version_json_path, &profile, event_tx).await?;
558 return Ok(true);
559 }
560 return Ok(false);
561 }
562
563 if !version_json_path.exists() {
565 extract_version_json(installer_path, version_json_path).await?;
566 }
567
568 let libs_dir = loader_base.join("libraries");
570 extract_maven_entries(installer_path, &libs_dir).await?;
571
572 download_profile_libraries(&profile, &libs_dir, options, event_tx).await?;
574
575 extract_data_files(installer_path, &profile, &libs_dir, &loader_type, neo_forge_old).await?;
577
578 let patcher = ForgePatcher::new(loader_base.to_path_buf(), loader_type);
580 if patcher.check(&profile) {
581 let _ = event_tx
582 .send(LaunchEvent::Patch("[patcher] Already patched, skipping".into()))
583 .await;
584 return Ok(true);
585 }
586
587 let config = PatchConfig {
589 java_path,
590 minecraft_jar: mc_jar,
591 minecraft_json: mc_json,
592 game_path,
593 };
594 patcher.patch(&profile, &config, neo_forge_old, event_tx).await?;
595 Ok(true)
596}
597
598async fn read_install_profile(installer_path: &str) -> Result<ForgeProfile, LoaderError> {
605 let result = get_file_from_archive(
606 PathBuf::from(installer_path),
607 Some("install_profile.json".into()),
608 None,
609 false,
610 )
611 .await
612 .map_err(|e| LoaderError::Archive(e.to_string()))?;
613
614 let bytes = match result {
615 ArchiveQueryResult::FileData(b) => b,
616 _ => return Err(LoaderError::ProfileNotFound),
617 };
618
619 let mut raw: serde_json::Value = serde_json::from_slice(&bytes)?;
620 if let Some(obj) = raw.as_object_mut() {
621 if obj.get("version").and_then(|v| v.as_str()).is_some() {
622 obj.remove("version");
623 }
624 }
625
626 let profile: ForgeProfile = serde_json::from_value(raw)?;
627 Ok(profile)
628}
629
630async fn install_old_forge_legacy(
635 installer_path: &str,
636 loader_base: &Path,
637 version_json_path: &Path,
638 profile: &ForgeProfile,
639 event_tx: &Sender<LaunchEvent>,
640) -> Result<(), LoaderError> {
641 let version_info = profile.version_info.as_ref().expect("caller checked Some");
642
643 if let Some(parent) = version_json_path.parent() {
644 tokio::fs::create_dir_all(parent).await?;
645 }
646 tokio::fs::write(version_json_path, serde_json::to_vec_pretty(version_info)?).await?;
647 let _ = event_tx.send(LaunchEvent::Patch("[patcher] Old-format Forge: wrote version JSON".into())).await;
648
649 if let Some(install) = &profile.install {
650 if let (Some(file_in_zip), Some(maven_coord)) = (&install.file_path, &install.path) {
651 if let Ok(lib_info) = get_path_libraries(maven_coord, None, None) {
652 let dest = loader_base.join("libraries").join(&lib_info.path).join(&lib_info.name);
653 if !dest.exists() {
654 let result = get_file_from_archive(
655 PathBuf::from(installer_path),
656 Some(file_in_zip.clone()),
657 None,
658 false,
659 )
660 .await
661 .map_err(|e| LoaderError::Archive(e.to_string()))?;
662
663 if let ArchiveQueryResult::FileData(bytes) = result {
664 if let Some(parent) = dest.parent() {
665 tokio::fs::create_dir_all(parent).await?;
666 }
667 tokio::fs::write(&dest, bytes).await?;
668 let _ = event_tx
669 .send(LaunchEvent::Patch(format!("[patcher] Old-format Forge: extracted {}", lib_info.name)))
670 .await;
671 }
672 }
673 }
674 }
675 }
676
677 Ok(())
678}
679
680async fn extract_version_json(installer_path: &str, dest_path: &Path) -> Result<(), LoaderError> {
681 let result = get_file_from_archive(
682 PathBuf::from(installer_path),
683 Some("version.json".into()),
684 None,
685 false,
686 )
687 .await
688 .map_err(|e| LoaderError::Archive(e.to_string()))?;
689
690 let bytes = match result {
691 ArchiveQueryResult::FileData(b) => b,
692 _ => {
693 return Err(LoaderError::ApiError(
694 "version.json not found in installer JAR".into(),
695 ))
696 }
697 };
698
699 if let Some(parent) = dest_path.parent() {
700 tokio::fs::create_dir_all(parent).await?;
701 }
702 tokio::fs::write(dest_path, &bytes).await?;
703 Ok(())
704}
705
706async fn extract_maven_entries(installer_path: &str, libs_dir: &Path) -> Result<(), LoaderError> {
711 let installer = PathBuf::from(installer_path);
712
713 let names =
714 match get_file_from_archive(installer.clone(), None, Some("maven/".into()), false)
715 .await
716 .map_err(|e| LoaderError::Archive(e.to_string()))?
717 {
718 ArchiveQueryResult::Names(n) => n,
719 _ => return Ok(()),
720 };
721
722 for name in names {
723 let rel = match name.strip_prefix("maven/") {
724 Some(r) if !r.is_empty() => r.to_owned(),
725 _ => continue,
726 };
727
728 let dest = libs_dir.join(&rel);
729 if dest.exists() {
730 continue;
731 }
732
733 let bytes =
734 match get_file_from_archive(installer.clone(), Some(name), None, false)
735 .await
736 .map_err(|e| LoaderError::Archive(e.to_string()))?
737 {
738 ArchiveQueryResult::FileData(b) => b,
739 _ => continue,
740 };
741
742 if let Some(parent) = dest.parent() {
743 tokio::fs::create_dir_all(parent).await?;
744 }
745 tokio::fs::write(&dest, &bytes).await?;
746 }
747
748 Ok(())
749}
750
751async fn download_profile_libraries(
756 profile: &ForgeProfile,
757 libs_dir: &Path,
758 options: &LaunchOptions,
759 event_tx: &Sender<LaunchEvent>,
760) -> Result<(), LoaderError> {
761 let libs = match profile.libraries.as_deref() {
762 Some(l) if !l.is_empty() => l,
763 _ => return Ok(()),
764 };
765
766 let mut items: Vec<DownloadItem> = Vec::new();
767
768 for lib in libs {
769 let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
770 let url = match artifact {
771 Some(a) if !a.url.is_empty() => a.url.clone(),
772 _ => continue,
773 };
774
775 let rel_path = artifact
776 .and_then(|a| a.path.clone())
777 .or_else(|| {
778 get_path_libraries(&lib.name, None, None)
779 .ok()
780 .map(|info| format!("{}/{}", info.path, info.name))
781 })
782 .unwrap_or_default();
783
784 if rel_path.is_empty() {
785 continue;
786 }
787
788 let dest = libs_dir.join(&rel_path);
789 if dest.exists() {
790 continue;
791 }
792
793 let folder = dest.parent().unwrap_or(libs_dir).to_path_buf();
794 let name = dest
795 .file_name()
796 .map(|n| n.to_string_lossy().into_owned())
797 .unwrap_or_default();
798
799 items.push(DownloadItem {
800 url,
801 path: dest,
802 folder,
803 name,
804 size: artifact.and_then(|a| a.size).unwrap_or(0),
805 r#type: Some("forge-lib".into()),
806 sha1: artifact.and_then(|a| a.sha1.clone()),
807 });
808 }
809
810 if !items.is_empty() {
811 let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
812 downloader
813 .download_multiple(items, event_tx.clone())
814 .await
815 .map_err(|e| {
816 LoaderError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
817 })?;
818 }
819
820 Ok(())
821}
822
823async fn extract_data_files(
829 installer_path: &str,
830 profile: &ForgeProfile,
831 libs_dir: &Path,
832 loader_type: &LoaderType,
833 neo_forge_old: bool,
834) -> Result<(), LoaderError> {
835 let data = match &profile.data {
836 Some(d) => d,
837 None => return Ok(()),
838 };
839
840 let universal_name: Option<String> = profile.libraries.as_deref().and_then(|libs| {
841 libs.iter()
842 .find(|lib| match loader_type {
843 LoaderType::Forge => lib.name.starts_with("net.minecraftforge:forge"),
844 LoaderType::NeoForge => {
845 if neo_forge_old {
846 lib.name.starts_with("net.neoforged:forge")
847 } else {
848 lib.name.starts_with("net.neoforged:neoforge")
849 }
850 }
851 _ => false,
852 })
853 .map(|lib| lib.name.clone())
854 });
855
856 for (key, entry) in data {
857 let client_val = entry.client.trim();
858
859 if !client_val.starts_with('/') {
861 continue;
862 }
863 let in_jar_path = &client_val[1..]; let dest: PathBuf = if key == "BINPATCH" {
866 let coord = profile
867 .path
868 .as_deref()
869 .or_else(|| profile.install.as_ref().and_then(|i| i.path.as_deref()))
870 .or(universal_name.as_deref())
871 .unwrap_or("");
872
873 if coord.is_empty() {
874 continue;
875 }
876
877 let info = match get_path_libraries(coord, None, None) {
878 Ok(i) => i,
879 Err(_) => continue,
880 };
881 let lzma_name = info.name.replace(".jar", "-clientdata.lzma");
882 libs_dir.join(&info.path).join(lzma_name)
883 } else {
884 libs_dir.join(in_jar_path)
885 };
886
887 if dest.exists() {
888 continue;
889 }
890
891 let result = get_file_from_archive(
892 PathBuf::from(installer_path),
893 Some(in_jar_path.to_owned()),
894 None,
895 false,
896 )
897 .await
898 .map_err(|e| LoaderError::Archive(e.to_string()))?;
899
900 let bytes = match result {
901 ArchiveQueryResult::FileData(b) => b,
902 _ => continue,
903 };
904
905 if let Some(parent) = dest.parent() {
906 tokio::fs::create_dir_all(parent).await?;
907 }
908 tokio::fs::write(&dest, &bytes).await?;
909 }
910
911 Ok(())
912}
913
914#[cfg(test)]
917mod tests {
918 use super::*;
919
920 #[test]
921 fn fallback_metadata_is_valid_json() {
922 let parsed: serde_json::Value = serde_json::from_slice(FALLBACK_META).unwrap();
923 assert!(parsed.is_object(), "forge metadata should be a JSON object");
924 }
925
926 #[test]
927 fn fallback_metadata_contains_versions() {
928 let parsed: HashMap<String, Vec<String>> =
929 serde_json::from_slice(FALLBACK_META).unwrap();
930 assert!(!parsed.is_empty());
931 }
932
933 #[test]
934 fn forge_mc_constructs() {
935 let _f = ForgeMC::new();
936 }
937
938 #[test]
939 fn build_library_assets_uses_explicit_artifact_path() {
940 let version = ForgeVersionSection {
941 id: Some("1.20.1-forge-47.4.20".into()),
942 libraries: Some(vec![LoaderLibrary {
943 name: "cpw.mods:bootstraplauncher:1.1.2".into(),
944 url: None,
945 downloads: Some(crate::models::loader::LoaderLibraryDownloads {
946 artifact: Some(crate::models::loader::LoaderArtifact {
947 sha1: Some("abc".into()),
948 size: Some(123),
949 path: Some("cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar".into()),
950 url: "https://example.com/x.jar".into(),
951 }),
952 }),
953 rules: None,
954 clientreq: None,
955 }]),
956 main_class: None,
957 minecraft_arguments: None,
958 arguments: None,
959 extra: HashMap::new(),
960 };
961 let base = PathBuf::from("/mc/loader/forge");
962 let items = build_library_assets(&base, &version);
963 assert_eq!(items.len(), 1);
964 match &items[0] {
965 AssetItem::Asset { path, .. } => {
966 assert!(path.ends_with("bootstraplauncher-1.1.2.jar"), "got {path}");
967 assert!(path.contains("loader/forge/libraries/cpw/mods"));
968 }
969 _ => panic!("expected Asset"),
970 }
971 }
972
973 #[test]
974 fn build_library_assets_skips_rule_restricted() {
975 let version = ForgeVersionSection {
976 id: None,
977 libraries: Some(vec![LoaderLibrary {
978 name: "x:y:1".into(),
979 url: None,
980 downloads: None,
981 rules: Some(vec![serde_json::json!({"action":"disallow"})]),
982 clientreq: None,
983 }]),
984 main_class: None,
985 minecraft_arguments: None,
986 arguments: None,
987 extra: HashMap::new(),
988 };
989 let items = build_library_assets(Path::new("/mc/loader/forge"), &version);
990 assert!(items.is_empty());
991 }
992}