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 = Downloader::new(options.timeout_secs, 1, options.force_ipv4);
173 downloader
174 .download_multiple(vec![item], event_tx.clone())
175 .await
176 .map_err(|e| {
177 LoaderError::Io(std::io::Error::new(
178 std::io::ErrorKind::Other,
179 e.to_string(),
180 ))
181 })?;
182 }
183
184 Ok(InstallerInfo {
185 file_path: installer_path.to_string_lossy().into_owned(),
186 meta_data: forge_build.clone(),
187 ext: "jar".into(),
188 id: format!("forge-{forge_build}"),
189 old_api: false,
190 })
191 }
192}
193
194impl Default for ForgeMC {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200async fn read_installer_version_id(installer_path: &str) -> Result<String, LoaderError> {
206 let result = get_file_from_archive(
207 PathBuf::from(installer_path),
208 Some("install_profile.json".into()),
209 None,
210 false,
211 )
212 .await
213 .map_err(|e| LoaderError::Archive(e.to_string()))?;
214
215 let bytes = match result {
216 ArchiveQueryResult::FileData(b) => b,
217 _ => return Err(LoaderError::ProfileNotFound),
218 };
219
220 let raw: serde_json::Value = serde_json::from_slice(&bytes)?;
221
222 if let Some(v) = raw.get("version").and_then(|v| v.as_str()) {
224 return Ok(v.to_owned());
225 }
226 if let Some(v) = raw
228 .get("install")
229 .and_then(|i| i.get("version"))
230 .and_then(|v| v.as_str())
231 {
232 return Ok(v.to_owned());
233 }
234 if let Some(v) = raw
235 .get("versionInfo")
236 .and_then(|i| i.get("id"))
237 .and_then(|v| v.as_str())
238 {
239 return Ok(v.to_owned());
240 }
241
242 Err(LoaderError::ApiError(
243 "Could not determine version ID from install_profile.json".into(),
244 ))
245}
246
247async fn prepare_install_dir(
250 loader_base: &Path,
251 mc_version: &str,
252 mc_jar: &str,
253 mc_json: &str,
254) -> Result<(), LoaderError> {
255 let profiles_path = loader_base.join("launcher_profiles.json");
256 if !profiles_path.exists() {
257 tokio::fs::write(&profiles_path, b"{\"profiles\":{}}\n").await?;
258 }
259
260 let dest_dir = loader_base.join("versions").join(mc_version);
261 tokio::fs::create_dir_all(&dest_dir).await?;
262
263 let dest_jar = dest_dir.join(format!("{mc_version}.jar"));
264 if !dest_jar.exists() {
265 tokio::fs::copy(mc_jar, &dest_jar).await?;
266 }
267 let dest_json = dest_dir.join(format!("{mc_version}.json"));
268 if !dest_json.exists() {
269 tokio::fs::copy(mc_json, &dest_json).await?;
270 }
271
272 Ok(())
273}
274
275async fn run_installer(
278 java_path: &str,
279 installer_path: &str,
280 loader_base: &Path,
281 event_tx: &Sender<LaunchEvent>,
282) -> Result<(), LoaderError> {
283 let _ = event_tx
284 .send(LaunchEvent::Patch(format!(
285 "Running Forge installer: {}",
286 installer_path
287 )))
288 .await;
289
290 let mut child = tokio::process::Command::new(java_path)
291 .arg("-jar")
292 .arg(installer_path)
293 .arg("--installClient")
294 .arg(loader_base.as_os_str())
295 .stdout(Stdio::piped())
296 .stderr(Stdio::piped())
297 .spawn()
298 .map_err(LoaderError::Io)?;
299
300 if let Some(stdout) = child.stdout.take() {
301 let tx = event_tx.clone();
302 let mut lines = BufReader::new(stdout).lines();
303 tokio::spawn(async move {
304 while let Ok(Some(line)) = lines.next_line().await {
305 let _ = tx.send(LaunchEvent::Patch(line)).await;
306 }
307 });
308 }
309 if let Some(stderr) = child.stderr.take() {
310 let tx = event_tx.clone();
311 let mut lines = BufReader::new(stderr).lines();
312 tokio::spawn(async move {
313 while let Ok(Some(line)) = lines.next_line().await {
314 let _ = tx.send(LaunchEvent::Patch(line)).await;
315 }
316 });
317 }
318
319 let status = child.wait().await.map_err(LoaderError::Io)?;
320 if !status.success() {
321 let _ = event_tx
325 .send(LaunchEvent::Patch(format!(
326 "Forge installer exited with code {:?} (checking for version JSON)",
327 status.code()
328 )))
329 .await;
330 }
331 Ok(())
332}
333
334async fn read_version_json(path: &Path) -> Result<ForgeVersionSection, LoaderError> {
335 let bytes = tokio::fs::read(path).await?;
336 let version: ForgeVersionSection = serde_json::from_slice(&bytes)?;
337 Ok(version)
338}
339
340fn extract_game_args(version: &ForgeVersionSection) -> Vec<String> {
343 let mut args: Vec<String> = Vec::new();
344 if let Some(mc_args) = &version.minecraft_arguments {
345 for token in mc_args.split_whitespace() {
346 args.push(token.to_owned());
347 }
348 }
349 if let Some(forge_args) = &version.arguments {
350 for entry in &forge_args.game {
351 if let Some(s) = entry.as_str() {
352 args.push(s.to_owned());
353 }
354 }
355 }
356 args
357}
358
359fn extract_jvm_args(
365 loader_base: &Path,
366 version_id: &str,
367 version: &ForgeVersionSection,
368) -> Vec<String> {
369 let lib_dir = loader_base.join("libraries").to_string_lossy().into_owned();
370 let sep = if cfg!(target_os = "windows") {
371 ";"
372 } else {
373 ":"
374 };
375 let mut args = Vec::new();
376 if let Some(forge_args) = &version.arguments {
377 for entry in &forge_args.jvm {
378 if let Some(s) = entry.as_str() {
379 args.push(
380 s.replace("${library_directory}", &lib_dir)
381 .replace("${classpath_separator}", sep)
382 .replace("${version_name}", version_id),
383 );
384 }
385 }
386 }
387 args
388}
389
390fn build_library_assets(loader_base: &Path, version: &ForgeVersionSection) -> Vec<AssetItem> {
392 let libs = version.libraries.as_deref().unwrap_or(&[]);
393 let mut items: Vec<AssetItem> = Vec::with_capacity(libs.len());
394
395 for lib in libs {
396 if lib.rules.is_some() {
397 continue;
398 }
399 if lib.clientreq == Some(false) {
401 continue;
402 }
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
438 .map(|a| a.url.clone())
439 .filter(|u| !u.is_empty())
440 .or_else(|| {
441 lib.url
442 .as_ref()
443 .filter(|u| !u.is_empty())
444 .map(|base| format!("{}/{}", base.trim_end_matches('/'), &rel_path))
445 })
446 .or_else(|| {
447 if !rel_path.is_empty() {
448 Some(format!("https://libraries.minecraft.net/{rel_path}"))
449 } else {
450 None
451 }
452 })
453 .unwrap_or_default();
454
455 (abs_path.to_string_lossy().into_owned(), sha1, size, url)
456}
457
458fn match_promo_in_versions(candidate: &str, mc_version: &str, versions: &[String]) -> String {
465 if versions.iter().any(|v| v == candidate) {
466 return candidate.to_owned();
467 }
468 let with_suffix = format!("{candidate}-{mc_version}");
469 if versions.iter().any(|v| v == &with_suffix) {
470 return with_suffix;
471 }
472 candidate.to_owned()
473}
474
475async fn resolve_forge_build(
476 build: &str,
477 mc_version: &str,
478 versions: &[String],
479 client: &reqwest::Client,
480) -> Result<String, LoaderError> {
481 match build {
482 "latest" => {
483 if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
484 if let Ok(p) = promos.json::<Promotions>().await {
485 let key = format!("{mc_version}-latest");
486 if let Some(ver) = p.promos.get(&key) {
487 let candidate = format!("{mc_version}-{ver}");
488 return Ok(match_promo_in_versions(&candidate, mc_version, versions));
489 }
490 }
491 }
492 versions.last().cloned().ok_or_else(|| {
493 LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}"))
494 })
495 }
496 "recommended" => {
497 if let Ok(promos) = client.get(PROMOTIONS_URL).send().await {
498 if let Ok(p) = promos.json::<Promotions>().await {
499 let rec_key = format!("{mc_version}-recommended");
500 let lat_key = format!("{mc_version}-latest");
501 let ver = p.promos.get(&rec_key).or_else(|| p.promos.get(&lat_key));
502 if let Some(v) = ver {
503 let candidate = format!("{mc_version}-{v}");
504 return Ok(match_promo_in_versions(&candidate, mc_version, versions));
505 }
506 }
507 }
508 versions.last().cloned().ok_or_else(|| {
509 LoaderError::VersionNotFound(format!("No Forge builds for {mc_version}"))
510 })
511 }
512 specific => Ok(specific.to_owned()),
513 }
514}
515
516pub(crate) async fn try_patcher_install(
524 installer_path: &str,
525 loader_base: &Path,
526 version_json_path: &Path,
527 mc_jar: &str,
528 mc_json: &str,
529 java_path: &str,
530 game_path: &Path,
531 options: &LaunchOptions,
532 loader_type: LoaderType,
533 neo_forge_old: bool,
534 event_tx: &Sender<LaunchEvent>,
535) -> bool {
536 match try_patcher_install_inner(
537 installer_path,
538 loader_base,
539 version_json_path,
540 mc_jar,
541 mc_json,
542 java_path,
543 game_path,
544 options,
545 loader_type,
546 neo_forge_old,
547 event_tx,
548 )
549 .await
550 {
551 Ok(result) => result,
552 Err(e) => {
553 let _ = event_tx
554 .send(LaunchEvent::Patch(format!(
555 "[patcher] Manual patch failed ({e}); falling back to --installClient"
556 )))
557 .await;
558 false
559 }
560 }
561}
562
563async fn try_patcher_install_inner(
564 installer_path: &str,
565 loader_base: &Path,
566 version_json_path: &Path,
567 mc_jar: &str,
568 mc_json: &str,
569 java_path: &str,
570 game_path: &Path,
571 options: &LaunchOptions,
572 loader_type: LoaderType,
573 neo_forge_old: bool,
574 event_tx: &Sender<LaunchEvent>,
575) -> Result<bool, LoaderError> {
576 let profile = read_install_profile(installer_path).await?;
578
579 let has_processors = profile.processors.as_ref().map_or(false, |p| !p.is_empty());
581 if !has_processors {
582 if profile.version_info.is_some() {
584 install_old_forge_legacy(
585 installer_path,
586 loader_base,
587 version_json_path,
588 &profile,
589 event_tx,
590 )
591 .await?;
592 return Ok(true);
593 }
594 return Ok(false);
595 }
596
597 if !version_json_path.exists() {
599 extract_version_json(installer_path, version_json_path).await?;
600 }
601
602 let libs_dir = loader_base.join("libraries");
604 extract_maven_entries(installer_path, &libs_dir).await?;
605
606 download_profile_libraries(&profile, &libs_dir, options, event_tx).await?;
608
609 extract_data_files(
611 installer_path,
612 &profile,
613 &libs_dir,
614 &loader_type,
615 neo_forge_old,
616 )
617 .await?;
618
619 let patcher = ForgePatcher::new(loader_base.to_path_buf(), loader_type);
621 if patcher.check(&profile) {
622 let _ = event_tx
623 .send(LaunchEvent::Patch(
624 "[patcher] Already patched, skipping".into(),
625 ))
626 .await;
627 return Ok(true);
628 }
629
630 let config = PatchConfig {
632 java_path,
633 minecraft_jar: mc_jar,
634 minecraft_json: mc_json,
635 game_path,
636 };
637 patcher
638 .patch(&profile, &config, neo_forge_old, event_tx)
639 .await?;
640 Ok(true)
641}
642
643async fn read_install_profile(installer_path: &str) -> Result<ForgeProfile, LoaderError> {
650 let result = get_file_from_archive(
651 PathBuf::from(installer_path),
652 Some("install_profile.json".into()),
653 None,
654 false,
655 )
656 .await
657 .map_err(|e| LoaderError::Archive(e.to_string()))?;
658
659 let bytes = match result {
660 ArchiveQueryResult::FileData(b) => b,
661 _ => return Err(LoaderError::ProfileNotFound),
662 };
663
664 let mut raw: serde_json::Value = serde_json::from_slice(&bytes)?;
665 if let Some(obj) = raw.as_object_mut() {
666 if obj.get("version").and_then(|v| v.as_str()).is_some() {
667 obj.remove("version");
668 }
669 }
670
671 let profile: ForgeProfile = serde_json::from_value(raw)?;
672 Ok(profile)
673}
674
675async fn install_old_forge_legacy(
680 installer_path: &str,
681 loader_base: &Path,
682 version_json_path: &Path,
683 profile: &ForgeProfile,
684 event_tx: &Sender<LaunchEvent>,
685) -> Result<(), LoaderError> {
686 let version_info = profile.version_info.as_ref().expect("caller checked Some");
687
688 if let Some(parent) = version_json_path.parent() {
689 tokio::fs::create_dir_all(parent).await?;
690 }
691 tokio::fs::write(version_json_path, serde_json::to_vec_pretty(version_info)?).await?;
692 let _ = event_tx
693 .send(LaunchEvent::Patch(
694 "[patcher] Old-format Forge: wrote version JSON".into(),
695 ))
696 .await;
697
698 if let Some(install) = &profile.install {
699 if let (Some(file_in_zip), Some(maven_coord)) = (&install.file_path, &install.path) {
700 if let Ok(lib_info) = get_path_libraries(maven_coord, None, None) {
701 let dest = loader_base
702 .join("libraries")
703 .join(&lib_info.path)
704 .join(&lib_info.name);
705 if !dest.exists() {
706 let result = get_file_from_archive(
707 PathBuf::from(installer_path),
708 Some(file_in_zip.clone()),
709 None,
710 false,
711 )
712 .await
713 .map_err(|e| LoaderError::Archive(e.to_string()))?;
714
715 if let ArchiveQueryResult::FileData(bytes) = result {
716 if let Some(parent) = dest.parent() {
717 tokio::fs::create_dir_all(parent).await?;
718 }
719 tokio::fs::write(&dest, bytes).await?;
720 let _ = event_tx
721 .send(LaunchEvent::Patch(format!(
722 "[patcher] Old-format Forge: extracted {}",
723 lib_info.name
724 )))
725 .await;
726 }
727 }
728 }
729 }
730 }
731
732 Ok(())
733}
734
735async fn extract_version_json(installer_path: &str, dest_path: &Path) -> Result<(), LoaderError> {
736 let result = get_file_from_archive(
737 PathBuf::from(installer_path),
738 Some("version.json".into()),
739 None,
740 false,
741 )
742 .await
743 .map_err(|e| LoaderError::Archive(e.to_string()))?;
744
745 let bytes = match result {
746 ArchiveQueryResult::FileData(b) => b,
747 _ => {
748 return Err(LoaderError::ApiError(
749 "version.json not found in installer JAR".into(),
750 ))
751 }
752 };
753
754 if let Some(parent) = dest_path.parent() {
755 tokio::fs::create_dir_all(parent).await?;
756 }
757 tokio::fs::write(dest_path, &bytes).await?;
758 Ok(())
759}
760
761async fn extract_maven_entries(installer_path: &str, libs_dir: &Path) -> Result<(), LoaderError> {
766 let installer = PathBuf::from(installer_path);
767
768 let names = match get_file_from_archive(installer.clone(), None, Some("maven/".into()), false)
769 .await
770 .map_err(|e| LoaderError::Archive(e.to_string()))?
771 {
772 ArchiveQueryResult::Names(n) => n,
773 _ => return Ok(()),
774 };
775
776 for name in names {
777 let rel = match name.strip_prefix("maven/") {
778 Some(r) if !r.is_empty() => r.to_owned(),
779 _ => continue,
780 };
781
782 let dest = libs_dir.join(&rel);
783 if dest.exists() {
784 continue;
785 }
786
787 let bytes = match get_file_from_archive(installer.clone(), Some(name), None, false)
788 .await
789 .map_err(|e| LoaderError::Archive(e.to_string()))?
790 {
791 ArchiveQueryResult::FileData(b) => b,
792 _ => continue,
793 };
794
795 if let Some(parent) = dest.parent() {
796 tokio::fs::create_dir_all(parent).await?;
797 }
798 tokio::fs::write(&dest, &bytes).await?;
799 }
800
801 Ok(())
802}
803
804async fn download_profile_libraries(
809 profile: &ForgeProfile,
810 libs_dir: &Path,
811 options: &LaunchOptions,
812 event_tx: &Sender<LaunchEvent>,
813) -> Result<(), LoaderError> {
814 let libs = match profile.libraries.as_deref() {
815 Some(l) if !l.is_empty() => l,
816 _ => return Ok(()),
817 };
818
819 let mut items: Vec<DownloadItem> = Vec::new();
820
821 for lib in libs {
822 let artifact = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref());
823 let url = match artifact {
824 Some(a) if !a.url.is_empty() => a.url.clone(),
825 _ => continue,
826 };
827
828 let rel_path = artifact
829 .and_then(|a| a.path.clone())
830 .or_else(|| {
831 get_path_libraries(&lib.name, None, None)
832 .ok()
833 .map(|info| format!("{}/{}", info.path, info.name))
834 })
835 .unwrap_or_default();
836
837 if rel_path.is_empty() {
838 continue;
839 }
840
841 let dest = libs_dir.join(&rel_path);
842 if dest.exists() {
843 continue;
844 }
845
846 let folder = dest.parent().unwrap_or(libs_dir).to_path_buf();
847 let name = dest
848 .file_name()
849 .map(|n| n.to_string_lossy().into_owned())
850 .unwrap_or_default();
851
852 items.push(DownloadItem {
853 url,
854 path: dest,
855 folder,
856 name,
857 size: artifact.and_then(|a| a.size).unwrap_or(0),
858 r#type: Some("forge-lib".into()),
859 sha1: artifact.and_then(|a| a.sha1.clone()),
860 });
861 }
862
863 if !items.is_empty() {
864 let downloader = Downloader::new(
865 options.timeout_secs,
866 options.clamped_concurrency(),
867 options.force_ipv4,
868 );
869 downloader
870 .download_multiple(items, event_tx.clone())
871 .await
872 .map_err(|e| {
873 LoaderError::Io(std::io::Error::new(
874 std::io::ErrorKind::Other,
875 e.to_string(),
876 ))
877 })?;
878 }
879
880 Ok(())
881}
882
883async fn extract_data_files(
889 installer_path: &str,
890 profile: &ForgeProfile,
891 libs_dir: &Path,
892 loader_type: &LoaderType,
893 neo_forge_old: bool,
894) -> Result<(), LoaderError> {
895 let data = match &profile.data {
896 Some(d) => d,
897 None => return Ok(()),
898 };
899
900 let universal_name: Option<String> = profile.libraries.as_deref().and_then(|libs| {
901 libs.iter()
902 .find(|lib| match loader_type {
903 LoaderType::Forge => lib.name.starts_with("net.minecraftforge:forge"),
904 LoaderType::NeoForge => {
905 if neo_forge_old {
906 lib.name.starts_with("net.neoforged:forge")
907 } else {
908 lib.name.starts_with("net.neoforged:neoforge")
909 }
910 }
911 _ => false,
912 })
913 .map(|lib| lib.name.clone())
914 });
915
916 for (key, entry) in data {
917 let client_val = entry.client.trim();
918
919 if !client_val.starts_with('/') {
921 continue;
922 }
923 let in_jar_path = &client_val[1..]; let dest: PathBuf = if key == "BINPATCH" {
926 let coord = profile
927 .path
928 .as_deref()
929 .or_else(|| profile.install.as_ref().and_then(|i| i.path.as_deref()))
930 .or(universal_name.as_deref())
931 .unwrap_or("");
932
933 if coord.is_empty() {
934 continue;
935 }
936
937 let info = match get_path_libraries(coord, None, None) {
938 Ok(i) => i,
939 Err(_) => continue,
940 };
941 let lzma_name = info.name.replace(".jar", "-clientdata.lzma");
942 libs_dir.join(&info.path).join(lzma_name)
943 } else {
944 libs_dir.join(in_jar_path)
945 };
946
947 if dest.exists() {
948 continue;
949 }
950
951 let result = get_file_from_archive(
952 PathBuf::from(installer_path),
953 Some(in_jar_path.to_owned()),
954 None,
955 false,
956 )
957 .await
958 .map_err(|e| LoaderError::Archive(e.to_string()))?;
959
960 let bytes = match result {
961 ArchiveQueryResult::FileData(b) => b,
962 _ => continue,
963 };
964
965 if let Some(parent) = dest.parent() {
966 tokio::fs::create_dir_all(parent).await?;
967 }
968 tokio::fs::write(&dest, &bytes).await?;
969 }
970
971 Ok(())
972}
973
974#[cfg(test)]
977mod tests {
978 use super::*;
979
980 #[test]
981 fn fallback_metadata_is_valid_json() {
982 let parsed: serde_json::Value = serde_json::from_slice(FALLBACK_META).unwrap();
983 assert!(parsed.is_object(), "forge metadata should be a JSON object");
984 }
985
986 #[test]
987 fn fallback_metadata_contains_versions() {
988 let parsed: HashMap<String, Vec<String>> = serde_json::from_slice(FALLBACK_META).unwrap();
989 assert!(!parsed.is_empty());
990 }
991
992 #[test]
993 fn forge_mc_constructs() {
994 let _f = ForgeMC::new();
995 }
996
997 #[test]
998 fn build_library_assets_uses_explicit_artifact_path() {
999 let version = ForgeVersionSection {
1000 id: Some("1.20.1-forge-47.4.20".into()),
1001 libraries: Some(vec![LoaderLibrary {
1002 name: "cpw.mods:bootstraplauncher:1.1.2".into(),
1003 url: None,
1004 downloads: Some(crate::models::loader::LoaderLibraryDownloads {
1005 artifact: Some(crate::models::loader::LoaderArtifact {
1006 sha1: Some("abc".into()),
1007 size: Some(123),
1008 path: Some(
1009 "cpw/mods/bootstraplauncher/1.1.2/bootstraplauncher-1.1.2.jar".into(),
1010 ),
1011 url: "https://example.com/x.jar".into(),
1012 }),
1013 }),
1014 rules: None,
1015 clientreq: None,
1016 }]),
1017 main_class: None,
1018 minecraft_arguments: None,
1019 arguments: None,
1020 extra: HashMap::new(),
1021 };
1022 let base = PathBuf::from("/mc/loader/forge");
1023 let items = build_library_assets(&base, &version);
1024 assert_eq!(items.len(), 1);
1025 match &items[0] {
1026 AssetItem::Asset { path, .. } => {
1027 assert!(path.ends_with("bootstraplauncher-1.1.2.jar"), "got {path}");
1028 assert!(path.contains("loader/forge/libraries/cpw/mods"));
1029 }
1030 _ => panic!("expected Asset"),
1031 }
1032 }
1033
1034 #[test]
1035 fn build_library_assets_skips_rule_restricted() {
1036 let version = ForgeVersionSection {
1037 id: None,
1038 libraries: Some(vec![LoaderLibrary {
1039 name: "x:y:1".into(),
1040 url: None,
1041 downloads: None,
1042 rules: Some(vec![serde_json::json!({"action":"disallow"})]),
1043 clientreq: None,
1044 }]),
1045 main_class: None,
1046 minecraft_arguments: None,
1047 arguments: None,
1048 extra: HashMap::new(),
1049 };
1050 let items = build_library_assets(Path::new("/mc/loader/forge"), &version);
1051 assert!(items.is_empty());
1052 }
1053}