//! Downloader for Minecraft data use crate::data::ModLoader; use crate::install::{ InstallErrorContext, InstallPhaseDetails, InstallPhaseId, InstallProgress, InstallProgressReporter, }; use crate::instance::QuickPlayType; use crate::launcher::parse_rules; use crate::{ event::{ LoadingBarId, emit::{emit_loading, loading_try_for_each_concurrent}, }, state::State, util::download::h2_download::{ ASSET_BATCH_CONCURRENCY, H2BatchAsset, download_asset_batch_via_h2, }, util::{fetch::*, io}, }; use daedalus::minecraft::{LibraryDownload, LoggingConfiguration, LoggingSide}; use daedalus::{ self as d, minecraft::{ Asset, AssetsIndex, Library, Version as GameVersion, VersionInfo as GameVersionInfo, }, modded::LoaderVersion, }; use futures::prelude::*; use reqwest::Method; use std::{ collections::{HashMap, HashSet}, future::Future, path::{Path, PathBuf}, pin::Pin, sync::{ Arc, Mutex, atomic::{AtomicU64, Ordering}, }, }; use url::Url; const MINECRAFT_DOWNLOAD_PROGRESS_MIN_BYTES: u64 = 256 * 1024; #[derive(Clone, Debug, PartialEq, Eq)] pub struct LocalRuntimeSource { pub root: PathBuf, } impl LocalRuntimeSource { pub fn discover(instance_or_dotminecraft: &Path) -> Option { if is_local_runtime_root(instance_or_dotminecraft) { return Some(Self { root: instance_or_dotminecraft.to_path_buf(), }); } let dotminecraft = instance_or_dotminecraft.join(".minecraft"); if is_local_runtime_root(&dotminecraft) { return Some(Self { root: dotminecraft }); } instance_or_dotminecraft .ancestors() .find(|path| path.join("versions").is_dir()) .map(|root| Self { root: root.to_path_buf(), }) } } fn is_local_runtime_root(path: &Path) -> bool { path.join("assets").is_dir() && path.join("libraries").is_dir() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ArtifactAvailability { Cached, LocalReusable, NetworkRequired, } pub async fn classify_local_artifact( local: Option<&LocalRuntimeSource>, destination: &Path, relative_path: &Path, expected_sha1: Option<&str>, expected_size: Option, ) -> crate::Result { if destination.exists() { let cached = std::fs::metadata(destination).is_ok_and(|metadata| { metadata.is_file() && expected_size.is_none_or(|size| metadata.len() == size) }); if cached { return Ok(ArtifactAvailability::Cached); } } let Some(local) = local else { return Ok(ArtifactAvailability::NetworkRequired); }; let Some(expected_sha1) = expected_sha1 else { return Ok(ArtifactAvailability::NetworkRequired); }; let candidate = local.root.join(relative_path); if !candidate.is_file() { return Ok(ArtifactAvailability::NetworkRequired); } let metadata = io::metadata(&candidate).await?; if let Some(expected_size) = expected_size && metadata.len() != expected_size { return Ok(ArtifactAvailability::NetworkRequired); } let (_, actual_sha1) = sha1_file_async(&candidate).await?; if actual_sha1.eq_ignore_ascii_case(expected_sha1) { Ok(ArtifactAvailability::LocalReusable) } else { Ok(ArtifactAvailability::NetworkRequired) } } pub async fn copy_verified_local_artifact( st: &State, local: &LocalRuntimeSource, relative_path: &Path, destination: &Path, expected_sha1: Option<&str>, expected_size: Option, progress: Option<&MinecraftDownloadProgress>, context: InstallErrorContext, ) -> crate::Result { let source = local.root.join(relative_path); if let Some(progress) = progress { progress.set_context(context.clone()).await?; } let copied = match super::local_artifact::copy_verified( &source, destination, expected_sha1, expected_size, &st.io_semaphore, ) .await { Ok(copied) => copied, Err(error) => { if let Some(progress) = progress { progress.persist_failure_context(context).await; } return Err(error); } }; if !copied { return Ok(false); } let size = match expected_size { Some(size) => size, None => io::metadata(&source).await?.len(), }; if let Some(progress) = progress { progress.add_bytes(size).await?; } Ok(true) } #[derive(Clone, Debug)] pub struct MinecraftDownloadProgress { reporter: InstallProgressReporter, details: InstallPhaseDetails, current: Arc, total: Arc, last_reported: Arc, source: Arc>>, fallback_count: Arc, } impl MinecraftDownloadProgress { async fn new( reporter: InstallProgressReporter, details: InstallPhaseDetails, total: u64, ) -> crate::Result { let progress = Self { reporter, details, current: Arc::new(AtomicU64::new(0)), total: Arc::new(AtomicU64::new(total)), last_reported: Arc::new(AtomicU64::new(0)), source: Arc::new(Mutex::new(None)), fallback_count: Arc::new(AtomicU64::new(0)), }; if total > 0 { progress.emit_progress(0, total).await?; } Ok(progress) } async fn add_total(&self, total: u64) -> crate::Result<()> { if total == 0 { return Ok(()); } let total = self.total.fetch_add(total, Ordering::Relaxed) + total; self.emit_if_needed(self.current.load(Ordering::Relaxed), total, true) .await } async fn add_bytes(&self, bytes: u64) -> crate::Result<()> { if bytes == 0 { return Ok(()); } let current = self.current.fetch_add(bytes, Ordering::Relaxed) + bytes; let total = self.total.load(Ordering::Relaxed); self.emit_if_needed(current, total, false).await } /// Rolls back bytes that were counted for an abandoned download attempt, /// so retried files do not inflate the progress bar to 100% early. async fn sub_bytes(&self, bytes: u64) -> crate::Result<()> { if bytes == 0 { return Ok(()); } let mut current = self.current.load(Ordering::Relaxed); loop { let next = current.saturating_sub(bytes); match self.current.compare_exchange_weak( current, next, Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => { current = next; break; } Err(actual) => current = actual, } } let total = self.total.load(Ordering::Relaxed); self.emit_if_needed(current, total, true).await } async fn emit_if_needed( &self, current: u64, total: u64, force: bool, ) -> crate::Result<()> { if total == 0 { return Ok(()); } let min_delta = (total / 200).max(MINECRAFT_DOWNLOAD_PROGRESS_MIN_BYTES); let last_reported = self.last_reported.load(Ordering::Relaxed); if !force && current < total && current.saturating_sub(last_reported) < min_delta { return Ok(()); } self.last_reported.store(current, Ordering::Relaxed); self.emit_progress(current, total).await } async fn emit_progress( &self, current: u64, total: u64, ) -> crate::Result<()> { // A zero total represents a fully cached/no-op Minecraft install. // Persist it as a completed unit so parallel modpack progress does // not remain indeterminate after the task returned successfully. let (current, total) = if total == 0 { (1, 1) } else { (current, total) }; self.reporter .update( InstallPhaseId::DownloadingMinecraft, Some(InstallProgress { current: current.min(total), total, secondary: None, }), self.details.clone(), ) .await } async fn set_context( &self, context: InstallErrorContext, ) -> crate::Result<()> { self.reporter.set_transient_context(context).await } async fn persist_failure_context(&self, context: InstallErrorContext) { self.reporter.persist_failure_context(context).await; } fn record_download_result(&self, result: &DownloadResult) { if result.attempts > 0 && let Ok(mut source) = self.source.lock() { *source = Some(result.source.as_str().to_string()); } self.fallback_count .fetch_add(result.fallback_count as u64, Ordering::Relaxed); } async fn finish(&self) -> crate::Result<()> { // The initial missing-byte estimate can be stale when another install, // a local runtime source, or a cache satisfies the file before this // task reaches it. A successful Minecraft install is terminal for this // progress track, so publish its total as complete instead of leaving // a misleading `0/N` download bar behind. let total = self.total.load(Ordering::Relaxed); self.emit_progress(total, total).await?; let source = self.source.lock().ok().and_then(|source| source.clone()); let fallback_count = self.fallback_count.load(Ordering::Relaxed); if let Some(source) = source { self.reporter .record_download_metrics(source, fallback_count) .await?; } Ok(()) } } async fn download_minecraft_file( st: &State, url: &str, sha1: Option<&str>, expected_size: Option, destination: &std::path::Path, resource: ResourceClass, content_validation: ContentValidation, force: bool, progress: Option, context: InstallErrorContext, ) -> crate::Result { let urls = minecraft_library_mirrors(url); download_minecraft_file_with_candidates( st, &urls, sha1, expected_size, destination, resource, content_validation, force, progress, context, ) .await } async fn download_minecraft_file_with_candidates( st: &State, urls: &[String], sha1: Option<&str>, expected_size: Option, destination: &std::path::Path, resource: ResourceClass, content_validation: ContentValidation, force: bool, progress: Option, context: InstallErrorContext, ) -> crate::Result { let Some(url) = urls.first() else { return Err(crate::ErrorKind::LauncherError( "No trusted download URL is available".to_string(), ) .into()); }; let mut context = context; context.urls.extend(urls.iter().cloned()); context.expected_hash = sha1.map(str::to_string); context.expected_size = expected_size; if let Some(progress) = &progress { progress.set_context(context.clone()).await?; } if force && destination.exists() { io::remove_file(destination).await?; } let integrity = Integrity { size: expected_size, sha1: sha1.map(str::to_string), content: content_validation, ..Integrity::default() }; let mut request = DownloadRequest::new(url, resource) .with_candidate_urls(urls.iter().skip(1).cloned()) .with_integrity(integrity); if let Some(progress) = &progress { request = request.with_install_tracking( progress.reporter.clone(), destination.display().to_string(), destination .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| url.to_string()), ); } let Some(progress) = progress else { return download_to_path( request, destination, &st.download_semaphore, &st.pool, None, ) .await; }; let last_downloaded = Arc::new(AtomicU64::new(0)); let mut progress_fn = { let progress = progress.clone(); let last_downloaded = last_downloaded.clone(); move |downloaded: u64, _total: u64| -> Pin> + Send>> { let previous = last_downloaded.swap(downloaded, Ordering::Relaxed); let progress = progress.clone(); Box::pin(async move { if downloaded >= previous { progress.add_bytes(downloaded - previous).await } else { // The downloaded count went backwards, meaning a failed // attempt restarted; un-count the abandoned bytes. progress.sub_bytes(previous - downloaded).await } }) } }; let result = match download_to_path( request, destination, &st.download_semaphore, &st.pool, Some(&mut progress_fn as &mut FetchProgressFn<'_>), ) .await { Ok(bytes) => bytes, Err(error) => { progress.persist_failure_context(context).await; return Err(error); } }; progress.record_download_result(&result); if let Some(expected_size) = expected_size { let downloaded = last_downloaded.load(Ordering::Relaxed); progress .add_bytes(expected_size.saturating_sub(downloaded)) .await?; } Ok(result) } pub(crate) fn minecraft_library_mirrors(url: &str) -> Vec { const MACHINA_LWJGL_RELEASE: &str = "https://github.com/MinecraftMachina/lwjgl/releases/download/2.9.4-20150209-mmachina.2/"; const MOJANG_LWJGL_PATH: &str = "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/"; let mut mirrors = vec![url.to_string()]; if let Some(file_name) = url.strip_prefix(MACHINA_LWJGL_RELEASE) { mirrors.push(format!("{MOJANG_LWJGL_PATH}{file_name}")); } mirrors } const LAUNCHER_META_MAVEN: &str = "https://launcher-meta.modrinth.com/maven"; pub(crate) const LIBRARIES_MAVEN: &str = "https://libraries.minecraft.net"; const FABRIC_MAVEN: &str = "https://maven.fabricmc.net"; const FORGE_MAVEN: &str = "https://maven.minecraftforge.net"; const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases"; const QUILT_MAVEN: &str = "https://maven.quiltmc.org/repository/release"; const SPONGE_MAVEN: &str = "https://repo.spongepowered.org/maven"; const MAVEN_CENTRAL: &str = "https://repo.maven.apache.org/maven2"; pub(crate) fn legacy_library_download_urls( repository: Option<&str>, artifact_path: &str, ) -> Option> { if artifact_path.starts_with('/') || artifact_path.contains('\\') || artifact_path .split('/') .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) { return None; } let repository = repository.unwrap_or(LIBRARIES_MAVEN); let mut repository_url = Url::parse(repository).ok()?; if repository_url.scheme() != "https" || repository_url.host_str().is_none() || !repository_url.username().is_empty() || repository_url.password().is_some() || repository_url.query().is_some() || repository_url.fragment().is_some() { return None; } let normalized_path = format!("{}/", repository_url.path().trim_end_matches('/')); repository_url.set_path(&normalized_path); let declared_url = repository_url.join(artifact_path).ok()?.to_string(); let canonical_urls = legacy_library_canonical_urls(artifact_path); let mut candidates = Vec::new(); if repository.trim_end_matches('/') == LAUNCHER_META_MAVEN { candidates.extend(canonical_urls); candidates.push(declared_url); } else { candidates.push(declared_url); candidates.extend(canonical_urls); } candidates.push(format!("{LIBRARIES_MAVEN}/{artifact_path}")); let mut urls = Vec::new(); for candidate in candidates { if !urls.contains(&candidate) { urls.push(candidate); } } Some(urls) } fn legacy_library_canonical_urls(artifact_path: &str) -> Vec { let in_repository = |repository: &str| format!("{repository}/{artifact_path}"); if artifact_path.starts_with("org/scala-lang/scala-parser-combinators_") || artifact_path.starts_with("org/scala-lang/scala-swing_") || artifact_path.starts_with("org/scala-lang/scala-xml_") { vec![in_repository(FORGE_MAVEN)] } else if artifact_path.starts_with("net/fabricmc/") { vec![in_repository(FABRIC_MAVEN)] } else if artifact_path.starts_with("org/quiltmc/") { vec![in_repository(QUILT_MAVEN)] } else if artifact_path.starts_with("net/minecraftforge/") || artifact_path.starts_with("cpw/mods/") { vec![in_repository(FORGE_MAVEN)] } else if artifact_path.starts_with("net/neoforged/") { vec![in_repository(NEOFORGE_MAVEN)] } else if artifact_path.starts_with("org/spongepowered/") { vec![in_repository(SPONGE_MAVEN)] } else if artifact_path.starts_with("net/minecraft/launchwrapper/") { vec![in_repository(LIBRARIES_MAVEN)] } else if artifact_path.starts_with("org/ow2/") || artifact_path.starts_with("org/scala-lang/") || artifact_path.starts_with("org/jline/") || artifact_path.starts_with("jline/") || artifact_path.starts_with("net/java/dev/jna/") || artifact_path.starts_with("com/typesafe/") { vec![in_repository(MAVEN_CENTRAL)] } else if artifact_path.starts_with("com/modrinth/daedalus/") { vec![in_repository(LAUNCHER_META_MAVEN)] } else { Vec::new() } } fn legacy_library_content_validation(artifact_path: &str) -> ContentValidation { if artifact_path.ends_with(".jar") { ContentValidation::Jar } else { ContentValidation::None } } pub(crate) fn legacy_library_sha1(library: &Library) -> Option<&str> { library .checksums .as_deref() .and_then(|checksums| { checksums.iter().find(|checksum| { checksum.len() == 40 && checksum .bytes() .all(|character| character.is_ascii_hexdigit()) }) }) .map(String::as_str) } pub(crate) fn local_asset_index_path(asset_index_id: &str) -> PathBuf { Path::new("assets") .join("indexes") .join(format!("{asset_index_id}.json")) } pub(crate) fn local_asset_object_path(hash: &str) -> PathBuf { Path::new("assets") .join("objects") .join(&hash[..2]) .join(hash) } pub(crate) fn local_library_path(library_name: &str) -> crate::Result { Ok(Path::new("libraries").join(d::get_path_from_artifact(library_name)?)) } pub(crate) fn local_native_library_path( library: &Library, native: &LibraryDownload, classifier: &str, ) -> crate::Result { let artifact_path = match native.path.as_deref() { Some(path) => path.to_string(), None => native_library_artifact_path(library, classifier)?, }; Ok(Path::new("libraries").join(artifact_path)) } pub(crate) fn is_native_library(library: &Library) -> bool { library.natives.is_some() || library_classifier(&library.name) .is_some_and(|classifier| classifier.starts_with("natives-")) } /// Whether this library carries a non-native primary artifact that must be /// downloaded. This is normally a Java JAR, but loader processors can also /// declare archives and mapping files. A library can have both a primary /// artifact and native classifiers after manifest merging (LWJGL is the /// canonical example); the two are independent and must not be treated as /// mutually exclusive. /// /// `include_in_classpath` only controls the final Minecraft runtime classpath. /// Forge and NeoForge deliberately exclude installer processor dependencies /// from that classpath, but those artifacts are still required while the /// loader processors run. pub(crate) fn needs_java_artifact(library: &Library) -> bool { // Four-part native coordinates (group:artifact:version:natives-*) store // their native archive metadata in downloads.artifact, which is not a // Java JAR. Exclude them so they only produce NativeArtifact tasks. if library_classifier(&library.name) .is_some_and(|classifier| classifier.starts_with("natives-")) { return false; } // Legacy pure-native libraries carry a natives map but no downloads.artifact; // their main JAR is not downloaded by the original installer either. if library.natives.is_some() && library .downloads .as_ref() .and_then(|downloads| downloads.artifact.as_ref()) .is_none_or(|artifact| artifact.url.trim().is_empty()) && library .url .as_deref() .is_none_or(|url| url.trim().is_empty()) { return false; } true } fn java_artifact_applies( library: &Library, java_arch: &str, minecraft_updated: bool, ) -> bool { library.downloadable && needs_java_artifact(library) && library.rules.as_ref().is_none_or(|rules| { parse_rules( rules, java_arch, &QuickPlayType::None, minecraft_updated, ) }) } fn library_classifier(library_name: &str) -> Option<&str> { let mut coordinates = library_name.split(':'); coordinates.next()?; coordinates.next()?; coordinates.next()?; coordinates .next() .and_then(|classifier| classifier.split('@').next()) } pub(crate) fn native_library_artifact_path( library: &Library, classifier: &str, ) -> crate::Result { // A four-part coordinate is only the native artifact path when its // classifier is a native classifier. Other classifiers (sources, // javadoc, etc.) can occur in loader metadata and must retain the // legacy "append classifier" behaviour when this helper is reused. if library_classifier(&library.name) .is_some_and(|value| value.starts_with("natives-")) { Ok(d::get_path_from_artifact(&library.name)?) } else { classified_library_artifact_path(&library.name, classifier) } } pub(crate) fn classified_library_artifact_path( library_name: &str, classifier: &str, ) -> crate::Result { let artifact_path = d::get_path_from_artifact(library_name)?; Ok( if let Some((prefix, extension)) = artifact_path.rsplit_once('.') { format!("{prefix}-{classifier}.{extension}") } else { format!("{artifact_path}-{classifier}") }, ) } pub(crate) fn library_native_classifier( library: &Library, java_arch: &str, ) -> Option { let os = d::minecraft::Os::native_arch(java_arch); let base_os = os.get_os(); library .natives .as_ref() .and_then(|natives| natives.get(&os).or_else(|| natives.get(&base_os))) .map(|classifier| { classifier.replace("${arch}", crate::util::platform::ARCH_WIDTH) }) .or_else(|| library_classifier(&library.name).map(str::to_owned)) } pub(crate) fn local_client_path(game_version: &str) -> PathBuf { Path::new("versions") .join(game_version) .join(format!("{game_version}.jar")) } pub(crate) fn local_log_config_path(log_config_id: &str) -> PathBuf { Path::new("assets").join("log_configs").join(log_config_id) } async fn try_reuse_local_artifact( st: &State, local: Option<&LocalRuntimeSource>, relative_path: &Path, destination: &Path, expected_sha1: Option<&str>, expected_size: Option, progress: Option<&MinecraftDownloadProgress>, context: InstallErrorContext, ) -> crate::Result { let Some(local) = local else { return Ok(false); }; copy_verified_local_artifact( st, local, relative_path, destination, expected_sha1, expected_size, progress, context, ) .await } async fn download_or_reuse_local( st: &State, local: Option<&LocalRuntimeSource>, relative_path: &Path, destination: &Path, expected_sha1: Option<&str>, expected_size: Option, progress: Option<&MinecraftDownloadProgress>, context: InstallErrorContext, force: bool, download: F, ) -> crate::Result where F: FnOnce() -> Fut, Fut: Future>, { if !force && try_reuse_local_artifact( st, local, relative_path, destination, expected_sha1, expected_size, progress, context, ) .await? { return Ok(true); } download().await?; Ok(false) } fn should_download(path_exists: bool, force: bool) -> bool { !path_exists || force } fn missing_client_bytes( st: &State, version: &GameVersionInfo, force: bool, ) -> crate::Result { let client_download = version .downloads .get(&d::minecraft::DownloadType::Client) .ok_or( crate::ErrorKind::LauncherError(format!( "No client downloads exist for version {}", version.id )) .as_error(), )?; let path = st .directories .version_dir(&version.id) .join(format!("{}.jar", version.id)); Ok(if should_download(path.exists(), force) { client_download.size as u64 } else { 0 }) } fn missing_assets_index_bytes( st: &State, version: &GameVersionInfo, force: bool, ) -> u64 { let path = st .directories .assets_index_dir() .join(format!("{}.json", &version.asset_index.id)); if should_download(path.exists(), force) { version.asset_index.size as u64 } else { 0 } } fn asset_file_is_usable(path: &Path, expected_size: u64) -> bool { std::fs::metadata(path).is_ok_and(|metadata| { metadata.is_file() && metadata.len() == expected_size }) } fn missing_log_config_bytes( st: &State, version: &GameVersionInfo, force: bool, ) -> u64 { let log_download = version .logging .as_ref() .and_then(|x| x.get(&LoggingSide::Client)); let Some(LoggingConfiguration::Log4j2Xml { file: log_download, .. }) = log_download else { return 0; }; let path = st.directories.log_configs_dir().join(&log_download.id); if should_download(path.exists(), force) { log_download.size as u64 } else { 0 } } fn missing_asset_bytes( st: &State, with_legacy: bool, index: &AssetsIndex, force: bool, ) -> u64 { index .objects .iter() .filter_map(|(name, asset)| { let hash = &asset.hash; let object_path = st.directories.object_dir(hash); let legacy_path = st.directories.legacy_assets_dir().join( name.replace('/', &String::from(std::path::MAIN_SEPARATOR)), ); let should_fetch_object = force || !asset_file_is_usable(&object_path, asset.size as u64); let should_fetch_legacy = (with_legacy && !asset_file_is_usable(&legacy_path, asset.size as u64)) || force; (should_fetch_object || should_fetch_legacy) .then_some(asset.size as u64) }) .sum() } fn missing_library_bytes( st: &State, libraries: &[Library], java_arch: &str, force: bool, minecraft_updated: bool, ) -> crate::Result { let mut total = 0; for library in libraries { if let Some(rules) = &library.rules && !parse_rules( rules, java_arch, &QuickPlayType::None, minecraft_updated, ) { continue; } if !library.downloadable { continue; } // Native library size for this platform, if any. if is_native_library(library) { if let Some(classifier) = library_native_classifier(library, java_arch) && let Some(native) = library .downloads .as_ref() .and_then(|downloads| downloads.classifiers.as_ref()) .and_then(|classifiers| classifiers.get(&classifier)) { total += native.size as u64; } } // Java artifact size. Mixed libraries carry both. if needs_java_artifact(library) { let artifact_path = d::get_path_from_artifact(&library.name)?; let path = st.directories.libraries_dir().join(&artifact_path); if !path.exists() || force { if let Some(artifact) = library .downloads .as_ref() .and_then(|downloads| downloads.artifact.as_ref()) && !artifact.url.is_empty() { total += artifact.size as u64; } } } } Ok(total) } fn missing_initial_minecraft_bytes( st: &State, version: &GameVersionInfo, java_arch: &str, force: bool, minecraft_updated: bool, ) -> crate::Result { Ok(missing_client_bytes(st, version, force)? + missing_assets_index_bytes(st, version, force) + missing_log_config_bytes(st, version, force) + missing_library_bytes( st, version.libraries.as_slice(), java_arch, force, minecraft_updated, )?) } #[tracing::instrument(skip_all, fields(version = version.id.as_str()))] #[allow(clippy::too_many_arguments)] pub async fn download_minecraft( st: &State, local_source: Option<&LocalRuntimeSource>, game_version: &str, version: &GameVersionInfo, loading_bar: Option<&LoadingBarId>, java_arch: &str, force: bool, minecraft_updated: bool, reporter: Option, phase_details: InstallPhaseDetails, ) -> crate::Result<()> { tracing::info!("Downloading Minecraft version {}", version.id); let progress = if let Some(reporter) = reporter { Some( MinecraftDownloadProgress::new( reporter, phase_details, missing_initial_minecraft_bytes( st, version, java_arch, force, minecraft_updated, )?, ) .await?, ) } else { None }; let amount = if version.processors.as_ref().is_some_and(|x| !x.is_empty()) { 25.0 } else { 40.0 }; tokio::try_join! { async { let assets_index = download_assets_index( st, local_source, version, loading_bar, force, progress.clone(), ) .await?; if let Some(progress) = &progress { progress .add_total(missing_asset_bytes( st, version.assets == "legacy", &assets_index, force, )) .await?; } download_assets( st, local_source, version.assets == "legacy", &assets_index, loading_bar, amount, force, progress.clone(), ) .await }, async { tokio::try_join! { download_client(st, local_source, game_version, version, loading_bar, force, progress.clone()), download_log_config(st, local_source, version, loading_bar, force, progress.clone()), download_libraries(st, local_source, version.libraries.as_slice(), &version.id, loading_bar, amount, java_arch, force, minecraft_updated, progress.clone()) }?; Ok::<_, crate::Error>(()) } }?; if let Some(progress) = &progress { progress.finish().await?; } tracing::info!("Done downloading Minecraft!"); Ok(()) } #[tracing::instrument(skip_all, fields(version = version.id.as_str(), loader = ?loader))] pub async fn download_version_info( st: &State, version: &GameVersion, mod_loader: ModLoader, loader: Option<&LoaderVersion>, force: Option, loading_bar: Option<&LoadingBarId>, reporter: Option<&InstallProgressReporter>, ) -> crate::Result { let version_id = loader .map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); tracing::debug!("Loading version info for Minecraft {version_id}"); let path = st .directories .version_dir(&version_id) .join(format!("{version_id}.json")); let cache_is_current = loader.is_none() || derived_version_cache_is_current(&path).await; let res = if path.exists() && !force.unwrap_or(false) && cache_is_current { let mut info: GameVersionInfo = io::read(&path) .err_into::() .await .and_then(|ref it| Ok(serde_json::from_slice(it)?))?; let normalized = normalize_version_info(mod_loader, &version.id, &mut info, "cache"); let restored_legacy_arguments = restore_legacy_minecraft_arguments(st, version, loader, &mut info) .await?; if normalized || restored_legacy_arguments { write_version_info(&path, serde_json::to_vec(&info)?).await?; } info } else { tracing::info!( "Downloading version info for version {} from {}", &version.id, version.url ); if let Some(reporter) = reporter { reporter .set_context( InstallErrorContext::new( "download Minecraft version metadata", ) .minecraft_version(version.id.clone()) .urls(vec![version.url.clone()]) .target_path(path.display().to_string()) .build(), ) .await?; } let mut info = match fetch_json( Method::GET, &version.url, Some(&version.sha1), None, None, &st.api_semaphore, &st.pool, ) .await { Ok(info) => info, Err(primary_error) => { tracing::warn!( minecraft_version = %version.id, url = %version.url, error = %primary_error, "Version metadata failed; looking up the Mojang fallback" ); let manifest: d::minecraft::VersionManifest = match fetch_json( Method::GET, d::minecraft::VERSION_MANIFEST_URL, None, None, None, &st.api_semaphore, &st.pool, ) .await { Ok(manifest) => manifest, Err(error) => { tracing::warn!( minecraft_version = %version.id, error = %error, "Mojang manifest fallback failed" ); return Err(primary_error); } }; let Some(fallback_version) = manifest .versions .into_iter() .find(|candidate| candidate.id == version.id) else { return Err(primary_error); }; if fallback_version.url == version.url { return Err(primary_error); } if let Some(reporter) = reporter { reporter .set_context( InstallErrorContext::new( "download Minecraft version metadata", ) .minecraft_version(version.id.clone()) .urls(vec![ version.url.clone(), fallback_version.url.clone(), ]) .target_path(path.display().to_string()) .build(), ) .await?; } fetch_json( Method::GET, &fallback_version.url, Some(&fallback_version.sha1), None, None, &st.api_semaphore, &st.pool, ) .await? } }; if let Some(loader) = loader { if let Some(reporter) = reporter { reporter .set_context( InstallErrorContext::new( "download loader version metadata", ) .minecraft_version(version.id.clone()) .urls(vec![loader.url.clone()]) .target_path(path.display().to_string()) .build(), ) .await?; } let partial: d::modded::PartialVersionInfo = if mod_loader == ModLoader::OptiFine { crate::launcher::optifine::build_partial_version_info( st, &info, &version.id, &loader.id, ) .await? } else { crate::api::loader_metadata::resolve_loader_profile( st, &version.id, loader, ) .await? }; info = d::modded::merge_partial_version(partial, info); } normalize_version_info(mod_loader, &version.id, &mut info, "network"); info.id.clone_from(&version_id); write_version_info(&path, serde_json::to_vec(&info)?).await?; if loader.is_some() { write_derived_version_cache_marker(&path).await?; } info }; crate::api::loader_metadata::ensure_installer_artifacts(st, &res).await?; if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, 5.0, None)?; } tracing::debug!("Loaded version info for Minecraft {version_id}"); Ok(res) } pub async fn load_local_version_info( st: &State, version: &GameVersion, mod_loader: ModLoader, loader: Option<&LoaderVersion>, ) -> crate::Result { let version_id = loader .map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); let path = st .directories .version_dir(&version_id) .join(format!("{version_id}.json")); if !path.is_file() { return Err(crate::ErrorKind::LauncherError(format!( "Offline mode can only launch fully downloaded instances; missing {}", path.display() )) .as_error()); } let bytes = io::read(&path).err_into::().await?; let mut info: GameVersionInfo = serde_json::from_slice(&bytes)?; let normalized = normalize_version_info(mod_loader, &version.id, &mut info, "cache"); let restored_legacy_arguments = restore_legacy_minecraft_arguments(st, version, loader, &mut info) .await?; if normalized || restored_legacy_arguments { write_version_info(&path, serde_json::to_vec(&info)?).await?; } Ok(info) } async fn write_version_info(path: &Path, data: Vec) -> crate::Result<()> { if let Some(parent) = path.parent() { io::create_dir_all(parent).await?; } io::write(path, data).await?; Ok(()) } // Bumped when profile merge semantics change. This forces existing loader // caches to be regenerated so duplicate Forge/vanilla libraries regain native // classifier metadata. const DERIVED_VERSION_CACHE_FORMAT: &str = "2"; fn derived_version_cache_marker_path(path: &Path) -> PathBuf { path.with_extension("json.axolotl-format") } async fn derived_version_cache_is_current(path: &Path) -> bool { tokio::fs::read_to_string(derived_version_cache_marker_path(path)) .await .is_ok_and(|format| format.trim() == DERIVED_VERSION_CACHE_FORMAT) } async fn write_derived_version_cache_marker(path: &Path) -> crate::Result<()> { io::write( derived_version_cache_marker_path(path), DERIVED_VERSION_CACHE_FORMAT, ) .await?; Ok(()) } async fn restore_legacy_minecraft_arguments( st: &State, version: &GameVersion, loader: Option<&LoaderVersion>, version_info: &mut GameVersionInfo, ) -> crate::Result { if loader.is_none() || version_info.minecraft_arguments.is_some() { return Ok(false); } let vanilla_path = st .directories .version_dir(&version.id) .join(format!("{}.json", version.id)); if !vanilla_path.is_file() { return Ok(false); } let bytes = match io::read(&vanilla_path).await { Ok(bytes) => bytes, Err(error) => { tracing::warn!( minecraft_version = version.id, path = %vanilla_path.display(), error = %error, "Failed to read vanilla version profile while restoring legacy game arguments" ); return Ok(false); } }; let vanilla: GameVersionInfo = match serde_json::from_slice(&bytes) { Ok(version_info) => version_info, Err(error) => { tracing::warn!( minecraft_version = version.id, path = %vanilla_path.display(), error = %error, "Failed to parse vanilla version profile while restoring legacy game arguments" ); return Ok(false); } }; let Some(arguments) = vanilla.minecraft_arguments else { return Ok(false); }; version_info.minecraft_arguments = Some(arguments); tracing::info!( minecraft_version = version.id, "Restored legacy Minecraft game arguments from the vanilla version profile" ); Ok(true) } fn normalize_version_info( loader: ModLoader, game_version: &str, version_info: &mut GameVersionInfo, version_info_source: &str, ) -> bool { let removed = d::modded::normalize_loader_libraries( loader.as_meta_str(), game_version, &mut version_info.libraries, ); for removed_library in &removed { tracing::info!( loader = loader.as_meta_str(), game_version, removed_library, version_info_source, "Removed loader-incompatible library from Minecraft version profile" ); } let mut changed = !removed.is_empty(); if version_info .libraries .iter() .any(|library| is_liteloader_library(&library.name)) && version_info .java_version .as_ref() .is_none_or(|java| java.major_version != 8) { version_info.java_version = Some(d::minecraft::JavaVersion { component: "jre-legacy".to_string(), major_version: 8, }); tracing::info!( loader = loader.as_meta_str(), game_version, version_info_source, "Pinned LiteLoader version profile to Java 8" ); changed = true; } changed } fn is_liteloader_library(name: &str) -> bool { let mut coordinates = name.split(':'); coordinates.next() == Some("com.mumfrey") && coordinates.next() == Some("liteloader") && coordinates.next().is_some() } pub fn ensure_local_log_config( st: &State, version_info: &GameVersionInfo, ) -> crate::Result<()> { let log_download = version_info .logging .as_ref() .and_then(|logging| logging.get(&LoggingSide::Client)); let Some(LoggingConfiguration::Log4j2Xml { file, .. }) = log_download else { return Ok(()); }; let path = st.directories.log_configs_dir().join(&file.id); if !path.is_file() { return Err(crate::ErrorKind::LauncherError(format!( "Offline mode can only launch fully downloaded instances; missing {}", path.display() )) .as_error()); } Ok(()) } #[tracing::instrument(skip_all)] pub async fn download_client( st: &State, local_source: Option<&LocalRuntimeSource>, game_version: &str, version_info: &GameVersionInfo, loading_bar: Option<&LoadingBarId>, force: bool, progress: Option, ) -> crate::Result<()> { let version = &version_info.id; tracing::debug!("Locating client for version {version}"); let client_download = version_info .downloads .get(&d::minecraft::DownloadType::Client) .ok_or( crate::ErrorKind::LauncherError(format!( "No client downloads exist for version {version}" )) .as_error(), )?; let path = st .directories .version_dir(version) .join(format!("{version}.jar")); if !path.exists() || force { let context = InstallErrorContext::new("download Minecraft client") .minecraft_version(version.to_string()) .file_path(format!("{version}.jar")) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_client_path(game_version), &path, Some(&client_download.sha1), Some(client_download.size as u64), progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &client_download.url, Some(&client_download.sha1), Some(client_download.size as u64), &path, ResourceClass::MinecraftLibrary, ContentValidation::Jar, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::trace!("Reused local client version {version}"); } else { tracing::trace!("Fetched client version {version}"); } } if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, 9.0, None)?; } tracing::debug!("Client loaded for version {version}!"); Ok(()) } #[tracing::instrument(skip_all)] pub async fn download_assets_index( st: &State, local_source: Option<&LocalRuntimeSource>, version: &GameVersionInfo, loading_bar: Option<&LoadingBarId>, force: bool, progress: Option, ) -> crate::Result { tracing::debug!("Loading assets index"); let path = st .directories .assets_index_dir() .join(format!("{}.json", &version.asset_index.id)); let cached = if path.exists() && !force { match io::read(&path) .err_into::() .await .and_then(|ref bytes| Ok(serde_json::from_slice(bytes)?)) { Ok(index) => Some(index), Err(error) => { tracing::warn!( path = %path.display(), error = %error, "Cached assets index is invalid; downloading a replacement" ); io::remove_file(&path).await?; None } } } else { None }; let res = if let Some(index) = cached { index } else { let context = InstallErrorContext::new("download Minecraft assets index") .minecraft_version(version.id.clone()) .file_path(format!("{}.json", version.asset_index.id)) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_asset_index_path(&version.asset_index.id), &path, Some(&version.asset_index.sha1), Some(version.asset_index.size as u64), progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &version.asset_index.url, Some(&version.asset_index.sha1), Some(version.asset_index.size as u64), &path, ResourceClass::Metadata, ContentValidation::Json, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::info!("Reused local assets index"); } else { tracing::info!("Fetched assets index"); } serde_json::from_slice(&io::read(&path).await?)? }; if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, 5.0, None)?; } tracing::debug!("Assets index successfully loaded!"); Ok(res) } /// Owned per-asset work item for the per-file fallback path of /// `download_assets`. Owned so the concurrent fallback futures do not borrow /// from the assets index. struct FallbackAsset { name: String, hash: String, size: u64, url: String, resource_path: PathBuf, legacy_resource_paths: Vec, logical_items: u32, } fn build_fallback_asset( st: &State, name: &str, asset: &Asset, ) -> FallbackAsset { let hash = &asset.hash; let url = format!( "https://resources.download.minecraft.net/{sub_hash}/{hash}", sub_hash = &hash[..2] ); FallbackAsset { name: name.to_string(), hash: hash.clone(), size: asset.size as u64, url, resource_path: st.directories.object_dir(hash), legacy_resource_paths: vec![ st.directories.legacy_assets_dir().join( name.replace('/', &String::from(std::path::MAIN_SEPARATOR)), ), ], logical_items: 1, } } fn build_fallback_asset_from_batch(item: H2BatchAsset) -> FallbackAsset { FallbackAsset { name: item.sha1.clone(), hash: item.sha1, size: item.size, url: item.original_url, resource_path: item.destination, legacy_resource_paths: item.legacy_destinations, logical_items: item.logical_items, } } /// Coalesce index aliases that refer to one physical object. The key includes /// the destination and full integrity contract so unrelated artifacts can /// never share a writer. Legacy paths remain separate outputs of that single /// committed object. fn coalesce_batch_assets(items: Vec) -> Vec { let mut positions: HashMap<(PathBuf, String, u64), usize> = HashMap::new(); let mut coalesced: Vec = Vec::new(); for mut item in items { let key = (item.destination.clone(), item.sha1.clone(), item.size); if let Some(&position) = positions.get(&key) { let existing = &mut coalesced[position]; existing.logical_items = existing.logical_items.saturating_add(item.logical_items); for legacy in item.legacy_destinations.drain(..) { if !existing.legacy_destinations.contains(&legacy) { existing.legacy_destinations.push(legacy); } } } else { positions.insert(key, coalesced.len()); coalesced.push(item); } } coalesced } #[tracing::instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn download_assets( st: &State, local_source: Option<&LocalRuntimeSource>, with_legacy: bool, index: &AssetsIndex, loading_bar: Option<&LoadingBarId>, loading_amount: f64, force: bool, progress: Option, ) -> crate::Result<()> { tracing::debug!("Loading assets"); let num_futs = index.objects.len(); let per_file_fraction = if num_futs > 0 { loading_amount / num_futs as f64 } else { 0.0 }; // Partition assets: batch downloads (object missing), legacy-only copies // (object present, legacy missing), and per-file fallbacks (local reuse, // no batch route, or failed batch items). let mut batch_items = Vec::new(); let mut legacy_copies = Vec::new(); let mut fallback_assets = Vec::new(); let mut skipped_count = 0_u64; for (name, asset) in index.objects.iter() { let hash = &asset.hash; let resource_path = st.directories.object_dir(hash); let legacy_resource_path = st .directories .legacy_assets_dir() .join(name.replace('/', &String::from(std::path::MAIN_SEPARATOR))); let should_fetch_object = force || !asset_file_is_usable(&resource_path, asset.size as u64); let should_fetch_legacy = (with_legacy && !asset_file_is_usable(&legacy_resource_path, asset.size as u64)) || force; if should_fetch_object { let local_candidate = !force && super::local_artifact::candidate_is_usable( local_source, &local_asset_object_path(hash), Some(asset.size as u64), ) .await?; if local_candidate { fallback_assets.push(build_fallback_asset(st, name, asset)); } else { let url = format!( "https://resources.download.minecraft.net/{sub_hash}/{hash}", sub_hash = &hash[..2] ); batch_items.push(H2BatchAsset { original_url: url.clone(), url, destination: resource_path, legacy_destinations: should_fetch_legacy .then_some(legacy_resource_path) .into_iter() .collect(), sha1: hash.clone(), size: asset.size as u64, logical_items: 1, }); } } else if should_fetch_legacy { legacy_copies.push((name, asset)); } else { skipped_count += 1; } } let mut batch_items = coalesce_batch_assets(batch_items); // Batch-download the object files over a single shared HTTP/2 connection: // hundreds of concurrent multiplexed streams, one connection per // authority — never one connection per file. if !batch_items.is_empty() { let source_mode = st.minecraft_file_source(); let first_url = batch_items[0].url.clone(); let mut routes = resolve_download_routes_for( &first_url, ResourceClass::MinecraftAsset, source_mode, ); let apply_native_policy = crate::util::download::active_engine() != crate::util::download::DownloadEngine::XmclCompat; if apply_native_policy { let probe_request = DownloadRequest::new(&first_url, ResourceClass::MinecraftAsset) .with_integrity( Integrity::sha1(&batch_items[0].sha1) .with_size(batch_items[0].size), ); prepare_native_download_routes( &probe_request, &mut routes, &st.fetch_semaphore, ) .await; } let route = routes.into_iter().next(); if let Some(route) = route { // Resolve each item's URL onto the chosen route (official or // mirror) so the batch reuses one connection to that authority. // Items whose resolved URL targets a different authority cannot // share the batch connection and go through the per-file path. let route_authority = url_authority(&route.url); for item in &mut batch_items { let item_routes = resolve_download_routes_for( &item.url, ResourceClass::MinecraftAsset, source_mode, ); item.url = item_routes .iter() .find(|candidate| { apply_native_policy && candidate.source == route.source && candidate.proxy == route.proxy }) .or_else(|| item_routes.first()) .map(|route| route.url.clone()) .unwrap_or_else(|| item.url.clone()); } let (batch_items, rerouted_items): (Vec<_>, Vec<_>) = batch_items .into_iter() .partition(|item| url_authority(&item.url) == route_authority); fallback_assets.extend( rerouted_items .into_iter() .map(build_fallback_asset_from_batch), ); let callback = { let progress = progress.clone(); let loading_bar = loading_bar.cloned(); move |item: H2BatchAsset| -> Pin + Send>> { let progress = progress.clone(); let loading_bar = loading_bar.clone(); Box::pin(async move { for _ in 0..item.logical_items { if let Some(progress) = &progress { if let Err(error) = progress.add_bytes(item.size).await { tracing::warn!( error = %error, "Failed to record batch asset bytes" ); } } if let Some(loading_bar) = &loading_bar { let _ = emit_loading(loading_bar, per_file_fraction, None); } } }) } }; let failed = download_asset_batch_via_h2( &route, batch_items, ASSET_BATCH_CONCURRENCY, apply_native_policy, apply_native_policy.then_some(&st.fetch_semaphore), callback, ) .await?; if !failed.is_empty() { tracing::warn!( items = failed.len(), source = route.source.as_str(), "Falling back to per-file downloads for {} batch-failed Minecraft assets on route {}", failed.len(), route.source.as_str(), ); fallback_assets.extend( failed.into_iter().map(build_fallback_asset_from_batch), ); } } else { // No route could be resolved; fall back to per-file for all. fallback_assets.extend( batch_items.into_iter().map(build_fallback_asset_from_batch), ); } } // Legacy copies for assets whose object is already on disk. futures::stream::iter(legacy_copies) .map(Ok::<_, crate::Error>) .try_for_each_concurrent( crate::util::download::task_concurrency_limit(st), |(name, asset)| { let progress = progress.clone(); async move { let resource_path = st.directories.object_dir(&asset.hash); let legacy_resource_path = st.directories.legacy_assets_dir().join(name.replace( '/', &String::from(std::path::MAIN_SEPARATOR), )); crate::util::fetch::copy( &resource_path, &legacy_resource_path, &st.io_semaphore, ) .await?; if let Some(progress) = &progress { progress.add_bytes(asset.size as u64).await?; } if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, per_file_fraction, None)?; } Ok::<_, crate::Error>(()) } }, ) .await?; // Per-file fallback path: local runtime reuse, no batch route, or batch // failures. Runs concurrently (same budget as the original scheduler) so // import flows are not serialised. if !fallback_assets.is_empty() { let limit = crate::util::download::task_concurrency_limit(st) .map(|limit| limit.saturating_mul(2)) .unwrap_or(ASSET_BATCH_CONCURRENCY); futures::stream::iter(fallback_assets) .map(Ok::) .try_for_each_concurrent(limit, |item| { let progress = progress.clone(); async move { let resource_path = &item.resource_path; let hash = &item.hash; let name = &item.name; let should_fetch_object = force || !asset_file_is_usable(resource_path, item.size); let legacy_resource_paths = item .legacy_resource_paths .iter() .filter(|path| { force || (with_legacy && !asset_file_is_usable(path, item.size)) }) .collect::>(); let should_fetch_legacy = !legacy_resource_paths.is_empty(); let fetch_progress = if should_fetch_object || should_fetch_legacy { progress.clone() } else { None }; if should_fetch_object { let context = InstallErrorContext::new( "download Minecraft asset", ) .file_path(name.clone()) .target_path(resource_path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_asset_object_path(hash), resource_path, Some(hash), Some(item.size), fetch_progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &item.url, Some(hash), Some(item.size), resource_path, ResourceClass::MinecraftAsset, ContentValidation::None, force, fetch_progress.clone(), context, ) }, ) .await?; if reused { tracing::trace!("Reused asset with hash {hash}"); } else { tracing::trace!("Fetched asset with hash {hash}"); } if item.logical_items > 1 && let Some(progress) = &fetch_progress { for _ in 1..item.logical_items { progress.add_bytes(item.size).await?; } } } if should_fetch_legacy { for legacy_resource_path in legacy_resource_paths { crate::util::fetch::copy( resource_path, legacy_resource_path, &st.io_semaphore, ) .await?; if !should_fetch_object && let Some(progress) = &fetch_progress { progress.add_bytes(item.size).await?; } tracing::trace!( "Copied legacy asset with hash {hash}" ); } } if let Some(loading_bar) = loading_bar { for _ in 0..item.logical_items { emit_loading(loading_bar, per_file_fraction, None)?; } } tracing::trace!("Loaded asset with hash {hash}"); Ok::<_, crate::Error>(()) } }) .await?; } // Account for assets that were already on disk so the loading bar still // reaches its total. for _ in 0..skipped_count { if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, per_file_fraction, None)?; } } tracing::debug!("Done loading assets!"); Ok(()) } #[tracing::instrument(skip_all, fields(version))] #[allow(clippy::too_many_arguments)] pub async fn download_libraries( st: &State, local_source: Option<&LocalRuntimeSource>, libraries: &[Library], version: &str, loading_bar: Option<&LoadingBarId>, loading_amount: f64, java_arch: &str, force: bool, minecraft_updated: bool, progress: Option, ) -> crate::Result<()> { tracing::debug!("Loading libraries"); tokio::try_join! { io::create_dir_all(st.directories.libraries_dir()), io::create_dir_all(st.directories.version_natives_dir(version)) }?; // Plan individual file download tasks instead of treating each // Library as mutually exclusive "normal or native". A merged library // (e.g. LWJGL after manifest merging) may carry both a Java artifact // and a native classifier for this platform; both become independent // concurrent tasks so neither is starved by the other. enum LibraryDownloadTask<'a> { JavaArtifact(&'a Library), NativeArtifact(&'a Library), } let mut tasks: Vec> = Vec::new(); // Java artifact tasks: every library with a downloadable Java JAR. // Deduplicate by target path so repeated manifest entries download once. let mut seen_java_paths = std::collections::HashSet::new(); for library in libraries { if !java_artifact_applies(library, java_arch, minecraft_updated) { continue; } let target = d::get_path_from_artifact(&library.name) .unwrap_or_else(|_| library.name.clone()); if seen_java_paths.insert(target) { tasks.push(LibraryDownloadTask::JavaArtifact(library)); } } // Native artifact tasks: reuse the existing native planner, which // handles rules, classifier existence and SHA-1 deduplication. for library in native_libraries_to_download(libraries, java_arch, minecraft_updated)? { tasks.push(LibraryDownloadTask::NativeArtifact(library)); } let num_files = tasks.len(); loading_try_for_each_concurrent( stream::iter(tasks).map(Ok::, crate::Error>), crate::util::download::task_concurrency_limit(&st).map(|limit| limit.saturating_mul(2)), loading_bar, loading_amount, num_files, None, |task| { let progress = progress.clone(); async move { let library = match &task { LibraryDownloadTask::JavaArtifact(lib) => *lib, LibraryDownloadTask::NativeArtifact(lib) => *lib, }; if let Some(rules) = &library.rules && !parse_rules( rules, java_arch, &QuickPlayType::None, minecraft_updated, ) { tracing::trace!("Skipped library {}", &library.name); return Ok(()); } if !library.downloadable { tracing::trace!( "Skipped non-downloadable library {}", &library.name ); return Ok(()); } match task { LibraryDownloadTask::JavaArtifact(library) => { let artifact_path = d::get_path_from_artifact(&library.name)?; let path = st.directories.libraries_dir().join(&artifact_path); if path.exists() && !force { // already present; skip Java artifact download but continue to natives } else if let Some(d::minecraft::LibraryDownloads { artifact: Some(ref artifact), .. }) = library.downloads && !artifact.url.is_empty() { let local_relative = local_library_path(&library.name)?; let context = InstallErrorContext::new( "download Minecraft library", ) .minecraft_version(version.to_string()) .file_path(library.name.clone()) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_relative, &path, Some(&artifact.sha1), Some(artifact.size as u64), progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &artifact.url, Some(&artifact.sha1), Some(artifact.size as u64), &path, ResourceClass::MinecraftLibrary, ContentValidation::None, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::trace!( "Reused library {} to path {:?}", &library.name, &path ); } else { tracing::trace!( "Fetched library {} to path {:?}", &library.name, &path ); } } else { let Some(urls) = legacy_library_download_urls( library.url.as_deref(), &artifact_path, ) else { return Err(crate::ErrorKind::LauncherError(format!( "No safe Maven repository is known for required library {}", library.name )) .into()); }; let local_relative = local_library_path(&library.name)?; let context = InstallErrorContext::new( "download loader library", ) .minecraft_version(version.to_string()) .file_path(library.name.clone()) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_relative, &path, legacy_library_sha1(library), None, progress.as_ref(), context.clone(), force, || { download_minecraft_file_with_candidates( st, &urls, legacy_library_sha1(library), None, &path, ResourceClass::Loader, legacy_library_content_validation(&artifact_path), force, progress.clone(), context, ) }, ) .await?; if reused { tracing::debug!( "Reused legacy library {} to path {:?}", &library.name, &path ); } else { tracing::debug!( "Fetched legacy library {} to path {:?}", &library.name, &path ); } } } LibraryDownloadTask::NativeArtifact(library) => { if let Some(classifier) = library_native_classifier(library, java_arch) { let native = library .downloads .as_ref() .and_then(|downloads| downloads.classifiers.as_ref()) .and_then(|classifiers| classifiers.get(&classifier)); let _native_archive_path = if let Some(native) = native { let path = st .directories .caches_dir() .join("minecraft-natives") .join(format!("{}.jar", native.sha1)); let context = InstallErrorContext::new( "download Minecraft native library", ) .minecraft_version(version.to_string()) .file_path(library.name.clone()) .target_path(path.display().to_string()) .build(); let local_relative = local_native_library_path(library, native, &classifier)?; let reused = download_or_reuse_local( st, local_source, &local_relative, &path, Some(&native.sha1), Some(native.size as u64), progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &native.url, Some(&native.sha1), Some(native.size as u64), &path, ResourceClass::MinecraftLibrary, ContentValidation::Jar, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::trace!("Reused native {}", &library.name); } path } else if library_classifier(&library.name) .is_some_and(|value| value.starts_with("natives-")) && let Some(artifact) = library .downloads .as_ref() .and_then(|downloads| downloads.artifact.as_ref()) .filter(|artifact| !artifact.url.trim().is_empty()) { let artifact_path = native_library_artifact_path(library, &classifier)?; let path = st .directories .libraries_dir() .join(&artifact_path); let local_relative = Path::new("libraries").join(&artifact_path); let context = InstallErrorContext::new( "download Minecraft native library", ) .minecraft_version(version.to_string()) .file_path(format!("{}:{classifier}", library.name)) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_relative, &path, Some(&artifact.sha1), Some(artifact.size as u64), progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &artifact.url, Some(&artifact.sha1), Some(artifact.size as u64), &path, ResourceClass::MinecraftLibrary, ContentValidation::Jar, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::trace!("Reused native {}", &library.name); } else { tracing::trace!("Fetched native {}", &library.name); } path } else { let artifact_path = native_library_artifact_path( library, &classifier, )?; let path = st.directories.libraries_dir().join(&artifact_path); let Some(urls) = legacy_library_download_urls( library.url.as_deref(), &artifact_path, ) else { return Err(crate::ErrorKind::LauncherError(format!( "No safe Maven repository is known for required native library {}", library.name )) .into()); }; let local_relative = Path::new("libraries").join(&artifact_path); let context = InstallErrorContext::new( "download loader native library", ) .minecraft_version(version.to_string()) .file_path(format!("{}:{classifier}", library.name)) .urls(urls.clone()) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_relative, &path, None, None, progress.as_ref(), context.clone(), force, || { download_minecraft_file_with_candidates( st, &urls, None, None, &path, ResourceClass::Loader, ContentValidation::Jar, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::debug!( "Reused legacy native {} to path {:?}", &library.name, &path ); } else { tracing::debug!( "Fetched legacy native {} to path {:?}", &library.name, &path ); } path }; tracing::debug!("Downloaded native {}", &library.name); } else { tracing::trace!( "Skipped native library without a classifier for this platform: {}", &library.name ); } } } tracing::debug!("Loaded library {}", library.name); Ok(()) } }, ) .await?; crate::launcher::natives::prepare_native_libraries( &st.directories.natives_dir(), &st.directories.libraries_dir(), &st.directories.caches_dir(), libraries, version, java_arch, minecraft_updated, ) .await?; tracing::debug!("Done loading libraries!"); Ok(()) } fn native_libraries_to_download<'a>( libraries: &'a [Library], java_arch: &str, minecraft_updated: bool, ) -> crate::Result> { let mut identities = HashSet::new(); let mut result = Vec::new(); for library in libraries { if !is_native_library(library) || !library.downloadable { continue; } if let Some(rules) = &library.rules && !parse_rules( rules, java_arch, &QuickPlayType::None, minecraft_updated, ) { continue; } let Some(classifier) = library_native_classifier(library, java_arch) else { continue; }; let identity = library .downloads .as_ref() .and_then(|downloads| downloads.classifiers.as_ref()) .and_then(|classifiers| classifiers.get(&classifier)) .filter(|download| !download.sha1.is_empty()) .map_or_else( || native_library_artifact_path(library, &classifier), |download| Ok(download.sha1.clone()), )?; if identities.insert(identity.clone()) { result.push(library); } else { tracing::debug!( library = %library.name, classifier, identity, "Skipped duplicate native archive download" ); } } Ok(result) } #[tracing::instrument(skip_all)] pub async fn download_log_config( st: &State, local_source: Option<&LocalRuntimeSource>, version_info: &GameVersionInfo, loading_bar: Option<&LoadingBarId>, force: bool, progress: Option, ) -> crate::Result { let log_download = version_info .logging .as_ref() .and_then(|x| x.get(&LoggingSide::Client)); let Some(LoggingConfiguration::Log4j2Xml { file: log_download, .. }) = log_download else { if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, 1.0, None)?; } return Ok(false); }; let path = st.directories.log_configs_dir().join(&log_download.id); if !path.exists() || force { let context = InstallErrorContext::new("download Minecraft log config") .minecraft_version(version_info.id.clone()) .file_path(log_download.id.clone()) .target_path(path.display().to_string()) .build(); let reused = download_or_reuse_local( st, local_source, &local_log_config_path(&log_download.id), &path, Some(&log_download.sha1), Some(log_download.size as u64), progress.as_ref(), context.clone(), force, || { download_minecraft_file( st, &log_download.url, Some(&log_download.sha1), Some(log_download.size as u64), &path, ResourceClass::MinecraftLibrary, ContentValidation::None, force, progress.clone(), context, ) }, ) .await?; if reused { tracing::trace!("Reused log config {}", log_download.id); } else { tracing::trace!("Fetched log config {}", log_download.id); } } if let Some(loading_bar) = loading_bar { emit_loading(loading_bar, 1.0, None)?; } tracing::debug!("Log config {} loaded", log_download.id); Ok(true) } #[cfg(test)] mod tests { use super::*; use crate::launcher::natives::prepare_native_libraries as prepare_test_natives; fn urls(values: &[&str]) -> Option> { Some(values.iter().map(|value| (*value).to_string()).collect()) } #[test] fn batch_assets_coalesce_duplicate_objects_and_keep_legacy_targets() { let destination = PathBuf::from("assets/objects/ab/abcdef"); let first_legacy = PathBuf::from("resources/first"); let second_legacy = PathBuf::from("resources/second"); let make_item = |legacy_destinations| H2BatchAsset { original_url: "https://resources.download.minecraft.net/ab/abcdef" .into(), url: "https://resources.download.minecraft.net/ab/abcdef".into(), destination: destination.clone(), legacy_destinations, sha1: "abcdef".into(), size: 42, logical_items: 1, }; let items = coalesce_batch_assets(vec![ make_item(vec![first_legacy.clone()]), make_item(vec![second_legacy.clone(), first_legacy.clone()]), ]); assert_eq!(items.len(), 1); assert_eq!(items[0].logical_items, 2); assert_eq!( items[0].legacy_destinations, vec![first_legacy, second_legacy] ); } #[test] fn batch_assets_do_not_coalesce_different_integrity_contracts() { let destination = PathBuf::from("assets/objects/ab/object"); let make_item = |sha1: &str, size| H2BatchAsset { original_url: format!( "https://resources.download.minecraft.net/ab/{sha1}" ), url: format!("https://resources.download.minecraft.net/ab/{sha1}"), destination: destination.clone(), legacy_destinations: Vec::new(), sha1: sha1.into(), size, logical_items: 1, }; assert_eq!( coalesce_batch_assets(vec![ make_item("first", 42), make_item("second", 42), make_item("first", 43), ]) .len(), 3 ); } #[test] fn batch_fallback_restores_official_asset_route_after_mirror_failure() { let official = "https://resources.download.minecraft.net/14/14b3534e2622470a71dbe69474c15e6a233cc1c6"; let item = H2BatchAsset { original_url: official.into(), url: "https://bmclapi2.bangbang93.com/assets/14/14b3534e2622470a71dbe69474c15e6a233cc1c6" .into(), destination: PathBuf::from("assets/objects/14/hash"), legacy_destinations: Vec::new(), sha1: "14b3534e2622470a71dbe69474c15e6a233cc1c6".into(), size: 42, logical_items: 1, }; let fallback = build_fallback_asset_from_batch(item); let routes = resolve_download_routes_for( &fallback.url, ResourceClass::MinecraftAsset, crate::state::DownloadSourceMode::MirrorPreferred, ); assert_eq!(fallback.url, official); assert!(routes.iter().any(|route| route.url == official)); } #[test] fn asset_fast_path_requires_a_regular_file_with_expected_size() { let directory = tempfile::tempdir().unwrap(); let valid = directory.path().join("valid"); let truncated = directory.path().join("truncated"); let folder = directory.path().join("folder"); std::fs::write(&valid, b"asset").unwrap(); std::fs::write(&truncated, b"as").unwrap(); std::fs::create_dir(&folder).unwrap(); assert!(asset_file_is_usable(&valid, 5)); assert!(!asset_file_is_usable(&valid, 4)); assert!(!asset_file_is_usable(&truncated, 5)); assert!(!asset_file_is_usable(&folder, 0)); assert!(!asset_file_is_usable(&directory.path().join("missing"), 5)); } #[tokio::test] async fn cached_artifact_with_wrong_size_is_not_reused() { let directory = tempfile::tempdir().unwrap(); let destination = directory.path().join("asset"); std::fs::write(&destination, b"bad").unwrap(); assert_eq!( classify_local_artifact( None, &destination, Path::new("assets/objects/00/hash"), Some("unused"), Some(5), ) .await .unwrap(), ArtifactAvailability::NetworkRequired, ); } #[tokio::test] async fn local_asset_availability_distinguishes_reusable_and_missing() { let directory = tempfile::tempdir().unwrap(); let local_root = directory.path().join(".minecraft"); let relative = Path::new("assets/objects/ab/hash"); let candidate = local_root.join(relative); io::create_dir_all(candidate.parent().unwrap()) .await .unwrap(); io::write(&candidate, "asset").await.unwrap(); let source = LocalRuntimeSource { root: local_root }; let destination = directory.path().join("destination"); assert_eq!( classify_local_artifact( Some(&source), &destination, relative, Some("0000000000000000000000000000000000000000"), Some(5), ) .await .unwrap(), ArtifactAvailability::NetworkRequired ); assert_eq!( classify_local_artifact( Some(&source), &destination, relative, Some("05fac94380a70241f23780e7aef62b190894238f"), Some(5), ) .await .unwrap(), ArtifactAvailability::LocalReusable ); } #[test] fn liteloader_library_detection_is_coordinate_specific() { assert!(is_liteloader_library( "com.mumfrey:liteloader:1.12.2-SNAPSHOT" )); assert!(!is_liteloader_library("example:liteloader:1.12.2-SNAPSHOT")); assert!(!is_liteloader_library("com.mumfrey:other:1.0")); assert!(!is_liteloader_library("com.mumfrey:liteloader")); } #[test] fn legacy_native_library_uses_platform_classifier_without_downloads_block() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4+legacyfabric.17", "url": "https://maven.legacyfabric.net/", "natives": { "linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows" }, "checksums": ["05fac94380a70241f23780e7aef62b190894238f"] })) .unwrap(); assert!(library.natives_os_key_and_classifiers("x86_64").is_none()); let classifier = library_native_classifier(&library, "x86_64").unwrap(); assert_eq!( legacy_library_sha1(&library), Some("05fac94380a70241f23780e7aef62b190894238f") ); assert_eq!( legacy_library_content_validation("lwjgl-platform.jar"), ContentValidation::Jar ); let artifact_path = classified_library_artifact_path(&library.name, &classifier) .unwrap(); assert_eq!( artifact_path, format!( "org/lwjgl/lwjgl/lwjgl-platform/2.9.4+legacyfabric.17/lwjgl-platform-2.9.4+legacyfabric.17-{classifier}.jar" ) ); assert_eq!( legacy_library_download_urls( library.url.as_deref(), &artifact_path, ) .unwrap()[0], format!("https://maven.legacyfabric.net/{artifact_path}") ); } #[test] fn native_library_with_legacy_repository_keeps_java_artifact_task() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4", "url": "https://libraries.minecraft.net/", "natives": { "windows": "natives-windows" } })) .unwrap(); assert!(needs_java_artifact(&library)); } #[test] fn legacy_launchwrapper_without_download_metadata_is_downloaded() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "net.minecraft:launchwrapper:1.12" })) .unwrap(); let artifact_path = d::get_path_from_artifact(&library.name).unwrap(); assert!(needs_java_artifact(&library)); assert_eq!( legacy_library_download_urls( library.url.as_deref(), &artifact_path ) .unwrap(), vec![format!("{LIBRARIES_MAVEN}/{artifact_path}")] ); } #[test] fn neoforge_processor_dependency_is_downloaded_outside_runtime_classpath() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "net.neoforged.installertools:installertools:2.1.2", "include_in_classpath": false, "downloadable": true, "downloads": {"artifact": { "path": "net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar", "url": "https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar", "sha1": "72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b", "size": 83543 }} })) .unwrap(); assert!(!library.include_in_classpath); assert!(needs_java_artifact(&library)); assert!(java_artifact_applies(&library, "x86_64", false)); } #[test] fn java_artifact_rules_are_applied_before_path_deduplication() { let blocked: Library = serde_json::from_value(serde_json::json!({ "name": "example:library:1.0", "rules": [{"action": "allow", "features": {"is_demo_user": true}}], "downloads": {"artifact": { "url": "https://example.invalid/library.jar", "sha1": "", "size": 1 }} })) .unwrap(); let allowed: Library = serde_json::from_value(serde_json::json!({ "name": "example:library:1.0", "downloads": {"artifact": { "url": "https://example.invalid/library.jar", "sha1": "", "size": 1 }} })) .unwrap(); assert!(!java_artifact_applies(&blocked, "x86_64", true)); assert!(java_artifact_applies(&allowed, "x86_64", true)); } #[test] fn empty_legacy_repository_is_not_a_java_artifact_source() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "example:native:1.0", "url": "", "natives": {"windows": "natives-windows"} })) .unwrap(); assert!(!needs_java_artifact(&library)); } #[test] fn forge_classifier_native_library_uses_its_declared_artifact_path() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.lwjgl:lwjgl:3.2.2:natives-windows" })) .unwrap(); assert!(is_native_library(&library)); assert_eq!( library_native_classifier(&library, "x86_64"), Some("natives-windows".to_string()) ); assert_eq!( native_library_artifact_path(&library, "natives-windows").unwrap(), "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar" ); } #[tokio::test] async fn writing_version_info_creates_missing_parent_directory() { let directory = tempfile::tempdir().unwrap(); let path = directory .path() .join("meta/versions/1.21.11-21.11.44/1.21.11-21.11.44.json"); assert!(!path.parent().unwrap().exists()); write_version_info(&path, br#"{"id":"1.21.11-21.11.44"}"#.to_vec()) .await .unwrap(); assert_eq!( io::read(&path).await.unwrap(), br#"{"id":"1.21.11-21.11.44"}"# ); } #[tokio::test] async fn derived_version_cache_requires_current_format_marker() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("1.20.1-47.2.20.json"); write_version_info(&path, b"{}".to_vec()).await.unwrap(); assert!(!derived_version_cache_is_current(&path).await); io::write(derived_version_cache_marker_path(&path), "0") .await .unwrap(); assert!(!derived_version_cache_is_current(&path).await); write_derived_version_cache_marker(&path).await.unwrap(); assert!(derived_version_cache_is_current(&path).await); } #[test] fn legacy_launcher_meta_maven_uses_canonical_repositories() { assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven/"), "net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", ), urls(&[ "https://maven.fabricmc.net/net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", "https://launcher-meta.modrinth.com/maven/net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", "https://libraries.minecraft.net/net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", ]), ); assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven/"), "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", ), urls(&[ "https://repo.maven.apache.org/maven2/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", "https://launcher-meta.modrinth.com/maven/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", "https://libraries.minecraft.net/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", ]), ); assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven/"), "com/modrinth/daedalus/forge-installer-extracts/1.20.1-47.4.20/forge-installer-extracts-1.20.1-47.4.20-client.lzma", ), urls(&[ "https://launcher-meta.modrinth.com/maven/com/modrinth/daedalus/forge-installer-extracts/1.20.1-47.4.20/forge-installer-extracts-1.20.1-47.4.20-client.lzma", "https://libraries.minecraft.net/com/modrinth/daedalus/forge-installer-extracts/1.20.1-47.4.20/forge-installer-extracts-1.20.1-47.4.20-client.lzma", ]), ); assert_eq!( legacy_library_content_validation("library.jar"), ContentValidation::Jar, ); assert_eq!( legacy_library_content_validation("library.lzma"), ContentValidation::None, ); } #[test] fn legacy_scala_libraries_use_maven_central() { let artifact_path = "org/scala-lang/plugins/scala-continuations-library_2.11/1.0.2/scala-continuations-library_2.11-1.0.2.jar"; let expected = urls(&[ "https://repo.maven.apache.org/maven2/org/scala-lang/plugins/scala-continuations-library_2.11/1.0.2/scala-continuations-library_2.11-1.0.2.jar", "https://launcher-meta.modrinth.com/maven/org/scala-lang/plugins/scala-continuations-library_2.11/1.0.2/scala-continuations-library_2.11-1.0.2.jar", "https://libraries.minecraft.net/org/scala-lang/plugins/scala-continuations-library_2.11/1.0.2/scala-continuations-library_2.11-1.0.2.jar", ]); assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven"), artifact_path, ), expected, ); assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven/"), artifact_path, ), expected, ); } #[test] fn historical_forge_scala_modules_keep_archived_coordinates() { let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.scala-lang:scala-xml_2.11:1.0.2", "url": "https://launcher-meta.modrinth.com/maven/", "checksums": [ "7a80ec00aec122fba7cd4e0d4cdd87ff7e4cb6d0", "62736b01689d56b6d09a0164b7ef9da2b0b9633d" ] })) .unwrap(); let artifact_path = d::get_path_from_artifact(&library.name).unwrap(); assert_eq!( artifact_path, "org/scala-lang/scala-xml_2.11/1.0.2/scala-xml_2.11-1.0.2.jar", ); assert_eq!( legacy_library_sha1(&library), Some("7a80ec00aec122fba7cd4e0d4cdd87ff7e4cb6d0"), ); for artifact_path in [ "org/scala-lang/scala-parser-combinators_2.11/1.0.1/scala-parser-combinators_2.11-1.0.1.jar", "org/scala-lang/scala-swing_2.11/1.0.1/scala-swing_2.11-1.0.1.jar", "org/scala-lang/scala-xml_2.11/1.0.2/scala-xml_2.11-1.0.2.jar", ] { assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven/"), artifact_path, ), Some(vec![ format!("{FORGE_MAVEN}/{artifact_path}"), format!("{LAUNCHER_META_MAVEN}/{artifact_path}"), format!("{LIBRARIES_MAVEN}/{artifact_path}"), ]), ); } } #[test] fn legacy_forge_maven_central_groups_use_maven_central() { for artifact_path in [ "org/jline/jline/3.5.1/jline-3.5.1.jar", "jline/jline/2.13/jline-2.13.jar", "net/java/dev/jna/jna/4.4.0/jna-4.4.0.jar", "com/typesafe/akka/akka-actor_2.11/2.3.3/akka-actor_2.11-2.3.3.jar", "com/typesafe/config/1.2.1/config-1.2.1.jar", ] { assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven"), artifact_path, ), Some(vec![ format!("{MAVEN_CENTRAL}/{artifact_path}"), format!("{LAUNCHER_META_MAVEN}/{artifact_path}"), format!("{LIBRARIES_MAVEN}/{artifact_path}"), ]), ); } } #[test] fn legacy_maven_download_uses_trusted_declared_paths() { assert_eq!( legacy_library_download_urls( Some("https://launcher-meta.modrinth.com/maven/"), "example/unknown/1/unknown-1.jar", ), urls(&[ "https://launcher-meta.modrinth.com/maven/example/unknown/1/unknown-1.jar", "https://libraries.minecraft.net/example/unknown/1/unknown-1.jar", ]), ); assert_eq!( legacy_library_download_urls( Some("https://maven.minecraftforge.net"), "example/unknown/1/unknown-1.jar", ), urls(&[ "https://maven.minecraftforge.net/example/unknown/1/unknown-1.jar", "https://libraries.minecraft.net/example/unknown/1/unknown-1.jar", ]), ); assert_eq!( legacy_library_download_urls( Some("https://example.invalid/maven"), "net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", ), urls(&[ "https://example.invalid/maven/net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", "https://maven.fabricmc.net/net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", "https://libraries.minecraft.net/net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", ]), ); for repository in [ "http://example.invalid/maven", "https://user@example.invalid/maven", "https://example.invalid/maven?token=secret", ] { assert_eq!( legacy_library_download_urls( Some(repository), "example/unknown/1/unknown-1.jar", ), None, ); } for artifact_path in [ "/example/unknown.jar", "../example/unknown.jar", "example\\unknown.jar", ] { assert_eq!( legacy_library_download_urls( Some("https://example.invalid/maven"), artifact_path, ), None, ); } } fn write_native_archive(path: &Path, entries: &[(&str, &[u8])]) { use std::io::Write; let file = std::fs::File::create(path).unwrap(); let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored); for (name, contents) in entries { writer.start_file(*name, options.clone()).unwrap(); writer.write_all(contents).unwrap(); } writer.finish().unwrap(); } fn modern_native_library(sha1: &str) -> Library { serde_json::from_value(serde_json::json!({ "name": "org.lwjgl:lwjgl-platform:3.2.1", "natives": { "windows": "natives-windows" }, "downloads": { "classifiers": { "natives-windows": { "sha1": sha1, "size": 64, "url": "https://example.com/natives.jar" } } } })) .unwrap() } #[tokio::test] async fn missing_natives_are_restored_from_cached_archives() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let cache = caches_dir.join("minecraft-natives").join("deadbeef.jar"); std::fs::create_dir_all(cache.parent().unwrap()).unwrap(); write_native_archive( &cache, &[("lwjgl.dll", b"native-binary"), ("sub/inner.dll", b"inner")], ); let libraries = [modern_native_library("deadbeef")]; prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap(); let natives_dir = natives_root.join("1.16.5"); assert_eq!( std::fs::read(natives_dir.join("lwjgl.dll")).unwrap(), b"native-binary" ); assert_eq!( std::fs::read(natives_dir.join("sub/inner.dll")).unwrap(), b"inner" ); // Same-length corruption must be repaired on later launches. std::fs::write(natives_dir.join("lwjgl.dll"), b"tampered").unwrap(); prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap(); assert_eq!( std::fs::read(natives_dir.join("lwjgl.dll")).unwrap(), b"native-binary" ); } #[tokio::test] async fn only_missing_or_zero_byte_native_entries_are_restored() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let cache = caches_dir.join("minecraft-natives").join("deadbeef.jar"); std::fs::create_dir_all(cache.parent().unwrap()).unwrap(); write_native_archive( &cache, &[ ("a.dll", b"from-archive"), ("b.dll", b"repaired"), ("c.txt", b"added"), ], ); let natives_dir = natives_root.join("1.16.5"); std::fs::create_dir_all(&natives_dir).unwrap(); std::fs::write(natives_dir.join("a.dll"), b"custom").unwrap(); std::fs::write(natives_dir.join("b.dll"), b"").unwrap(); let libraries = [modern_native_library("deadbeef")]; prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap(); assert_eq!( std::fs::read(natives_dir.join("a.dll")).unwrap(), b"from-archive" ); assert_eq!( std::fs::read(natives_dir.join("b.dll")).unwrap(), b"repaired" ); assert_eq!(std::fs::read(natives_dir.join("c.txt")).unwrap(), b"added"); } #[tokio::test] async fn native_entries_escaping_natives_directory_are_ignored() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let cache = caches_dir.join("minecraft-natives").join("deadbeef.jar"); std::fs::create_dir_all(cache.parent().unwrap()).unwrap(); write_native_archive( &cache, &[("../evil.dll", b"boom"), ("ok.dll", b"fine")], ); let libraries = [modern_native_library("deadbeef")]; prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap(); let natives_dir = natives_root.join("1.16.5"); assert_eq!(std::fs::read(natives_dir.join("ok.dll")).unwrap(), b"fine"); assert!(!natives_root.join("evil.dll").exists()); assert!(!directory.path().join("evil.dll").exists()); } #[tokio::test] async fn missing_native_archive_reports_actionable_error() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let libraries = [modern_native_library("nonexistent")]; let error = prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap_err(); assert!( error .to_string() .contains("Repair the instance while online") ); } #[tokio::test] async fn missing_native_archive_with_extracted_natives_is_rejected() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let natives_dir = natives_root.join("1.16.5"); std::fs::create_dir_all(&natives_dir).unwrap(); std::fs::write(natives_dir.join("lwjgl.dll"), b"native-binary") .unwrap(); let libraries = [modern_native_library("nonexistent")]; let error = prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap_err(); assert!( error .to_string() .contains("Repair the instance while online") ); } #[tokio::test] async fn missing_cached_archive_falls_back_to_classified_library_archive() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let archive = libraries_dir.join( "org/lwjgl/lwjgl-platform/3.2.1/lwjgl-platform-3.2.1-natives-windows.jar", ); std::fs::create_dir_all(archive.parent().unwrap()).unwrap(); write_native_archive(&archive, &[("lwjgl.dll", b"from-libraries")]); let libraries = [modern_native_library("deadbeef")]; prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &libraries, "1.16.5", "x86_64", true, ) .await .unwrap(); assert_eq!( std::fs::read(natives_root.join("1.16.5").join("lwjgl.dll")) .unwrap(), b"from-libraries" ); } #[tokio::test] async fn legacy_native_libraries_restore_from_libraries_directory() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let archive = libraries_dir.join( "org/lwjgl/lwjgl/lwjgl-platform/2.9.0/lwjgl-platform-2.9.0-natives-windows.jar", ); std::fs::create_dir_all(archive.parent().unwrap()).unwrap(); write_native_archive(&archive, &[("lwjgl.dll", b"legacy-binary")]); let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.0", "natives": { "linux": "natives-linux", "osx": "natives-osx", "windows": "natives-windows" } })) .unwrap(); prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &[library], "1.6.4-9.11.1.1345", "x86_64", true, ) .await .unwrap(); assert_eq!( std::fs::read( natives_root.join("1.6.4-9.11.1.1345").join("lwjgl.dll") ) .unwrap(), b"legacy-binary" ); } #[tokio::test] async fn non_native_libraries_do_not_trigger_repairs() { let directory = tempfile::tempdir().unwrap(); let natives_root = directory.path().join("natives"); let libraries_dir = directory.path().join("libraries"); let caches_dir = directory.path().join("caches"); let library: Library = serde_json::from_value(serde_json::json!({ "name": "net.sf.jopt-simple:jopt-simple:4.5" })) .unwrap(); prepare_test_natives( &natives_root, &libraries_dir, &caches_dir, &[library], "1.6.4", "x86_64", true, ) .await .unwrap(); let natives_dir = natives_root.join("1.6.4"); assert!(natives_dir.exists()); assert_eq!(std::fs::read_dir(&natives_dir).unwrap().count(), 0); } }