use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use crate::instance::loader::GameVersion;
use crate::net::{HttpClient, NetError, download_file};
use crate::tui::progress::{set_action, set_sub_action};
const FORGE_PROMOTIONS_URL: &str =
"https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
const FORGE_MAVEN_BASE: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge";
#[derive(Debug, Deserialize)]
struct ForgePromotions {
promos: HashMap<String, String>,
}
pub async fn fetch_forge_versions(
client: &HttpClient,
game_version: &str,
) -> Result<Vec<String>, NetError> {
fetch_forge_versions_from(client, FORGE_PROMOTIONS_URL, game_version).await
}
pub async fn fetch_forge_versions_from(
client: &HttpClient,
promotions_url: &str,
game_version: &str,
) -> Result<Vec<String>, NetError> {
let promotions: ForgePromotions = client.get_json(promotions_url).await?;
let prefix = format!("{}-", game_version);
let mut versions: Vec<String> = promotions
.promos
.iter()
.filter(|(key, _)| key.starts_with(&prefix))
.map(|(_, value)| value.clone())
.collect();
versions.sort();
versions.dedup();
Ok(versions)
}
pub async fn fetch_forge_game_versions(client: &HttpClient) -> Result<Vec<GameVersion>, NetError> {
fetch_forge_game_versions_from(client, FORGE_PROMOTIONS_URL).await
}
pub async fn fetch_forge_game_versions_from(
client: &HttpClient,
promotions_url: &str,
) -> Result<Vec<GameVersion>, NetError> {
let promos: ForgePromotions = client.get_json(promotions_url).await?;
let mut game_versions: Vec<String> = promos
.promos
.keys()
.filter_map(|key| key.rsplit_once('-').map(|(version, _)| version.to_string()))
.collect();
game_versions.sort();
game_versions.dedup();
game_versions.reverse();
Ok(game_versions
.into_iter()
.map(|version| GameVersion {
id: version,
stable: true,
})
.collect())
}
pub async fn download_forge_installer(
client: &HttpClient,
game_version: &str,
forge_version: &str,
dest: &Path,
) -> Result<(), NetError> {
let mc_no_dots: String = game_version.chars().filter(|c| *c != '.').collect();
let slugs = [
format!("{game_version}-{forge_version}"),
format!("{game_version}-{forge_version}-{game_version}"),
format!("{game_version}-{forge_version}-mc{mc_no_dots}"),
];
set_action(format!(
"Downloading Forge {}-{}...",
game_version, forge_version
));
let mut last_err = None;
for slug in &slugs {
let url = format!("{}/{slug}/forge-{slug}-installer.jar", FORGE_MAVEN_BASE,);
match download_file(client, &url, dest, |downloaded, total| {
crate::tui::progress::set_progress(downloaded, total);
})
.await
{
Ok(()) => return Ok(()),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| {
NetError::Parse(format!(
"No Forge installer found for {game_version}-{forge_version}"
))
}))
}
pub async fn run_forge_installer(
installer_path: &Path,
instance_dir: &Path,
java_path: &str,
) -> Result<(), NetError> {
use tokio::process::Command;
set_action("Running Forge installer...");
let output = match Command::new(java_path)
.arg("-jar")
.arg(installer_path)
.arg("--installClient")
.current_dir(instance_dir.join(".minecraft"))
.output()
.await
{
Ok(o) => o,
Err(e) => {
return Err(NetError::Io(e));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if stderr.trim().is_empty() {
format!("exit code {:?}", output.status.code())
} else {
stderr.lines().last().unwrap_or("unknown error").to_string()
};
return Err(NetError::InstallerFailed(detail));
}
Ok(())
}
pub(crate) fn has_legacy_install_profile(installer_path: &Path) -> bool {
let file = match std::fs::File::open(installer_path) {
Ok(f) => f,
Err(_) => return false,
};
let mut archive = match zip::ZipArchive::new(file) {
Ok(a) => a,
Err(_) => return false,
};
let entry = match archive.by_name("install_profile.json") {
Ok(e) => e,
Err(_) => return false,
};
let value: serde_json::Value = match serde_json::from_reader(entry) {
Ok(v) => v,
Err(_) => return false,
};
value.get("versionInfo").is_some()
}
pub(crate) async fn install_forge_from_profile(
client: &HttpClient,
installer_path: &Path,
meta_dir: &Path,
profile_filename: &str,
) -> Result<(), NetError> {
use std::io::Read;
set_action("Installing legacy Forge from profile...");
let file = std::fs::File::open(installer_path)?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| NetError::Parse(format!("Failed to open installer as ZIP: {e}")))?;
let profile_data: serde_json::Value = {
let entry = archive.by_name("install_profile.json").map_err(|e| {
NetError::Parse(format!("install_profile.json not found in installer: {e}"))
})?;
serde_json::from_reader(entry)
.map_err(|e| NetError::Parse(format!("Failed to parse install_profile.json: {e}")))?
};
let version_info = profile_data
.get("versionInfo")
.ok_or_else(|| NetError::Parse("install_profile.json missing versionInfo".into()))?;
let install_info = profile_data
.get("install")
.ok_or_else(|| NetError::Parse("install_profile.json missing install section".into()))?;
let libraries = version_info
.get("libraries")
.and_then(|v| v.as_array())
.ok_or_else(|| NetError::Parse("missing versionInfo.libraries".into()))?;
let file_path = install_info
.get("filePath")
.and_then(|v| v.as_str())
.ok_or_else(|| NetError::Parse("missing install.filePath".into()))?;
let install_path_coord = install_info
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| NetError::Parse("missing install.path".into()))?;
let universal_maven_path =
crate::net::maven_coord_to_path(install_path_coord).ok_or_else(|| {
NetError::Parse(format!(
"Invalid maven coord in install.path: {install_path_coord}"
))
})?;
set_sub_action("Extracting universal JAR...");
let universal_dest = meta_dir.join("libraries").join(&universal_maven_path);
if let Some(parent) = universal_dest.parent() {
std::fs::create_dir_all(parent)?;
}
{
let mut entry = archive.by_name(file_path).map_err(|e| {
NetError::Parse(format!(
"Universal JAR '{file_path}' not found in installer: {e}"
))
})?;
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
std::fs::write(&universal_dest, &buf)?;
}
let libraries_dir = meta_dir.join("libraries");
for lib in libraries {
let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default();
let maven_path = match crate::net::maven_coord_to_path(name) {
Some(p) => p,
None => {
return Err(NetError::Parse(format!("Invalid Maven coordinate: {name}")));
}
};
let dest = libraries_dir.join(&maven_path);
if dest.exists() {
continue;
}
let base_url = lib
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("https://libraries.minecraft.net/")
.trim_end_matches('/');
let download_url = format!("{base_url}/{maven_path}");
set_sub_action(name);
download_file(client, &download_url, &dest, |_, _| {}).await?;
}
set_action("Saving Forge profile...");
let serialized = serde_json::to_vec(version_info)
.map_err(|e| NetError::Parse(format!("Failed to serialize Forge profile: {e}")))?;
crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::HttpClient;
#[tokio::test]
#[ignore = "hits live Forge API"]
async fn test_fetch_versions() {
let client = HttpClient::new();
match fetch_forge_versions(&client, "1.20.1").await {
Ok(versions) => {
assert!(
!versions.is_empty(),
"Should have Forge versions for 1.20.1"
);
}
Err(e) => panic!("fetch_forge_versions failed: {}", e),
}
}
#[tokio::test]
#[ignore = "hits live Forge API"]
async fn test_fetch_game_versions() {
let client = HttpClient::new();
match fetch_forge_game_versions(&client).await {
Ok(versions) => {
assert!(!versions.is_empty(), "Should have Forge game versions");
assert!(versions.iter().any(|version| version.id == "1.20.1"));
}
Err(e) => panic!("fetch_forge_game_versions failed: {}", e),
}
}
fn make_installer_zip(tmp: &std::path::Path, json: &serde_json::Value) -> std::path::PathBuf {
use std::io::Write;
let path = tmp.join("installer.jar");
let file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
zip.start_file("install_profile.json", opts).unwrap();
zip.write_all(serde_json::to_string(json).unwrap().as_bytes())
.unwrap();
zip.finish().unwrap();
path
}
#[test]
fn has_legacy_install_profile_true_when_version_info_present() {
let tmp = tempfile::tempdir().unwrap();
let jar = make_installer_zip(
tmp.path(),
&serde_json::json!({
"install": {},
"versionInfo": {
"id": "1.7.10-Forge10.13.4.1614-1.7.10",
"mainClass": "net.minecraft.launchwrapper.Launch"
}
}),
);
assert!(has_legacy_install_profile(&jar));
}
#[test]
fn has_legacy_install_profile_false_when_version_info_absent() {
let tmp = tempfile::tempdir().unwrap();
let jar = make_installer_zip(
tmp.path(),
&serde_json::json!({
"spec": 1,
"minecraft": "1.20.1",
"data": {}
}),
);
assert!(!has_legacy_install_profile(&jar));
}
#[test]
fn has_legacy_install_profile_false_for_missing_jar() {
assert!(!has_legacy_install_profile(std::path::Path::new(
"/nonexistent/installer.jar"
)));
}
}