mod fabric;
mod forge;
mod neoforge;
mod quilt;
mod vanilla;
use std::path::Path;
use async_trait::async_trait;
use crate::instance::models::ModLoader;
use crate::net::{HttpClient, NetError};
pub use vanilla::VanillaInstaller;
#[derive(Debug, Clone)]
pub struct GameVersion {
pub id: String,
pub stable: bool,
}
#[async_trait]
pub trait ModLoaderInstaller: Send + Sync {
fn loader_type(&self) -> ModLoader;
async fn get_game_versions(&self, client: &HttpClient) -> Result<Vec<GameVersion>, NetError>;
async fn get_versions(
&self,
client: &HttpClient,
game_version: &str,
) -> Result<Vec<String>, NetError>;
async fn install(
&self,
client: &HttpClient,
game_version: &str,
loader_version: &str,
instance_dir: &Path,
meta_dir: &Path,
) -> Result<(), NetError>;
}
pub(crate) fn save_profile_bytes(
meta_dir: &Path,
filename: &str,
bytes: &[u8],
) -> std::io::Result<()> {
let profiles_dir = meta_dir.join("loader-profiles");
std::fs::create_dir_all(&profiles_dir)?;
std::fs::write(profiles_dir.join(filename), bytes)
}
pub(crate) fn save_installer_profile(
instance_dir: &Path,
meta_dir: &Path,
version_dir_name: &str,
profile_filename: &str,
) -> Result<(), NetError> {
let ver_json_path = instance_dir
.join(".minecraft")
.join("versions")
.join(version_dir_name)
.join(format!("{version_dir_name}.json"));
if !ver_json_path.exists() {
return Err(NetError::Parse(format!(
"Version JSON not found at {}",
ver_json_path.display()
)));
}
let raw = std::fs::read(&ver_json_path)?;
let profiles_dir = meta_dir.join("loader-profiles");
std::fs::create_dir_all(&profiles_dir)?;
let profile_path = profiles_dir.join(profile_filename);
std::fs::write(&profile_path, &raw)?;
Ok(())
}
pub fn get_installer(loader: ModLoader) -> Box<dyn ModLoaderInstaller + Send + Sync> {
match loader {
ModLoader::Vanilla => Box::new(vanilla::VanillaInstaller),
ModLoader::Fabric => Box::new(fabric::FabricInstaller),
ModLoader::Forge => Box::new(forge::ForgeInstaller),
ModLoader::NeoForge => Box::new(neoforge::NeoForgeInstaller),
ModLoader::Quilt => Box::new(quilt::QuiltInstaller),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
#[case::vanilla(ModLoader::Vanilla)]
#[case::forge(ModLoader::Forge)]
#[case::neoforge(ModLoader::NeoForge)]
#[case::fabric(ModLoader::Fabric)]
#[case::quilt(ModLoader::Quilt)]
fn get_installer_returns_matching_loader_type(#[case] loader: ModLoader) {
let installer = get_installer(loader);
assert_eq!(installer.loader_type(), loader);
}
#[tokio::test]
#[ignore = "hits live Mojang API"]
async fn test_vanilla_get_game_versions() {
let client = HttpClient::new();
let installer = VanillaInstaller;
let versions = installer.get_game_versions(&client).await.unwrap();
assert!(!versions.is_empty());
assert!(versions.iter().any(|v| v.id == "1.20.1"));
}
#[test]
fn save_installer_profile_copies_raw_bytes_verbatim() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let instance_dir = tmp.path().join("instance");
let meta_dir = tmp.path().join("meta");
let installer_json = br#"{
"id": "1.20.1-forge-47.2.0",
"inheritsFrom": "1.20.1",
"mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
"libraries": [{ "name": "net.minecraftforge:forge:47.2.0" }],
"arguments": {
"game": ["--launchTarget", "forge_client"],
"jvm": ["--add-opens", "java.base/sun.security.util=cpw.mods.securejarhandler"]
}
}"#;
let ver_dir = instance_dir
.join(".minecraft")
.join("versions")
.join("1.20.1-forge-47.2.0");
std::fs::create_dir_all(&ver_dir).unwrap();
let ver_json_path = ver_dir.join("1.20.1-forge-47.2.0.json");
std::fs::write(&ver_json_path, installer_json).unwrap();
save_installer_profile(
&instance_dir,
&meta_dir,
"1.20.1-forge-47.2.0",
"forge-1.20.1-47.2.0.json",
)
.unwrap();
let saved = std::fs::read(
meta_dir
.join("loader-profiles")
.join("forge-1.20.1-47.2.0.json"),
)
.unwrap();
assert_eq!(
saved,
installer_json.to_vec(),
"saved profile should be byte-for-byte identical to installer output"
);
}
#[test]
fn legacy_forge_version_info_deserialises_as_launch_profile() {
let bytes = br#"{
"id": "1.7.10-Forge10.13.4.1614-1.7.10",
"mainClass": "net.minecraft.launchwrapper.Launch",
"minecraftArguments": "--username ${auth_player_name} --tweakClass cpw.mods.fml.common.launcher.FMLTweaker",
"libraries": [
{ "name": "net.minecraftforge:forge:10.13.4.1614", "url": "http://files.minecraftforge.net/maven/" },
{ "name": "net.minecraft:launchwrapper:1.9" }
]
}"#;
let profile: crate::launch_profile::model::LaunchProfile =
serde_json::from_slice(bytes).unwrap();
assert_eq!(profile.id, "1.7.10-Forge10.13.4.1614-1.7.10");
assert_eq!(
profile.main_class.as_deref(),
Some("net.minecraft.launchwrapper.Launch")
);
assert!(profile.inherits_from.is_none());
assert!(
profile
.minecraft_arguments
.as_deref()
.unwrap()
.contains("--tweakClass")
);
assert_eq!(profile.libraries.len(), 2);
assert_eq!(
profile.libraries[0].name,
"net.minecraftforge:forge:10.13.4.1614"
);
assert_eq!(
profile.libraries[0].url.as_deref(),
Some("http://files.minecraftforge.net/maven/")
);
assert!(profile.libraries[0].downloads.is_none());
}
#[test]
fn raw_fabric_profile_bytes_parse_as_launch_profile() {
let bytes = br#"{
"id": "fabric-loader-0.14.21-1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": [
{ "name": "net.fabricmc:fabric-loader:0.14.21", "url": "https://maven.fabricmc.net/" },
{ "name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/" }
]
}"#;
let parsed: crate::launch_profile::model::LaunchProfile =
serde_json::from_slice(bytes).unwrap();
assert_eq!(parsed.id, "fabric-loader-0.14.21-1.20.1");
assert_eq!(
parsed.main_class.as_deref(),
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
);
assert!(parsed.inherits_from.is_none());
assert!(parsed.arguments.is_none());
assert_eq!(parsed.libraries.len(), 2);
assert_eq!(
parsed.libraries[0].url.as_deref(),
Some("https://maven.fabricmc.net/")
);
}
}