Files
Starlight_Launcher/packages/app-lib/src/api/loader_metadata.rs
T

2815 lines
88 KiB
Rust

use crate::State;
use crate::state::CachedLoaderManifest;
use crate::util::fetch::{FetchSemaphore, fetch, fetch_json, fetch_official};
use crate::util::io;
use daedalus::minecraft::{
Argument, ArgumentType, JavaVersion, Library, VersionType,
};
use daedalus::modded::{
DUMMY_REPLACE_STRING, LoaderProfileSource, LoaderVersion, Manifest,
PartialVersionInfo, Processor, SidedDataEntry, Version, VersionGroup,
};
use reqwest::Method;
use serde::Deserialize;
use sqlx::SqlitePool;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read};
const FABRIC_META_URL: &str = "https://meta.fabricmc.net/v2/versions/";
const QUILT_META_URL: &str = "https://meta.quiltmc.org/v3/versions/";
const LEGACY_FABRIC_META_URL: &str =
"https://meta.legacyfabric.net/v2/versions/";
const BABRIC_META_URL: &str = "https://meta.babric.dev/v2/versions/";
const BABRIC_GAME_VERSION: &str = "b1.7.3";
const BABRIC_MAVEN_URL: &str = "https://maven.glass-launcher.net/babric/";
const BABRIC_LOADER_METADATA_URL: &str =
"https://maven.glass-launcher.net/babric/babric/fabric-loader/";
const BABRIC_POLYFILL_VERSION_URL: &str =
"https://babric.github.io/manifest-polyfill/b1.7.3.json";
const BABRIC_FALLBACK_LOADER_VERSION: &str = "0.15.6-babric.2";
const OPTIFINE_META_URL: &str = "https://bmclapi2.bangbang93.com/optifine";
const OPTIFINE_VERSION_LIST_URL: &str =
"https://bmclapi2.bangbang93.com/optifine/versionList";
const LITELOADER_META_URL: &str =
"https://dl.liteloader.com/versions/versions.json";
const CLEANROOM_RELEASES_URL: &str =
"https://api.github.com/repos/CleanroomMC/Cleanroom/releases?per_page=100";
const FORGE_MAVEN_URL: &str =
"https://maven.minecraftforge.net/net/minecraftforge/forge/";
const FORGE_PROMOTIONS_URL: &str = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
const NEOFORGE_MAVEN_URL: &str =
"https://maven.neoforged.net/releases/net/neoforged/neoforge/";
const NEOFORGE_LEGACY_MAVEN_URL: &str =
"https://maven.neoforged.net/releases/net/neoforged/forge/";
const INSTALLER_URL_KEY: &str = "AXOLOTL_INSTALLER_URL";
const INSTALLER_ENTRY_KEY_PREFIX: &str = "AXOLOTL_INSTALLER_ENTRY_";
#[derive(Deserialize)]
struct MetaGameVersion {
version: String,
stable: bool,
}
#[derive(Deserialize)]
struct FabricLoaderVersion {
version: String,
stable: bool,
}
#[derive(Deserialize)]
struct QuiltLoaderVersion {
version: String,
}
#[derive(Deserialize)]
struct FabricLoaderMetadata {
loader: FabricLoaderVersion,
}
#[derive(Deserialize)]
struct QuiltLoaderMetadata {
loader: QuiltLoaderVersion,
}
#[derive(Deserialize)]
struct LegacyFabricMetadata {
game: Vec<MetaGameVersion>,
loader: Vec<FabricLoaderVersion>,
}
#[derive(Deserialize)]
struct OptiFineMetadata {
mcversion: String,
#[serde(rename = "type")]
type_: String,
patch: String,
}
#[derive(Deserialize)]
struct GithubRelease {
tag_name: String,
draft: bool,
prerelease: bool,
#[serde(default)]
assets: Vec<GithubReleaseAsset>,
}
#[derive(Deserialize)]
struct GithubReleaseAsset {
name: String,
browser_download_url: String,
}
#[derive(Deserialize)]
struct MavenMetadata {
versioning: MavenVersioning,
}
#[derive(Deserialize)]
struct MavenVersioning {
#[serde(default)]
release: String,
versions: MavenVersions,
}
#[derive(Deserialize)]
struct MavenVersions {
#[serde(rename = "version", default)]
values: Vec<String>,
}
#[derive(Deserialize)]
struct BabricLauncherMetadata {
#[serde(default)]
libraries: BabricLauncherLibraries,
#[serde(rename = "mainClass")]
main_class: BabricMainClass,
#[serde(default)]
min_java_version: Option<u32>,
}
#[derive(Deserialize, Default)]
struct BabricLauncherLibraries {
#[serde(default)]
common: Vec<Library>,
#[serde(default)]
client: Vec<Library>,
}
#[derive(Deserialize)]
struct BabricMainClass {
client: String,
}
#[derive(Deserialize, Default)]
struct ForgePromotions {
#[serde(default)]
promos: HashMap<String, String>,
}
#[derive(Deserialize)]
struct InstallerProfile {
#[serde(default)]
minecraft: Option<String>,
#[serde(default)]
data: HashMap<String, SidedDataEntry>,
#[serde(default)]
processors: Vec<Processor>,
#[serde(default)]
libraries: Vec<Library>,
#[serde(default)]
install: Option<LegacyInstallerArtifact>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyInstallerArtifact {
path: String,
file_path: String,
}
struct InstallerArtifact {
coordinate: String,
data: Vec<u8>,
}
struct ParsedInstaller {
profile: PartialVersionInfo,
artifacts: Vec<InstallerArtifact>,
}
pub(crate) async fn fetch_loader_manifest_official_first(
loader: &str,
game_version: Option<&str>,
fallback_url: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<CachedLoaderManifest> {
let official = match (loader, game_version) {
("fabric", Some(game_version)) => {
fetch_fabric_manifest_for_game(game_version, fetch_semaphore, pool)
.await
}
("quilt", Some(game_version)) => {
fetch_quilt_manifest_for_game(game_version, fetch_semaphore, pool)
.await
}
("legacy_fabric", Some(game_version)) => {
fetch_legacy_fabric_manifest(fetch_semaphore, pool)
.await
.map(|manifest| scope_manifest(manifest, game_version))
}
("babric", Some(game_version)) => {
fetch_babric_manifest_for_game(game_version, fetch_semaphore, pool)
.await
}
("optifine", Some(game_version)) => {
fetch_optifine_manifest(Some(game_version), fetch_semaphore, pool)
.await
}
("lite_loader", Some(game_version)) => {
fetch_liteloader_manifest(fetch_semaphore, pool)
.await
.map(|manifest| scope_manifest(manifest, game_version))
}
("cleanroom", Some(game_version)) => {
fetch_cleanroom_manifest(fetch_semaphore, pool)
.await
.map(|manifest| scope_manifest(manifest, game_version))
}
("forge", Some(game_version)) => {
fetch_forge_manifest(fetch_semaphore, pool)
.await
.map(|manifest| scope_manifest(manifest, game_version))
}
("neo", Some(game_version)) => {
fetch_neoforge_manifest(fetch_semaphore, pool)
.await
.map(|manifest| scope_manifest(manifest, game_version))
}
("fabric", None) => fetch_fabric_manifest(fetch_semaphore, pool).await,
("forge", None) => fetch_forge_manifest(fetch_semaphore, pool).await,
("neo", None) => fetch_neoforge_manifest(fetch_semaphore, pool).await,
("quilt", None) => fetch_quilt_manifest(fetch_semaphore, pool).await,
("legacy_fabric", None) => {
fetch_legacy_fabric_manifest(fetch_semaphore, pool).await
}
("babric", None) => fetch_babric_manifest(fetch_semaphore, pool).await,
("optifine", None) => {
fetch_optifine_manifest(None, fetch_semaphore, pool).await
}
("lite_loader", None) => {
fetch_liteloader_manifest(fetch_semaphore, pool).await
}
("cleanroom", None) => {
fetch_cleanroom_manifest(fetch_semaphore, pool).await
}
_ => {
return fetch_fallback_manifest(
loader,
game_version,
fallback_url,
fetch_semaphore,
pool,
)
.await;
}
};
let official = official.and_then(|manifest| {
if let Some(game_version) = game_version {
validate_scoped_manifest(manifest, game_version)
} else {
validate_manifest(manifest)
}
});
match official {
Ok(manifest) => Ok(CachedLoaderManifest {
loader: loader.to_string(),
manifest,
}),
Err(error) => {
tracing::warn!(
loader,
fallback_url,
error = %error,
"Official loader metadata failed; using launcher-meta fallback"
);
fetch_fallback_manifest(
loader,
game_version,
fallback_url,
fetch_semaphore,
pool,
)
.await
}
}
}
async fn fetch_fallback_manifest(
loader: &str,
game_version: Option<&str>,
fallback_url: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<CachedLoaderManifest> {
let mut manifest: Manifest = fetch_json(
Method::GET,
fallback_url,
None,
None,
None,
fetch_semaphore,
pool,
)
.await?;
if loader == "lite_loader" {
manifest
.game_versions
.retain(|version| liteloader_supports_game_version(&version.id));
}
let manifest = if let Some(game_version) = game_version {
scope_manifest(manifest, game_version)
} else {
manifest
};
Ok(CachedLoaderManifest {
loader: loader.to_string(),
manifest,
})
}
fn validate_scoped_manifest(
manifest: Manifest,
game_version: &str,
) -> crate::Result<Manifest> {
if manifest.game_versions.len() != 1
|| manifest.game_versions[0].id != game_version
{
return Err(crate::ErrorKind::OtherError(format!(
"Loader metadata response was not scoped to Minecraft {game_version}"
))
.as_error());
}
Ok(manifest)
}
fn validate_manifest(manifest: Manifest) -> crate::Result<Manifest> {
let loader_count = manifest
.game_versions
.iter()
.map(|version| version.loaders.len())
.chain(
manifest
.version_groups
.iter()
.map(|group| group.loaders.len()),
)
.sum::<usize>();
if manifest.game_versions.is_empty() || loader_count == 0 {
return Err(crate::ErrorKind::OtherError(
"Official loader metadata returned no installable versions"
.to_string(),
)
.as_error());
}
Ok(manifest)
}
async fn fetch_fabric_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let game_url = format!("{FABRIC_META_URL}game");
let loader_url = format!("{FABRIC_META_URL}loader");
let (games, loaders) = tokio::try_join!(
fetch_json::<Vec<MetaGameVersion>>(
Method::GET,
&game_url,
None,
None,
None,
fetch_semaphore,
pool,
),
fetch_json::<Vec<FabricLoaderVersion>>(
Method::GET,
&loader_url,
None,
None,
None,
fetch_semaphore,
pool,
),
)?;
Ok(meta_api_manifest(
games,
loaders
.into_iter()
.map(|loader| (loader.version, loader.stable))
.collect(),
"fabric",
FABRIC_META_URL,
0,
))
}
async fn fetch_fabric_manifest_for_game(
game_version: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let url = meta_loader_versions_url(FABRIC_META_URL, game_version);
let loaders = fetch_json::<Vec<FabricLoaderMetadata>>(
Method::GET,
&url,
None,
None,
None,
fetch_semaphore,
pool,
)
.await?
.into_iter()
.map(|metadata| (metadata.loader.version, metadata.loader.stable))
.collect();
Ok(meta_api_manifest_for_game(
game_version,
loaders,
"fabric",
FABRIC_META_URL,
0,
))
}
async fn fetch_quilt_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let game_url = format!("{QUILT_META_URL}game");
let loader_url = format!("{QUILT_META_URL}loader");
let (games, loaders) = tokio::try_join!(
fetch_json::<Vec<MetaGameVersion>>(
Method::GET,
&game_url,
None,
None,
None,
fetch_semaphore,
pool,
),
fetch_json::<Vec<QuiltLoaderVersion>>(
Method::GET,
&loader_url,
None,
None,
None,
fetch_semaphore,
pool,
),
)?;
Ok(meta_api_manifest(
games,
loaders
.into_iter()
.map(|loader| {
let stable = is_stable_version(&loader.version);
(loader.version, stable)
})
.collect(),
"quilt",
QUILT_META_URL,
1,
))
}
async fn fetch_quilt_manifest_for_game(
game_version: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let url = meta_loader_versions_url(QUILT_META_URL, game_version);
let loaders = fetch_json::<Vec<QuiltLoaderMetadata>>(
Method::GET,
&url,
None,
None,
None,
fetch_semaphore,
pool,
)
.await?
.into_iter()
.map(|metadata| {
let version = metadata.loader.version;
let stable = is_stable_version(&version);
(version, stable)
})
.collect();
Ok(meta_api_manifest_for_game(
game_version,
loaders,
"quilt",
QUILT_META_URL,
1,
))
}
async fn fetch_legacy_fabric_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let metadata: LegacyFabricMetadata = fetch_json(
Method::GET,
LEGACY_FABRIC_META_URL.trim_end_matches('/'),
None,
None,
None,
fetch_semaphore,
pool,
)
.await?;
Ok(meta_api_manifest(
metadata.game,
metadata
.loader
.into_iter()
.map(|loader| (loader.version, loader.stable))
.collect(),
"legacy_fabric",
LEGACY_FABRIC_META_URL,
0,
))
}
async fn fetch_babric_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
fetch_babric_manifest_for_game(BABRIC_GAME_VERSION, fetch_semaphore, pool)
.await
}
async fn fetch_babric_manifest_for_game(
game_version: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
if game_version != BABRIC_GAME_VERSION {
return Ok(babric_unsupported_manifest(game_version));
}
let metadata_url =
format!("{BABRIC_LOADER_METADATA_URL}maven-metadata.xml");
let metadata = fetch_maven_metadata(&metadata_url, fetch_semaphore, pool)
.await
.ok();
let versions = metadata
.map(|metadata| metadata.versioning.versions.values)
.filter(|versions| !versions.is_empty())
.unwrap_or_else(|| vec![BABRIC_FALLBACK_LOADER_VERSION.to_string()]);
Ok(babric_manifest(game_version, versions))
}
fn babric_unsupported_manifest(game_version: &str) -> Manifest {
Manifest {
game_versions: vec![Version {
id: game_version.to_string(),
stable: true,
version_group: None,
loaders: Vec::new(),
}],
version_groups: Vec::new(),
}
}
fn babric_manifest(game_version: &str, versions: Vec<String>) -> Manifest {
let mut loaders = versions
.into_iter()
.map(|version| LoaderVersion {
id: version.clone(),
url: format!(
"{BABRIC_META_URL}loader/{game_version}/{version}/profile/json"
),
stable: version == BABRIC_FALLBACK_LOADER_VERSION,
profile_source: LoaderProfileSource::Babric,
fallback_url: None,
})
.collect::<Vec<_>>();
loaders.sort_by(|left, right| compare_versions(&right.id, &left.id));
Manifest {
game_versions: vec![Version {
id: game_version.to_string(),
stable: true,
version_group: None,
loaders,
}],
version_groups: Vec::new(),
}
}
async fn fetch_optifine_manifest(
game_version: Option<&str>,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let url = optifine_metadata_url(game_version);
let entries: Vec<OptiFineMetadata> =
fetch_json(Method::GET, &url, None, None, None, fetch_semaphore, pool)
.await?;
Ok(optifine_manifest(entries, game_version))
}
fn optifine_metadata_url(game_version: Option<&str>) -> String {
game_version.map_or_else(
|| OPTIFINE_VERSION_LIST_URL.to_string(),
|game_version| format!("{OPTIFINE_META_URL}/{game_version}"),
)
}
fn optifine_manifest(
entries: Vec<OptiFineMetadata>,
game_version: Option<&str>,
) -> Manifest {
let mut grouped = HashMap::<String, Vec<LoaderVersion>>::new();
for entry in entries {
if game_version.is_some_and(|version| version != entry.mcversion) {
continue;
}
let id = format!("OptiFine_{}_{}", entry.type_, entry.patch);
let stable = !entry.patch.to_ascii_lowercase().contains("pre");
grouped.entry(entry.mcversion.clone()).or_default().push(
LoaderVersion {
id,
url: format!(
"{OPTIFINE_META_URL}/{}/{}/{}",
entry.mcversion, entry.type_, entry.patch
),
stable,
profile_source: LoaderProfileSource::Json,
fallback_url: None,
},
);
}
let manifest = grouped_manifest(grouped);
match game_version {
Some(game_version) => scope_manifest(manifest, game_version),
None => manifest,
}
}
async fn fetch_liteloader_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let metadata: serde_json::Value = fetch_json(
Method::GET,
LITELOADER_META_URL,
None,
None,
None,
fetch_semaphore,
pool,
)
.await?;
liteloader_manifest(&metadata)
}
fn liteloader_manifest(
metadata: &serde_json::Value,
) -> crate::Result<Manifest> {
let versions = metadata
.get("versions")
.and_then(serde_json::Value::as_object)
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"LiteLoader metadata has no versions object".to_string(),
)
.as_error()
})?;
let mut game_versions = Vec::new();
for (game_version, metadata) in versions {
if !liteloader_supports_game_version(game_version) {
continue;
}
let Some(entry) = liteloader_latest_entry(metadata) else {
continue;
};
let Some(version) =
entry.get("version").and_then(|value| value.as_str())
else {
continue;
};
let stable = entry
.get("stream")
.and_then(|value| value.as_str())
.is_none_or(|stream| !stream.eq_ignore_ascii_case("snapshot"));
game_versions.push(Version {
id: game_version.clone(),
stable: true,
version_group: None,
loaders: vec![LoaderVersion {
id: version.to_string(),
url: LITELOADER_META_URL.to_string(),
stable,
profile_source: LoaderProfileSource::LiteLoader,
fallback_url: Some(
"https://bmclapi2.bangbang93.com/maven/com/mumfrey/liteloader/versions.json"
.to_string(),
),
}],
});
}
game_versions.sort_by(|left, right| compare_versions(&right.id, &left.id));
Ok(Manifest {
game_versions,
version_groups: Vec::new(),
})
}
fn liteloader_supports_game_version(game_version: &str) -> bool {
!game_version.starts_with("1.5") && !game_version.starts_with("1.6")
}
fn liteloader_latest_entry(
game_metadata: &serde_json::Value,
) -> Option<&serde_json::Value> {
game_metadata
.get("artefacts")
.or_else(|| game_metadata.get("snapshots"))?
.get("com.mumfrey:liteloader")?
.get("latest")
}
async fn fetch_cleanroom_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let releases: Vec<GithubRelease> = fetch_json(
Method::GET,
CLEANROOM_RELEASES_URL,
None,
None,
None,
fetch_semaphore,
pool,
)
.await?;
Ok(cleanroom_manifest(releases))
}
fn cleanroom_manifest(releases: Vec<GithubRelease>) -> Manifest {
let mut loaders = releases
.into_iter()
.filter(|release| !release.draft)
.filter_map(|release| {
let expected_name =
format!("cleanroom-{}-installer.jar", release.tag_name);
let asset = release
.assets
.into_iter()
.find(|asset| asset.name == expected_name)?;
Some(LoaderVersion {
id: release.tag_name.clone(),
url: asset.browser_download_url,
stable: !release.prerelease
&& is_stable_version(&release.tag_name),
profile_source: LoaderProfileSource::Installer,
fallback_url: None,
})
})
.collect::<Vec<_>>();
loaders.sort_by(|left, right| compare_versions(&right.id, &left.id));
Manifest {
game_versions: vec![Version {
id: "1.12.2".to_string(),
stable: true,
version_group: None,
loaders,
}],
version_groups: Vec::new(),
}
}
fn meta_loader_versions_url(base_url: &str, game_version: &str) -> String {
format!("{base_url}loader/{game_version}")
}
fn meta_api_manifest_for_game(
game_version: &str,
mut loaders: Vec<(String, bool)>,
loader: &str,
official_base_url: &str,
fallback_format_version: usize,
) -> Manifest {
loaders.sort_by(|left, right| compare_versions(&right.0, &left.0));
Manifest {
game_versions: vec![Version {
id: game_version.to_string(),
stable: true,
version_group: None,
loaders: loaders
.into_iter()
.map(|(version, stable)| LoaderVersion {
id: version.clone(),
url: format!(
"{official_base_url}loader/{game_version}/{version}/profile/json"
),
stable,
profile_source: LoaderProfileSource::Json,
fallback_url: Some(fallback_profile_url(
loader,
fallback_format_version,
&version,
)),
})
.collect(),
}],
version_groups: Vec::new(),
}
}
fn meta_api_manifest(
games: Vec<MetaGameVersion>,
mut loaders: Vec<(String, bool)>,
loader: &str,
official_base_url: &str,
fallback_format_version: usize,
) -> Manifest {
loaders.sort_by(|left, right| compare_versions(&right.0, &left.0));
let group_id = format!("{loader}-official");
let loader_versions = loaders
.into_iter()
.map(|(version, stable)| LoaderVersion {
id: version.clone(),
url: format!(
"{official_base_url}loader/{DUMMY_REPLACE_STRING}/{version}/profile/json"
),
stable,
profile_source: LoaderProfileSource::Json,
fallback_url: Some(format!(
"{}{}{}{}{}",
env!("MODRINTH_LAUNCHER_META_URL"),
loader,
"/v",
fallback_format_version,
format!("/versions/{version}.json"),
)),
})
.collect();
Manifest {
game_versions: games
.into_iter()
.map(|game| Version {
id: game.version,
stable: game.stable,
version_group: Some(group_id.clone()),
loaders: Vec::new(),
})
.collect(),
version_groups: vec![VersionGroup {
id: group_id,
loaders: loader_versions,
}],
}
}
async fn fetch_forge_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let metadata_url = format!("{FORGE_MAVEN_URL}maven-metadata.xml");
let metadata =
fetch_forge_maven_metadata(&metadata_url, fetch_semaphore, pool)
.await?;
let promotions = fetch_json::<ForgePromotions>(
Method::GET,
FORGE_PROMOTIONS_URL,
None,
None,
None,
fetch_semaphore,
pool,
)
.await
.unwrap_or_default();
Ok(forge_manifest(metadata, promotions))
}
fn forge_manifest(
metadata: MavenMetadata,
promotions: ForgePromotions,
) -> Manifest {
let mut grouped: HashMap<String, Vec<LoaderVersion>> = HashMap::new();
for full_version in metadata.versioning.versions.values {
let Some((game_version, raw_loader_version)) =
full_version.split_once('-')
else {
continue;
};
let trailing_game_version = format!("-{game_version}");
let loader_version = raw_loader_version
.strip_suffix(&trailing_game_version)
.unwrap_or(raw_loader_version);
let recommended = promotions
.promos
.get(&format!("{game_version}-recommended"));
let stable =
recommended.is_some_and(|promoted| promoted == loader_version);
add_forge_loader_version(
&mut grouped,
game_version,
loader_version,
&full_version,
stable,
);
}
let mut manifest = grouped_manifest(grouped);
for game_version in &mut manifest.game_versions {
let Some(latest) = promotions
.promos
.get(&format!("{}-latest", game_version.id))
else {
continue;
};
let Some(index) = game_version
.loaders
.iter()
.position(|loader| loader.id == *latest)
else {
continue;
};
if index != 0 {
let latest = game_version.loaders.remove(index);
game_version.loaders.insert(0, latest);
}
}
manifest
}
fn add_forge_loader_version(
grouped: &mut HashMap<String, Vec<LoaderVersion>>,
game_version: &str,
loader_version: &str,
full_version: &str,
stable: bool,
) {
let loaders = grouped.entry(game_version.to_string()).or_default();
if let Some(existing) = loaders
.iter_mut()
.find(|version| version.id == loader_version)
{
existing.stable |= stable;
return;
}
loaders.push(LoaderVersion {
id: loader_version.to_string(),
url: format!(
"{FORGE_MAVEN_URL}{full_version}/forge-{full_version}-installer.jar"
),
stable,
profile_source: LoaderProfileSource::Installer,
fallback_url: Some(fallback_profile_url("forge", 0, loader_version)),
});
}
async fn fetch_neoforge_manifest(
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Manifest> {
let current_url = format!("{NEOFORGE_MAVEN_URL}maven-metadata.xml");
let legacy_url = format!("{NEOFORGE_LEGACY_MAVEN_URL}maven-metadata.xml");
let (current, legacy) = tokio::try_join!(
fetch_maven_metadata(&current_url, fetch_semaphore, pool),
fetch_maven_metadata(&legacy_url, fetch_semaphore, pool),
)?;
Ok(neoforge_manifest(current, legacy))
}
fn neoforge_manifest(
current: MavenMetadata,
legacy: MavenMetadata,
) -> Manifest {
let current_release = current.versioning.release;
let legacy_release = legacy.versioning.release;
let mut grouped: HashMap<String, Vec<LoaderVersion>> = HashMap::new();
for version in current.versioning.versions.values {
let Some(game_version) = neoforge_game_version(&version) else {
continue;
};
grouped
.entry(game_version)
.or_default()
.push(LoaderVersion {
id: version.clone(),
url: format!(
"{NEOFORGE_MAVEN_URL}{version}/neoforge-{version}-installer.jar"
),
stable: version == current_release
|| is_stable_version(&version),
profile_source: LoaderProfileSource::Installer,
fallback_url: Some(fallback_profile_url(
"neo", 0, &version,
)),
});
}
for full_version in legacy.versioning.versions.values {
let Some((game_version, loader_version)) = full_version.split_once('-')
else {
continue;
};
grouped
.entry(game_version.to_string())
.or_default()
.push(LoaderVersion {
id: loader_version.to_string(),
url: format!(
"{NEOFORGE_LEGACY_MAVEN_URL}{full_version}/forge-{full_version}-installer.jar"
),
stable: full_version == legacy_release
|| is_stable_version(loader_version),
profile_source: LoaderProfileSource::Installer,
fallback_url: Some(fallback_profile_url(
"neo",
0,
loader_version,
)),
});
}
grouped_manifest(grouped)
}
fn grouped_manifest(
mut grouped: HashMap<String, Vec<LoaderVersion>>,
) -> Manifest {
let mut game_versions = grouped
.drain()
.map(|(game_version, mut loaders)| {
loaders
.sort_by(|left, right| compare_versions(&right.id, &left.id));
Version {
id: game_version,
stable: true,
version_group: None,
loaders,
}
})
.collect::<Vec<_>>();
game_versions.sort_by(|left, right| compare_versions(&right.id, &left.id));
Manifest {
game_versions,
version_groups: Vec::new(),
}
}
fn scope_manifest(manifest: Manifest, game_version: &str) -> Manifest {
let has_explicit_game_version = manifest
.game_versions
.iter()
.any(|version| version.id == game_version);
let scoped = manifest.game_versions.iter().find(|version| {
version.id.replace(DUMMY_REPLACE_STRING, game_version) == game_version
});
let (stable, loaders) = scoped.map_or((true, Vec::new()), |version| {
let loaders = if version.id == DUMMY_REPLACE_STRING
&& !has_explicit_game_version
{
Vec::new()
} else if let Some(group_id) = &version.version_group {
manifest
.version_groups
.iter()
.find(|group| group.id == *group_id)
.map(|group| group.loaders.clone())
.unwrap_or_default()
} else {
version.loaders.clone()
};
(version.stable, loaders)
});
Manifest {
game_versions: vec![Version {
id: game_version.to_string(),
stable,
version_group: None,
loaders,
}],
version_groups: Vec::new(),
}
}
async fn fetch_maven_metadata(
url: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<MavenMetadata> {
let bytes = fetch(url, None, None, fetch_semaphore, pool).await?;
let xml = std::str::from_utf8(&bytes).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Maven metadata at {url} was not UTF-8: {error}"
))
.as_error()
})?;
quick_xml::de::from_str(xml).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Failed to parse Maven metadata at {url}: {error}"
))
.as_error()
})
}
async fn fetch_forge_maven_metadata(
url: &str,
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<MavenMetadata> {
let bytes = fetch_official(url, None, None, fetch_semaphore, pool).await?;
let xml = std::str::from_utf8(&bytes).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Forge Maven metadata at {url} was not UTF-8: {error}"
))
.as_error()
})?;
quick_xml::de::from_str(xml).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Failed to parse Forge Maven metadata at {url}: {error}"
))
.as_error()
})
}
fn fallback_profile_url(
loader: &str,
format_version: usize,
version: &str,
) -> String {
format!(
"{}{loader}/v{format_version}/versions/{version}.json",
env!("MODRINTH_LAUNCHER_META_URL")
)
}
fn neoforge_game_version(version: &str) -> Option<String> {
if let Some(snapshot) = version.strip_prefix("0.") {
let snapshot = snapshot.rsplit_once('.')?.0;
return Some(format!("1.0.{snapshot}"));
}
let components = version.split('.').collect::<Vec<_>>();
let major = components.first()?.parse::<u32>().ok()?;
let minor = components.get(1)?.parse::<u32>().ok()?;
if major >= 26 {
let patch = components.get(2)?.parse::<u32>().ok()?;
if patch == 0 {
Some(format!("{major}.{minor}"))
} else {
Some(format!("{major}.{minor}.{patch}"))
}
} else if minor == 0 {
Some(format!("1.{major}"))
} else {
Some(format!("1.{major}.{minor}"))
}
}
fn is_stable_version(version: &str) -> bool {
let version = version.to_ascii_lowercase();
!["alpha", "beta", "rc", "snapshot", "pre"]
.iter()
.any(|marker| version.contains(marker))
}
fn compare_versions(left: &str, right: &str) -> Ordering {
let mut left = left.as_bytes().iter().peekable();
let mut right = right.as_bytes().iter().peekable();
loop {
match (left.peek(), right.peek()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Greater,
(Some(_), None) => return Ordering::Less,
(Some(left_byte), Some(right_byte)) => {
let left_is_digit = left_byte.is_ascii_digit();
let right_is_digit = right_byte.is_ascii_digit();
if left_is_digit != right_is_digit {
return left_byte.cmp(right_byte);
}
let mut left_part = Vec::new();
let mut right_part = Vec::new();
while left
.peek()
.is_some_and(|byte| byte.is_ascii_digit() == left_is_digit)
{
left_part.push(**left.peek().unwrap());
left.next();
}
while right
.peek()
.is_some_and(|byte| byte.is_ascii_digit() == right_is_digit)
{
right_part.push(**right.peek().unwrap());
right.next();
}
let ordering = if left_is_digit {
left_part
.len()
.cmp(&right_part.len())
.then_with(|| left_part.cmp(&right_part))
} else {
left_part.cmp(&right_part)
};
if ordering != Ordering::Equal {
return ordering;
}
}
}
}
}
pub(crate) async fn resolve_loader_profile(
state: &State,
game_version: &str,
loader_version: &LoaderVersion,
) -> crate::Result<PartialVersionInfo> {
let primary_url = loader_version
.url
.replace(DUMMY_REPLACE_STRING, game_version);
let primary = match loader_version.profile_source {
LoaderProfileSource::Json => {
fetch_json(
Method::GET,
&primary_url,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await
}
LoaderProfileSource::Installer => {
resolve_installer_profile(state, &primary_url).await
}
LoaderProfileSource::LiteLoader => {
resolve_liteloader_profile(
state,
game_version,
&loader_version.id,
&primary_url,
)
.await
}
LoaderProfileSource::Babric => {
resolve_babric_profile(
state,
game_version,
&loader_version.id,
&primary_url,
)
.await
}
};
match primary {
Ok(profile) => Ok(profile),
Err(primary_error) => {
let Some(fallback_url) = &loader_version.fallback_url else {
return Err(primary_error);
};
let fallback_url =
fallback_url.replace(DUMMY_REPLACE_STRING, game_version);
tracing::warn!(
loader_version = loader_version.id,
primary_url,
fallback_url,
error = %primary_error,
"Official loader profile failed; using launcher-meta fallback"
);
match loader_version.profile_source {
LoaderProfileSource::LiteLoader => {
resolve_liteloader_profile(
state,
game_version,
&loader_version.id,
&fallback_url,
)
.await
}
LoaderProfileSource::Babric => {
resolve_babric_profile(
state,
game_version,
&loader_version.id,
&fallback_url,
)
.await
}
_ => {
fetch_json(
Method::GET,
&fallback_url,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await
}
}
}
}
}
async fn resolve_babric_profile(
state: &State,
game_version: &str,
loader_version: &str,
profile_url: &str,
) -> crate::Result<PartialVersionInfo> {
if game_version != BABRIC_GAME_VERSION {
return Err(crate::ErrorKind::InputError(format!(
"Babric does not support Minecraft {game_version}"
))
.into());
}
match fetch_json::<PartialVersionInfo>(
Method::GET,
profile_url,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await
{
Ok(profile) => Ok(profile),
Err(error) => {
tracing::warn!(
game_version,
loader_version,
error = %error,
"Babric profile endpoint is unavailable; synthesizing the profile from Glass Maven"
);
build_babric_profile(state, game_version, loader_version).await
}
}
}
async fn build_babric_profile(
state: &State,
game_version: &str,
loader_version: &str,
) -> crate::Result<PartialVersionInfo> {
let loader_metadata_url = format!(
"{BABRIC_LOADER_METADATA_URL}{loader_version}/fabric-loader-{loader_version}.json"
);
let polyfill = fetch_json::<serde_json::Value>(
Method::GET,
BABRIC_POLYFILL_VERSION_URL,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await?;
let launcher_metadata = fetch_json::<BabricLauncherMetadata>(
Method::GET,
&loader_metadata_url,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await?;
let mut libraries = launcher_metadata.libraries.common;
libraries.extend(launcher_metadata.libraries.client);
libraries.push(babric_library(
"babric:intermediary-upstream:b1.7.3",
BABRIC_MAVEN_URL,
));
libraries.push(babric_library(
&format!("babric:fabric-loader:{loader_version}"),
BABRIC_MAVEN_URL,
));
libraries.extend(babric_logging_libraries());
libraries.extend(babric_lwjgl_libraries(&polyfill)?);
let minecraft_arguments = polyfill
.get("minecraftArguments")
.and_then(serde_json::Value::as_str)
.unwrap_or("${auth_player_name} ${auth_session} --gameDir ${game_directory} --assetsDir ${game_assets}");
let game_arguments = minecraft_arguments
.split_whitespace()
.map(|argument| Argument::Normal(argument.to_string()))
.collect::<Vec<_>>();
let jvm_arguments = vec![
Argument::Normal(
"-DFabricMcEmu= net.minecraft.client.main.Main ".to_string(),
),
Argument::Normal("-cp".to_string()),
Argument::Normal("${classpath}".to_string()),
Argument::Normal(
"-Djava.library.path=${natives_directory}".to_string(),
),
];
let now = chrono::Utc::now();
Ok(PartialVersionInfo {
id: format!("fabric-loader-{loader_version}-{game_version}"),
inherits_from: game_version.to_string(),
release_time: now,
time: now,
main_class: Some(launcher_metadata.main_class.client),
minecraft_arguments: None,
arguments: Some(HashMap::from([
(ArgumentType::Game, game_arguments),
(ArgumentType::Jvm, jvm_arguments),
])),
libraries,
java_version: Some(JavaVersion {
component: "jre-legacy".to_string(),
major_version: launcher_metadata.min_java_version.unwrap_or(8),
}),
type_: VersionType::Release,
data: None,
processors: None,
})
}
fn babric_library(name: &str, url: &str) -> Library {
Library {
downloads: None,
extract: None,
name: name.to_string(),
url: Some(url.to_string()),
natives: None,
rules: None,
checksums: None,
include_in_classpath: true,
downloadable: true,
}
}
fn babric_logging_libraries() -> Vec<Library> {
[
("babric:log4j-config:1.0.0", BABRIC_MAVEN_URL),
(
"net.minecrell:terminalconsoleappender:1.2.0",
"https://repo1.maven.org/maven2/",
),
(
"org.slf4j:slf4j-api:1.8.0-beta4",
"https://libraries.minecraft.net/",
),
(
"org.apache.logging.log4j:log4j-slf4j18-impl:2.16.0",
"https://libraries.minecraft.net/",
),
(
"org.apache.logging.log4j:log4j-api:2.16.0",
"https://libraries.minecraft.net/",
),
(
"org.apache.logging.log4j:log4j-core:2.16.0",
"https://libraries.minecraft.net/",
),
(
"com.google.code.gson:gson:2.8.9",
"https://libraries.minecraft.net/",
),
(
"com.google.guava:guava:31.0.1-jre",
"https://libraries.minecraft.net/",
),
(
"com.google.guava:failureaccess:1.0.1",
"https://libraries.minecraft.net/",
),
(
"org.apache.commons:commons-lang3:3.12.0",
"https://libraries.minecraft.net/",
),
(
"commons-io:commons-io:2.11.0",
"https://libraries.minecraft.net/",
),
(
"commons-codec:commons-codec:1.15",
"https://libraries.minecraft.net/",
),
]
.into_iter()
.map(|(name, url)| babric_library(name, url))
.collect()
}
fn babric_lwjgl_libraries(
polyfill: &serde_json::Value,
) -> crate::Result<Vec<Library>> {
let Some(entries) = polyfill
.get("libraries")
.and_then(serde_json::Value::as_array)
else {
return Ok(Vec::new());
};
entries
.iter()
.filter(|library| {
let Some(name) =
library.get("name").and_then(serde_json::Value::as_str)
else {
return false;
};
name.contains("lwjgl") && name.contains("-babric.")
})
.cloned()
.map(serde_json::from_value)
.collect::<Result<Vec<_>, _>>()
.map_err(Into::into)
}
async fn resolve_liteloader_profile(
state: &State,
game_version: &str,
loader_version: &str,
url: &str,
) -> crate::Result<PartialVersionInfo> {
let metadata: serde_json::Value = fetch_json(
Method::GET,
url,
None,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await?;
build_liteloader_profile(&metadata, game_version, loader_version)
}
fn build_liteloader_profile(
metadata: &serde_json::Value,
game_version: &str,
loader_version: &str,
) -> crate::Result<PartialVersionInfo> {
let game_metadata = metadata
.get("versions")
.and_then(|versions| versions.get(game_version))
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"LiteLoader does not support Minecraft {game_version}"
))
.as_error()
})?;
let entry = liteloader_latest_entry(game_metadata).ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"LiteLoader metadata for {game_version} has no latest build"
))
.as_error()
})?;
let actual_version = entry
.get("version")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"LiteLoader metadata has no version".to_string(),
)
.as_error()
})?;
if actual_version != loader_version {
return Err(crate::ErrorKind::InputError(format!(
"LiteLoader version {loader_version} is not available for Minecraft {game_version}"
))
.into());
}
let tweak_class = entry
.get("tweakClass")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"LiteLoader metadata has no tweakClass".to_string(),
)
.as_error()
})?;
let mut libraries = entry
.get("libraries")
.cloned()
.map(serde_json::from_value::<Vec<Library>>)
.transpose()?
.unwrap_or_default();
libraries.push(Library {
downloads: None,
extract: None,
name: format!("com.mumfrey:liteloader:{actual_version}"),
url: Some("https://dl.liteloader.com/versions/".to_string()),
natives: None,
rules: None,
checksums: None,
include_in_classpath: true,
downloadable: true,
});
let timestamp = entry
.get("timestamp")
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_str()?.parse::<i64>().ok())
})
.unwrap_or(0);
let time = chrono::DateTime::from_timestamp(timestamp, 0)
.unwrap_or(chrono::DateTime::UNIX_EPOCH);
Ok(PartialVersionInfo {
id: format!("{game_version}-LiteLoader"),
inherits_from: game_version.to_string(),
release_time: time,
time,
main_class: Some("net.minecraft.launchwrapper.Launch".to_string()),
minecraft_arguments: None,
arguments: Some(HashMap::from([(
daedalus::minecraft::ArgumentType::Game,
vec![
daedalus::minecraft::Argument::Normal(
"--tweakClass".to_string(),
),
daedalus::minecraft::Argument::Normal(tweak_class.to_string()),
],
)])),
libraries,
java_version: Some(JavaVersion {
component: "jre-legacy".to_string(),
major_version: 8,
}),
type_: daedalus::minecraft::VersionType::Release,
data: None,
processors: None,
})
}
async fn resolve_installer_profile(
state: &State,
installer_url: &str,
) -> crate::Result<PartialVersionInfo> {
let bytes =
fetch(installer_url, None, None, &state.api_semaphore, &state.pool)
.await?;
let installer_url = installer_url.to_string();
let parsed = tokio::task::spawn_blocking(move || {
parse_installer(bytes.to_vec(), &installer_url)
})
.await??;
persist_installer_artifacts(state, &parsed.artifacts).await?;
Ok(parsed.profile)
}
pub(crate) async fn ensure_installer_artifacts(
state: &State,
version: &daedalus::minecraft::VersionInfo,
) -> crate::Result<()> {
let Some(data) = &version.data else {
return Ok(());
};
let Some(installer_url) = data.get(INSTALLER_URL_KEY) else {
return Ok(());
};
let entries = data
.iter()
.filter(|(key, _)| key.starts_with(INSTALLER_ENTRY_KEY_PREFIX))
.filter_map(|(_, entry)| entry.client.split_once('|'))
.map(|(coordinate, archive_path)| {
(coordinate.to_string(), archive_path.to_string())
})
.collect::<Vec<_>>();
let mut missing = Vec::new();
for (coordinate, archive_path) in entries {
let relative = daedalus::get_path_from_artifact(&coordinate)?;
if !state.directories.libraries_dir().join(relative).exists() {
missing.push((coordinate, archive_path));
}
}
if missing.is_empty() {
return Ok(());
}
let bytes = fetch(
&installer_url.client,
None,
None,
&state.api_semaphore,
&state.pool,
)
.await?;
let artifacts = tokio::task::spawn_blocking(move || {
extract_installer_artifacts(bytes.to_vec(), &missing)
})
.await??;
persist_installer_artifacts(state, &artifacts).await
}
fn parse_installer(
bytes: Vec<u8>,
installer_url: &str,
) -> crate::Result<ParsedInstaller> {
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Failed to open loader installer: {error}"
))
.as_error()
})?;
let profile_json = read_zip_entry(&mut archive, "install_profile.json")?;
let profile_value: serde_json::Value =
serde_json::from_slice(&profile_json)?;
let version_json = if let Ok(version_json) =
read_zip_entry(&mut archive, "version.json")
{
version_json
} else if let Some(path) =
profile_value.get("json").and_then(|value| value.as_str())
{
read_zip_entry(&mut archive, path.trim_start_matches('/'))?
} else if let Some(version_info) = profile_value.get("versionInfo") {
serde_json::to_vec(version_info)?
} else {
return Err(crate::ErrorKind::OtherError(
"Loader installer contains no version.json, profile json, or versionInfo"
.to_string(),
)
.as_error());
};
let mut installer: InstallerProfile =
serde_json::from_value(profile_value)?;
let mut version_value: serde_json::Value =
serde_json::from_slice(&version_json)?;
if version_value.get("inheritsFrom").is_none() {
let minecraft = installer.minecraft.clone().ok_or_else(|| {
crate::ErrorKind::OtherError(
"Loader installer version has no inheritsFrom or minecraft version"
.to_string(),
)
.as_error()
})?;
version_value
.as_object_mut()
.ok_or_else(|| {
crate::ErrorKind::OtherError(
"Loader installer version is not a JSON object".to_string(),
)
.as_error()
})?
.insert(
"inheritsFrom".to_string(),
serde_json::Value::String(minecraft),
);
}
let mut partial: PartialVersionInfo =
serde_json::from_value(version_value)?;
for library in &mut installer.libraries {
library.include_in_classpath = false;
}
let has_processors = !installer.processors.is_empty();
for library in &mut partial.libraries {
let library_name = library.name.clone();
if let Some(artifact) = library
.downloads
.as_mut()
.and_then(|downloads| downloads.artifact.as_mut())
&& artifact.url.is_empty()
{
if !has_processors
&& let Some(full_version) =
library_name.strip_prefix("net.minecraftforge:forge:")
{
artifact.url = format!(
"{FORGE_MAVEN_URL}{full_version}/forge-{full_version}-universal.jar"
);
library.downloadable = true;
} else {
library.downloadable = false;
}
}
}
let existing = partial
.libraries
.iter()
.map(|library| library.name.clone())
.collect::<HashSet<_>>();
partial.libraries.extend(
installer
.libraries
.into_iter()
.filter(|library| !existing.contains(&library.name)),
);
partial.processors = Some(installer.processors);
let requests = installer
.data
.iter()
.flat_map(|(key, entry)| {
[("client", &entry.client), ("server", &entry.server)]
.into_iter()
.filter_map(move |(side, value)| {
value
.strip_prefix('/')
.map(|path| (key.clone(), side, path.to_string()))
})
})
.collect::<Vec<_>>();
let mut artifacts = Vec::new();
for (index, (key, side, archive_path)) in requests.into_iter().enumerate() {
let artifact_data = read_zip_entry(&mut archive, &archive_path)?;
let extension = std::path::Path::new(&archive_path)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("bin");
let coordinate = format!(
"com.axolotl.loader-installer:embedded:{}:{}-{}@{}",
sanitize_coordinate_part(&partial.id),
sanitize_coordinate_part(&key),
side,
sanitize_coordinate_part(extension),
);
let entry = installer.data.get_mut(&key).unwrap();
let replacement = format!("[{coordinate}]");
if side == "client" {
entry.client = replacement;
} else {
entry.server = replacement;
}
installer.data.insert(
format!("{INSTALLER_ENTRY_KEY_PREFIX}{index}"),
SidedDataEntry {
client: format!("{coordinate}|{archive_path}"),
server: format!("{coordinate}|{archive_path}"),
},
);
partial.libraries.push(local_library(coordinate.clone()));
artifacts.push(InstallerArtifact {
coordinate,
data: artifact_data,
});
}
if let Some(install) = installer.install {
let archive_path = install.file_path.trim_start_matches('/');
let artifact_data = read_zip_entry(&mut archive, archive_path)?;
let index = installer.data.len();
installer.data.insert(
format!("{INSTALLER_ENTRY_KEY_PREFIX}{index}"),
SidedDataEntry {
client: format!("{}|{archive_path}", install.path),
server: format!("{}|{archive_path}", install.path),
},
);
if !partial
.libraries
.iter()
.any(|library| library.name == install.path)
{
partial.libraries.push(local_library(install.path.clone()));
}
artifacts.push(InstallerArtifact {
coordinate: install.path,
data: artifact_data,
});
}
let embedded_maven_entries = archive
.file_names()
.filter_map(|path| {
coordinate_from_maven_path(path)
.map(|coordinate| (coordinate, path.to_string()))
})
.collect::<Vec<_>>();
for (coordinate, archive_path) in embedded_maven_entries {
if artifacts
.iter()
.any(|artifact| artifact.coordinate == coordinate)
{
continue;
}
let artifact_data = read_zip_entry(&mut archive, &archive_path)?;
let index = installer.data.len();
installer.data.insert(
format!("{INSTALLER_ENTRY_KEY_PREFIX}{index}"),
SidedDataEntry {
client: format!("{coordinate}|{archive_path}"),
server: format!("{coordinate}|{archive_path}"),
},
);
artifacts.push(InstallerArtifact {
coordinate,
data: artifact_data,
});
}
installer.data.insert(
INSTALLER_URL_KEY.to_string(),
SidedDataEntry {
client: installer_url.to_string(),
server: installer_url.to_string(),
},
);
partial.data = Some(installer.data);
Ok(ParsedInstaller {
profile: partial,
artifacts,
})
}
fn coordinate_from_maven_path(path: &str) -> Option<String> {
let path = path.strip_prefix("maven/")?;
let components = path.split('/').collect::<Vec<_>>();
if components.len() < 4 {
return None;
}
let file_name = components.last()?;
let version = components.get(components.len() - 2)?;
let artifact = components.get(components.len() - 3)?;
let group = components[..components.len() - 3].join(".");
let prefix = format!("{artifact}-{version}");
let suffix = file_name.strip_prefix(&prefix)?;
let (classifier, extension) = suffix.rsplit_once('.')?;
let classifier = classifier
.strip_prefix('-')
.filter(|value| !value.is_empty());
Some(match classifier {
Some(classifier) => {
format!("{group}:{artifact}:{version}:{classifier}@{extension}")
}
None if extension == "jar" => format!("{group}:{artifact}:{version}"),
None => format!("{group}:{artifact}:{version}@{extension}"),
})
}
fn extract_installer_artifacts(
bytes: Vec<u8>,
entries: &[(String, String)],
) -> crate::Result<Vec<InstallerArtifact>> {
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Failed to open cached loader installer: {error}"
))
.as_error()
})?;
entries
.iter()
.map(|(coordinate, archive_path)| {
Ok(InstallerArtifact {
coordinate: coordinate.clone(),
data: read_zip_entry(&mut archive, archive_path)?,
})
})
.collect()
}
fn read_zip_entry<R: Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &str,
) -> crate::Result<Vec<u8>> {
let mut entry = archive.by_name(path).map_err(|error| {
crate::ErrorKind::OtherError(format!(
"Loader installer is missing {path}: {error}"
))
.as_error()
})?;
let mut data = Vec::new();
entry.read_to_end(&mut data)?;
Ok(data)
}
async fn persist_installer_artifacts(
state: &State,
artifacts: &[InstallerArtifact],
) -> crate::Result<()> {
for artifact in artifacts {
let relative = daedalus::get_path_from_artifact(&artifact.coordinate)?;
let path = state.directories.libraries_dir().join(relative);
if let Some(parent) = path.parent() {
io::create_dir_all(parent).await?;
}
io::write(path, &artifact.data).await?;
}
Ok(())
}
fn local_library(name: String) -> Library {
Library {
downloads: None,
extract: None,
name,
url: None,
natives: None,
rules: None,
checksums: None,
include_in_classpath: false,
downloadable: false,
}
}
fn sanitize_coordinate_part(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric()
|| matches!(character, '.' | '-' | '_')
{
character
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
fn maven_metadata(release: &str, versions: &[&str]) -> MavenMetadata {
MavenMetadata {
versioning: MavenVersioning {
release: release.to_string(),
versions: MavenVersions {
values: versions
.iter()
.map(|version| version.to_string())
.collect(),
},
},
}
}
fn forge_loaders<'a>(
manifest: &'a Manifest,
game_version: &str,
) -> &'a [LoaderVersion] {
&manifest
.game_versions
.iter()
.find(|version| version.id == game_version)
.unwrap_or_else(|| {
panic!("missing Forge support for {game_version}")
})
.loaders
}
#[test]
fn forge_metadata_groups_versions_and_uses_installer_profiles() {
let manifest = forge_manifest(
maven_metadata(
"1.21.5-55.1.13",
&["1.21.5-55.1.9", "1.21.5-55.1.13"],
),
ForgePromotions {
promos: HashMap::from([
("1.21.5-latest".to_string(), "55.1.13".to_string()),
("1.21.5-recommended".to_string(), "55.1.9".to_string()),
]),
},
);
let loaders = &manifest.game_versions[0].loaders;
assert_eq!(loaders[0].id, "55.1.13");
assert_eq!(loaders[0].profile_source, LoaderProfileSource::Installer);
assert!(!loaders[0].stable);
assert!(loaders[1].stable);
assert!(
loaders[0].url.ends_with(
"/1.21.5-55.1.13/forge-1.21.5-55.1.13-installer.jar"
)
);
}
#[test]
fn forge_maven_metadata_parses_modern_version_coordinates() {
let metadata: MavenMetadata = quick_xml::de::from_str(
r#"<metadata>
<versioning>
<release>26.2-65.1.0</release>
<versions>
<version>1.18.2-40.3.12</version>
<version>1.19-41.1.0</version>
<version>1.20.1-47.4.22</version>
<version>1.21.1-52.1.16</version>
<version>26.1-62.0.9</version>
<version>26.2-65.1.0</version>
</versions>
</versioning>
</metadata>"#,
)
.unwrap();
let manifest = forge_manifest(metadata, ForgePromotions::default());
for (game_version, loader_prefix) in [
("1.18.2", "40."),
("1.19", "41."),
("1.20.1", "47."),
("1.21.1", "52."),
("26.1", "62."),
("26.2", "65."),
] {
assert!(
forge_loaders(&manifest, game_version)
.iter()
.any(|loader| loader.id.starts_with(loader_prefix)),
"missing Forge {loader_prefix} for Minecraft {game_version}"
);
}
}
#[test]
fn forge_preserves_complete_maven_catalog_and_maps_promotions() {
let metadata: MavenMetadata = quick_xml::de::from_str(
r#"<metadata>
<versioning>
<release>26.2-65.1.1</release>
<versions>
<version>26.2-65.1.1</version>
<version>26.2-65.1.0</version>
<version>26.2-65.0.9</version>
<version>26.2-65.0.8</version>
<version>26.2-65.0.1</version>
<version>26.2-65.0.0</version>
<version>26.1-62.0.9</version>
</versions>
</versioning>
</metadata>"#,
)
.unwrap();
let manifest = forge_manifest(
metadata,
ForgePromotions {
promos: HashMap::from([
("26.2-latest".to_string(), "65.1.1".to_string()),
("26.2-recommended".to_string(), "65.1.0".to_string()),
]),
},
);
let loaders = forge_loaders(&manifest, "26.2");
assert_eq!(
loaders
.iter()
.map(|loader| loader.id.as_str())
.collect::<Vec<_>>(),
["65.1.1", "65.1.0", "65.0.9", "65.0.8", "65.0.1", "65.0.0"]
);
assert!(!loaders[0].stable);
assert!(loaders[1].stable);
assert!(loaders[2..].iter().all(|loader| !loader.stable));
assert!(
forge_loaders(&manifest, "26.1")
.iter()
.all(|loader| !loader.id.starts_with("65."))
);
}
#[test]
fn forge_promotions_do_not_create_catalog_entries() {
let manifest = forge_manifest(
maven_metadata("26.2-65.1.0", &["26.2-65.1.0"]),
ForgePromotions {
promos: HashMap::from([(
"26.2-latest".to_string(),
"65.1.1".to_string(),
)]),
},
);
assert_eq!(forge_loaders(&manifest, "26.2")[0].id, "65.1.0");
}
#[test]
fn forge_scoped_metadata_keeps_empty_and_supported_versions_isolated() {
let full = forge_manifest(
maven_metadata(
"1.20.1-47.4.22",
&["1.20.1-47.4.21", "1.20.1-47.4.22", "1.21.5-55.1.13"],
),
ForgePromotions::default(),
);
let forge_262 = scope_manifest(full.clone(), "26.2");
let forge_1201 = scope_manifest(full.clone(), "1.20.1");
let forge_262_again = scope_manifest(full, "26.2");
assert!(forge_262.game_versions[0].loaders.is_empty());
assert_eq!(
forge_1201.game_versions[0]
.loaders
.iter()
.map(|loader| loader.id.as_str())
.collect::<Vec<_>>(),
["47.4.22", "47.4.21"]
);
assert!(forge_262_again.game_versions[0].loaders.is_empty());
assert!(validate_scoped_manifest(forge_262, "26.2").is_ok());
}
#[test]
fn forge_scoped_metadata_preserves_complete_catalog_across_switches() {
let full = forge_manifest(
maven_metadata(
"26.2-65.1.1",
&[
"26.2-65.1.1",
"26.2-65.1.0",
"26.2-65.0.9",
"1.20.1-47.4.22",
"1.20.1-47.4.21",
],
),
ForgePromotions::default(),
);
let forge_262 = scope_manifest(full.clone(), "26.2");
let forge_1201 = scope_manifest(full.clone(), "1.20.1");
let forge_262_again = scope_manifest(full, "26.2");
let ids = |manifest: &Manifest| {
manifest.game_versions[0]
.loaders
.iter()
.map(|loader| loader.id.clone())
.collect::<Vec<_>>()
};
assert_eq!(ids(&forge_262), ["65.1.1", "65.1.0", "65.0.9"]);
assert_eq!(ids(&forge_1201), ["47.4.22", "47.4.21"]);
assert_eq!(ids(&forge_262_again), ids(&forge_262));
}
#[test]
fn meta_api_manifest_uses_official_profiles_and_fallbacks() {
let manifest = meta_api_manifest(
vec![MetaGameVersion {
version: "1.21.1".to_string(),
stable: true,
}],
vec![("0.16.9".to_string(), false), ("0.16.14".to_string(), true)],
"fabric",
FABRIC_META_URL,
0,
);
let version = &manifest.game_versions[0];
let loaders = &manifest.version_groups[0].loaders;
assert_eq!(version.version_group.as_deref(), Some("fabric-official"));
assert_eq!(loaders[0].id, "0.16.14");
assert!(
loaders[0]
.url
.contains("${modrinth.gameVersion}/0.16.14/profile/json")
);
assert!(
loaders[0]
.fallback_url
.as_ref()
.unwrap()
.ends_with("fabric/v0/versions/0.16.14.json")
);
}
#[test]
fn scoped_meta_manifest_binds_profile_and_versions_to_requested_game() {
assert_eq!(
meta_loader_versions_url(FABRIC_META_URL, "1.20.1"),
"https://meta.fabricmc.net/v2/versions/loader/1.20.1"
);
assert_eq!(
meta_loader_versions_url(QUILT_META_URL, "26.2"),
"https://meta.quiltmc.org/v3/versions/loader/26.2"
);
let manifest = meta_api_manifest_for_game(
"1.20.1",
vec![("0.16.9".to_string(), false), ("0.16.14".to_string(), true)],
"fabric",
FABRIC_META_URL,
0,
);
let game = &manifest.game_versions[0];
assert_eq!(game.id, "1.20.1");
assert_eq!(game.loaders[0].id, "0.16.14");
assert!(
game.loaders[0]
.url
.contains("loader/1.20.1/0.16.14/profile/json")
);
assert!(!game.loaders[0].url.contains(DUMMY_REPLACE_STRING));
}
#[test]
fn neoforge_scoped_metadata_does_not_mix_build_series() {
let full = neoforge_manifest(
maven_metadata("21.2.10", &["21.1.200", "21.1.201", "21.2.10"]),
maven_metadata("", &[]),
);
let mc_1211 = scope_manifest(full.clone(), "1.21.1");
let mc_1212 = scope_manifest(full.clone(), "1.21.2");
let mc_1211_again = scope_manifest(full, "1.21.1");
assert!(
mc_1211.game_versions[0]
.loaders
.iter()
.all(|loader| loader.id.starts_with("21.1."))
);
assert!(
mc_1212.game_versions[0]
.loaders
.iter()
.all(|loader| loader.id.starts_with("21.2."))
);
assert_eq!(
mc_1211.game_versions[0]
.loaders
.iter()
.map(|loader| loader.id.as_str())
.collect::<Vec<_>>(),
mc_1211_again.game_versions[0]
.loaders
.iter()
.map(|loader| loader.id.as_str())
.collect::<Vec<_>>()
);
}
#[test]
fn neoforge_versions_map_to_minecraft_versions() {
assert_eq!(neoforge_game_version("20.2.93"), Some("1.20.2".into()));
assert_eq!(neoforge_game_version("21.0.167"), Some("1.21".into()));
assert_eq!(neoforge_game_version("21.11.45"), Some("1.21.11".into()));
assert_eq!(
neoforge_game_version("26.1.0.19-beta"),
Some("26.1".into())
);
assert_eq!(neoforge_game_version("26.1.2.95"), Some("26.1.2".into()));
assert_eq!(
neoforge_game_version("0.25w14craftmine.5-beta"),
Some("1.0.25w14craftmine".into())
);
}
#[test]
fn installer_parser_merges_profile_and_embedded_artifacts() {
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
archive.start_file("version.json", options).unwrap();
archive
.write_all(
br#"{
"id":"1.21.5-forge-55.1.13",
"inheritsFrom":"1.21.5",
"releaseTime":"2026-08-15T20:37:45+00:00",
"time":"2026-08-15T20:37:45+00:00",
"mainClass":"example.Main",
"libraries":[{
"name":"example:generated:1.0",
"downloads":{"artifact":{"path":"example/generated/1.0/generated-1.0.jar","sha1":"abc","size":1,"url":""}}
}],
"type":"release"
}"#,
)
.unwrap();
archive.start_file("install_profile.json", options).unwrap();
archive
.write_all(
br#"{
"data":{"BINPATCH":{"client":"/data/client.lzma","server":"/data/server.lzma"}},
"processors":[{"jar":"example:processor:1.0","classpath":[],"args":["{BINPATCH}"]}],
"libraries":[{"name":"example:processor:1.0"}]
}"#,
)
.unwrap();
archive.start_file("data/client.lzma", options).unwrap();
archive.write_all(b"client-patch").unwrap();
archive.start_file("data/server.lzma", options).unwrap();
archive.write_all(b"server-patch").unwrap();
let bytes = archive.finish().unwrap().into_inner();
let parsed =
parse_installer(bytes, "https://example.com/installer.jar")
.unwrap();
assert_eq!(parsed.artifacts.len(), 2);
assert_eq!(parsed.profile.processors.as_ref().unwrap().len(), 1);
assert!(parsed.profile.libraries.iter().any(|library| {
library.name == "example:processor:1.0"
&& !library.include_in_classpath
}));
assert!(parsed.profile.libraries.iter().any(|library| {
library.name == "example:generated:1.0" && !library.downloadable
}));
let data = parsed.profile.data.unwrap();
assert!(data["BINPATCH"].client.starts_with("[com.axolotl."));
assert_eq!(
data[INSTALLER_URL_KEY].client,
"https://example.com/installer.jar"
);
}
fn minimal_installer_version(id: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"inheritsFrom": "1.12.2",
"releaseTime": "2017-09-18T08:39:46+00:00",
"time": "2017-09-18T08:39:46+00:00",
"mainClass": "net.minecraft.launchwrapper.Launch",
"libraries": [],
"type": "release"
})
}
fn installer_fixture(
version_path: Option<&str>,
profile: serde_json::Value,
) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
if let Some(version_path) = version_path {
archive.start_file(version_path, options).unwrap();
archive
.write_all(
serde_json::to_string(&minimal_installer_version(
version_path,
))
.unwrap()
.as_bytes(),
)
.unwrap();
}
archive.start_file("install_profile.json", options).unwrap();
archive
.write_all(serde_json::to_string(&profile).unwrap().as_bytes())
.unwrap();
archive.finish().unwrap().into_inner()
}
#[test]
fn babric_manifest_is_scoped_to_beta_1_7_3_and_uses_babric_profiles() {
let manifest =
babric_manifest("b1.7.3", vec!["0.15.6-babric.2".to_string()]);
assert_eq!(manifest.game_versions[0].id, "b1.7.3");
let loader = &manifest.game_versions[0].loaders[0];
assert!(loader.url.contains("meta.babric.dev"));
assert!(
loader
.url
.contains("loader/b1.7.3/0.15.6-babric.2/profile/json")
);
assert_eq!(loader.profile_source, LoaderProfileSource::Babric);
assert!(loader.fallback_url.is_none());
let profile: PartialVersionInfo =
serde_json::from_value(serde_json::json!({
"id": "babric-loader-0.15.6-babric.2-b1.7.3",
"inheritsFrom": "b1.7.3",
"releaseTime": "2026-08-23T00:00:00Z",
"time": "2026-08-23T00:00:00Z",
"type": "release",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": [
{"name": "babric:intermediary:b1.7.3"},
{"name": "babric:fabric-loader:0.15.6-babric.2"}
],
"arguments": {"game": ["--demo"]}
}))
.unwrap();
assert_eq!(
profile.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
assert_eq!(profile.inherits_from, "b1.7.3");
assert!(
profile.libraries.iter().any(|library| {
library.name == "babric:intermediary:b1.7.3"
})
);
assert_eq!(
serde_json::to_value(&profile.arguments).unwrap()["game"],
serde_json::json!(["--demo"])
);
}
#[test]
fn babric_uses_the_glass_maven_fallback_when_metadata_is_unavailable() {
let manifest = babric_manifest(
BABRIC_GAME_VERSION,
vec![BABRIC_FALLBACK_LOADER_VERSION.to_string()],
);
let loader = &manifest.game_versions[0].loaders[0];
assert_eq!(manifest.game_versions[0].id, BABRIC_GAME_VERSION);
assert_eq!(loader.id, BABRIC_FALLBACK_LOADER_VERSION);
assert!(loader.stable);
assert!(
loader
.url
.contains("loader/b1.7.3/0.15.6-babric.2/profile/json")
);
let unsupported = babric_unsupported_manifest("1.7.10");
assert_eq!(unsupported.game_versions[0].id, "1.7.10");
assert!(unsupported.game_versions[0].loaders.is_empty());
}
#[test]
fn babric_profile_libraries_copy_only_babric_lwjgl_from_polyfill() {
let libraries = babric_lwjgl_libraries(&serde_json::json!({
"libraries": [
{"name": "org.lwjgl.lwjgl:lwjgl:2.9.4-babric.1"},
{"name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-babric.1"},
{"name": "org.lwjgl.lwjgl:lwjgl:2.9.0"},
{"name": "net.java.jinput:jinput:2.0.5"}
]
}))
.unwrap();
assert_eq!(libraries.len(), 2);
assert!(libraries.iter().all(|library| {
library.name.contains("lwjgl") && library.name.contains("-babric.")
}));
}
#[test]
fn installer_parser_accepts_mid_legacy_profile_json_path() {
let bytes = installer_fixture(
Some("install/1.8.9.json"),
serde_json::json!({
"json": "/install/1.8.9.json",
"libraries": []
}),
);
let parsed = parse_installer(bytes, "https://example.com/forge.jar")
.expect("mid-legacy installer");
assert_eq!(parsed.profile.id, "install/1.8.9.json");
}
#[test]
fn installer_parser_accepts_old_inline_version_info() {
let version_info = minimal_installer_version("1.7.10-Forge");
let bytes = installer_fixture(
None,
serde_json::json!({
"versionInfo": version_info,
"libraries": []
}),
);
let parsed = parse_installer(bytes, "https://example.com/forge.jar")
.expect("old inline installer");
assert_eq!(parsed.profile.id, "1.7.10-Forge");
}
#[test]
fn installer_parser_accepts_legacy_universal_artifact() {
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
archive.start_file("install_profile.json", options).unwrap();
archive
.write_all(
serde_json::json!({
"versionInfo": minimal_installer_version("1.7.10-Forge"),
"install": {
"path": "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10",
"filePath": "/forge-universal.jar"
}
})
.to_string()
.as_bytes(),
)
.unwrap();
archive.start_file("forge-universal.jar", options).unwrap();
archive.write_all(b"legacy universal artifact").unwrap();
let parsed = parse_installer(
archive.finish().unwrap().into_inner(),
"https://example.com/forge.jar",
)
.unwrap();
assert!(parsed.profile.libraries.iter().any(|library| {
library.name
== "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10"
&& !library.downloadable
}));
assert_eq!(parsed.artifacts.len(), 1);
}
#[test]
fn coordinate_from_embedded_maven_path_preserves_classifier_and_extension()
{
assert_eq!(
coordinate_from_maven_path(
"maven/com/example/tool/1.2.3/tool-1.2.3-all.jar"
),
Some("com.example:tool:1.2.3:all@jar".to_string())
);
assert_eq!(
coordinate_from_maven_path(
"maven/com/example/tool/1.2.3/tool-1.2.3.zip"
),
Some("com.example:tool:1.2.3@zip".to_string())
);
assert_eq!(
coordinate_from_maven_path(
"maven/com/example/tool/1.2.3/not-an-artifact.jar"
),
None
);
}
#[test]
fn installer_profile_uses_profile_minecraft_when_version_has_no_inheritance()
{
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default();
archive.start_file("version.json", options).unwrap();
archive
.write_all(
serde_json::json!({
"id": "1.12.2-Cleanroom-test",
"releaseTime": "2026-08-15T20:37:45+00:00",
"time": "2026-08-15T20:37:45+00:00",
"mainClass": "top.outlands.foundation.boot.Foundation",
"javaVersion": {"component": "java-runtime-epsilon", "majorVersion": 25},
"libraries": [],
"type": "release"
})
.to_string()
.as_bytes(),
)
.unwrap();
archive.start_file("install_profile.json", options).unwrap();
archive
.write_all(br#"{"minecraft":"1.12.2","json":"/version.json"}"#)
.unwrap();
let parsed = parse_installer(
archive.finish().unwrap().into_inner(),
"https://example.com/cleanroom.jar",
)
.unwrap();
assert_eq!(parsed.profile.inherits_from, "1.12.2");
assert_eq!(
parsed
.profile
.java_version
.as_ref()
.map(|java| java.major_version),
Some(25)
);
}
#[test]
fn provider_fixtures_filter_and_parse_new_loaders() {
assert_eq!(
optifine_metadata_url(None),
"https://bmclapi2.bangbang93.com/optifine/versionList"
);
assert_eq!(
optifine_metadata_url(Some("1.20.1")),
"https://bmclapi2.bangbang93.com/optifine/1.20.1"
);
let optifine = optifine_manifest(
vec![
OptiFineMetadata {
mcversion: "1.12.2".to_string(),
type_: "HD_U".to_string(),
patch: "G5".to_string(),
},
OptiFineMetadata {
mcversion: "1.20.1".to_string(),
type_: "HD_U".to_string(),
patch: "I6_pre1".to_string(),
},
],
Some("1.12.2"),
);
assert_eq!(optifine.game_versions.len(), 1);
assert_eq!(optifine.game_versions[0].loaders[0].id, "OptiFine_HD_U_G5");
let unsupported_optifine = optifine_manifest(Vec::new(), Some("26.2"));
assert_eq!(unsupported_optifine.game_versions.len(), 1);
assert_eq!(unsupported_optifine.game_versions[0].id, "26.2");
assert!(unsupported_optifine.game_versions[0].loaders.is_empty());
assert!(validate_scoped_manifest(unsupported_optifine, "26.2").is_ok());
let lite_metadata = serde_json::json!({
"versions": {
"1.6.4": {
"artefacts": {
"com.mumfrey:liteloader": {
"latest": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.8"}],
"version": "1.6.4_01",
"stream": "RELEASE",
"timestamp": "1380567800"
}
}
}
},
"1.12.2": {
"snapshots": {
"com.mumfrey:liteloader": {
"latest": {
"tweakClass": "com.mumfrey.liteloader.launch.LiteLoaderTweaker",
"libraries": [{"name": "net.minecraft:launchwrapper:1.12"}],
"version": "1.12.2-SNAPSHOT",
"stream": "SNAPSHOT",
"timestamp": "1511880271"
}
}
}
}
}
});
let lite = liteloader_manifest(&lite_metadata).unwrap();
assert_eq!(lite.game_versions.len(), 1);
assert_eq!(lite.game_versions[0].id, "1.12.2");
assert_eq!(lite.game_versions[0].loaders[0].id, "1.12.2-SNAPSHOT");
let profile = build_liteloader_profile(
&lite_metadata,
"1.12.2",
"1.12.2-SNAPSHOT",
)
.unwrap();
assert_eq!(
profile.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
assert!(profile.libraries.iter().any(|library| {
library.name == "com.mumfrey:liteloader:1.12.2-SNAPSHOT"
}));
assert_eq!(
profile.java_version.as_ref().map(|java| java.major_version),
Some(8)
);
assert_eq!(
profile
.java_version
.as_ref()
.map(|java| java.component.as_str()),
Some("jre-legacy")
);
let legacy_fabric_profile: PartialVersionInfo =
serde_json::from_value(serde_json::json!({
"id": "fabric-loader-0.19.3-1.8.9",
"inheritsFrom": "1.8.9",
"releaseTime": "2026-08-19T13:03:54+0000",
"time": "2026-08-19T13:03:54+0000",
"type": "release",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": [
{"name": "net.legacyfabric:intermediary:1.8.9", "url": "https://maven.legacyfabric.net/"},
{"name": "net.fabricmc:fabric-loader:0.19.3", "url": "https://maven.fabricmc.net/"},
{"name": "org.lwjgl.lwjgl:lwjgl:2.9.4+legacyfabric.17", "url": "https://maven.legacyfabric.net/"}
]
}))
.unwrap();
assert_eq!(
legacy_fabric_profile.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
assert!(legacy_fabric_profile.libraries.iter().any(|library| {
library.name == "net.legacyfabric:intermediary:1.8.9"
}));
assert!(legacy_fabric_profile.arguments.is_none());
assert!(legacy_fabric_profile.minecraft_arguments.is_none());
let cleanroom = cleanroom_manifest(vec![GithubRelease {
tag_name: "0.6.11-alpha".to_string(),
draft: false,
prerelease: false,
assets: vec![GithubReleaseAsset {
name: "cleanroom-0.6.11-alpha-installer.jar".to_string(),
browser_download_url: "https://example.com/cleanroom.jar"
.to_string(),
}],
}]);
assert_eq!(cleanroom.game_versions[0].id, "1.12.2");
assert_eq!(
cleanroom.game_versions[0].loaders[0].profile_source,
LoaderProfileSource::Installer
);
}
#[test]
fn natural_version_order_keeps_latest_first() {
let mut versions = ["55.1.9", "55.1.13", "55.1.10"];
versions.sort_by(|left, right| compare_versions(right, left));
assert_eq!(versions, ["55.1.13", "55.1.10", "55.1.9"]);
}
}