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