Files
Starlight_Lancher/packages/app-lib/src/launcher/download.rs

3462 lines
119 KiB
Rust

//! 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<Self> {
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<u64>,
) -> crate::Result<ArtifactAvailability> {
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<u64>,
progress: Option<&MinecraftDownloadProgress>,
context: InstallErrorContext,
) -> crate::Result<bool> {
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<AtomicU64>,
total: Arc<AtomicU64>,
last_reported: Arc<AtomicU64>,
source: Arc<Mutex<Option<String>>>,
fallback_count: Arc<AtomicU64>,
}
impl MinecraftDownloadProgress {
async fn new(
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
total: u64,
) -> crate::Result<Self> {
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<u64>,
destination: &std::path::Path,
resource: ResourceClass,
content_validation: ContentValidation,
force: bool,
progress: Option<MinecraftDownloadProgress>,
context: InstallErrorContext,
) -> crate::Result<DownloadResult> {
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<u64>,
destination: &std::path::Path,
resource: ResourceClass,
content_validation: ContentValidation,
force: bool,
progress: Option<MinecraftDownloadProgress>,
context: InstallErrorContext,
) -> crate::Result<DownloadResult> {
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<Box<dyn Future<Output = crate::Result<()>> + 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<String> {
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<Vec<String>> {
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<String> {
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<PathBuf> {
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<PathBuf> {
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 Java artifact (regular JAR) that must be
/// downloaded and placed on the classpath. A library can have both a Java
/// artifact and native classifiers after manifest merging (LWJGL is the
/// canonical example); the two are independent and must not be treated as
/// mutually exclusive.
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;
}
library.include_in_classpath
}
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<String> {
// 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<String> {
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<String> {
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<u64>,
progress: Option<&MinecraftDownloadProgress>,
context: InstallErrorContext,
) -> crate::Result<bool> {
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<F, Fut>(
st: &State,
local: Option<&LocalRuntimeSource>,
relative_path: &Path,
destination: &Path,
expected_sha1: Option<&str>,
expected_size: Option<u64>,
progress: Option<&MinecraftDownloadProgress>,
context: InstallErrorContext,
force: bool,
download: F,
) -> crate::Result<bool>
where
F: FnOnce() -> Fut,
Fut: Future<Output = crate::Result<DownloadResult>>,
{
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<u64> {
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<u64> {
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<u64> {
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<InstallProgressReporter>,
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<bool>,
loading_bar: Option<&LoadingBarId>,
reporter: Option<&InstallProgressReporter>,
) -> crate::Result<GameVersionInfo> {
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::<crate::Error>()
.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<GameVersionInfo> {
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::<crate::Error>().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<u8>) -> 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<bool> {
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<MinecraftDownloadProgress>,
) -> 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<MinecraftDownloadProgress>,
) -> crate::Result<AssetsIndex> {
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::<crate::Error>()
.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<PathBuf>,
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<H2BatchAsset>) -> Vec<H2BatchAsset> {
let mut positions: HashMap<(PathBuf, String, u64), usize> = HashMap::new();
let mut coalesced: Vec<H2BatchAsset> = 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<MinecraftDownloadProgress>,
) -> 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<Box<dyn Future<Output = ()> + 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::<FallbackAsset, crate::Error>)
.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::<Vec<_>>();
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<MinecraftDownloadProgress>,
) -> 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<LibraryDownloadTask<'_>> = 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::<LibraryDownloadTask<'_>, 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<Vec<&'a Library>> {
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<MinecraftDownloadProgress>,
) -> crate::Result<bool> {
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<Vec<String>> {
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 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);
}
}