//! NeoForge trust boundary: downloads the official installer for //! `manifest.minecraft.loader.version` from `maven.neoforged.net` and runs //! it headlessly to produce a standard, vanilla-launcher-compatible version //! profile under the shared game directory. //! //! We deliberately do not reimplement the installer's client processor //! pipeline (mapping extraction, jar splitting, renaming, binary patching): //! running NeoForge's own official installer jar is far less code, matches //! exactly what a human running the installer manually would get, and //! survives future NeoForge releases changing their processor format. //! //! Empirically verified (2026-09-06, against the real //! neoforge-21.1.248-installer.jar and a real Temurin 21 JRE): the //! installer's `net.minecraftforge.installer.SimpleInstaller` refuses to //! target a directory that doesn't already look like a `.minecraft` folder //! ("you need to run the launcher first!") unless a `launcher_profiles.json` //! stub already exists there — see `ensure_launcher_profiles_stub`. After //! that, `--installClient ` downloads/patches everything itself and //! writes a standard `versions/neoforge-/neoforge-.json` //! that inherits from the vanilla version and needs no NeoForge-specific //! classpath handling: `mojang::merge_versions` + the resulting libraries //! list is everything `launch.rs` needs. The separately-produced //! `libraries/net/neoforged/neoforge//neoforge--client.jar` //! is loaded by FancyModLoader itself at runtime (via the `--fml.*` game //! arguments already present on the merged profile) and is intentionally //! never added to our own classpath. use crate::download::{self, Checksum, DownloadError, ProgressCallback}; use crate::mojang::VersionJson; use reqwest::blocking::Client; use std::{ fmt, fs, io, io::{BufRead, BufReader, Read}, path::{Path, PathBuf}, process::{Command, Stdio}, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, }, thread, }; pub(crate) const NEOFORGE_HOST: &str = "maven.neoforged.net"; /// Minimal `launcher_profiles.json` accepted by the legacy NeoForge/Forge /// installer as proof that a directory is a legitimate launcher data /// directory. Written once; never overwrites an existing file. const LAUNCHER_PROFILES_STUB: &str = r#"{"profiles":{},"selectedProfile":"","clientToken":"","authenticationDatabase":{},"settings":{"enableSnapshots":false,"enableAdvanced":false,"keepLauncherOpen":false,"soundOn":false,"showGameLog":false,"profileSorting":"ByLastPlayed","showMenu":false,"enableHistorical":false,"enableReleases":true,"crashAssistance":true},"version":3}"#; #[derive(Debug)] pub enum NeoForgeError { DisallowedHost(String), Network(reqwest::Error), HttpStatus(reqwest::StatusCode), InvalidChecksum(String), Download(DownloadError), Io(io::Error), InvalidJson(serde_json::Error), InstallerFailed { exit_code: Option, output_tail: String, }, } impl fmt::Display for NeoForgeError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::DisallowedHost(url) => { write!(formatter, "URL is not a recognised NeoForge host: {url}") } Self::Network(error) => write!(formatter, "network error: {error}"), Self::HttpStatus(status) => write!(formatter, "maven.neoforged.net returned {status}"), Self::InvalidChecksum(text) => { write!(formatter, "unexpected checksum response: {text}") } Self::Download(error) => write!(formatter, "{error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"), Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"), Self::InstallerFailed { exit_code, output_tail, } => { write!( formatter, "NeoForge installer failed (exit {exit_code:?}):\n{output_tail}" ) } } } } impl From for NeoForgeError { fn from(error: DownloadError) -> Self { Self::Download(error) } } impl From for NeoForgeError { fn from(error: io::Error) -> Self { Self::Io(error) } } pub(crate) fn is_allowed_host(url: &str) -> bool { crate::trusted_http::allows(url, &[NEOFORGE_HOST]) } pub fn http_client() -> Result { crate::trusted_http::client(&[NEOFORGE_HOST], std::time::Duration::from_secs(10 * 60)) } fn installer_jar_url(loader_version: &str) -> String { format!("https://{NEOFORGE_HOST}/releases/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar") } /// Downloads (or reuses a cached, still-valid) NeoForge installer jar, /// verified against the `.sha256` sidecar Maven publishes next to every /// artifact. pub fn ensure_installer( client: &Client, cache_dir: &Path, loader_version: &str, ) -> Result { let jar_url = installer_jar_url(loader_version); let checksum_url = format!("{jar_url}.sha256"); if !is_allowed_host(&jar_url) { return Err(NeoForgeError::DisallowedHost(jar_url)); } let response = client .get(&checksum_url) .send() .map_err(NeoForgeError::Network)?; if !response.status().is_success() { return Err(NeoForgeError::HttpStatus(response.status())); } let sha256 = response .text() .map_err(NeoForgeError::Network)? .trim() .to_ascii_lowercase(); if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err(NeoForgeError::InvalidChecksum(sha256)); } let target = cache_dir.join(format!("neoforge-{loader_version}-installer.jar")); let checksum = Checksum::Sha256(sha256); if !download::is_current(&target, None, &checksum)? { download::download_verified(client, &jar_url, &target, None, &checksum, |_, _| {})?; } Ok(target) } fn ensure_launcher_profiles_stub(game_dir: &Path) -> io::Result<()> { let path = game_dir.join("launcher_profiles.json"); if path.exists() { return Ok(()); } fs::create_dir_all(game_dir)?; fs::write(path, LAUNCHER_PROFILES_STUB) } pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> PathBuf { game_dir .join("versions") .join(format!("neoforge-{loader_version}")) .join(format!("neoforge-{loader_version}.json")) } fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf { game_dir .join("libraries/net/neoforged/neoforge") .join(loader_version) .join(format!("neoforge-{loader_version}-client.jar")) } fn is_nonempty_file(path: &Path) -> bool { path.metadata() .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) } fn installation_complete(game_dir: &Path, loader_version: &str) -> bool { is_nonempty_file(&installed_version_json_path(game_dir, loader_version)) && is_nonempty_file(&patched_client_path(game_dir, loader_version)) } /// The installer jar bundles its own `install_profile.json`, which lists /// exactly which libraries it will download and which processors it will /// run to patch the client — the same manifest the installer itself reads. /// Reading it upfront gives a real, version-agnostic total for progress /// reporting instead of a guessed constant. fn read_install_profile_counts(installer_path: &Path) -> Option<(u64, u64)> { let file = fs::File::open(installer_path).ok()?; let mut archive = zip::ZipArchive::new(file).ok()?; let mut entry = archive.by_name("install_profile.json").ok()?; let mut contents = String::new(); entry.read_to_string(&mut contents).ok()?; let profile: serde_json::Value = serde_json::from_str(&contents).ok()?; let libraries = profile.get("libraries")?.as_array()?.len() as u64; let processors = profile.get("processors")?.as_array()?.len() as u64; Some((libraries, processors)) } /// Bumps `downloads_done`/`processors_done` from one line of the installer's /// output and reports the combined total, clamped so a miscount (e.g. the /// installer logging a couple of extra non-library downloads) never exceeds /// or exceeds `total` by much. `total_libraries` caps the download half so /// those extra lines cannot crowd out the processor half of the bar. fn observe_installer_line( line: &str, downloads_done: &AtomicU64, processors_done: &AtomicU64, total_libraries: u64, total: u64, on_progress: &ProgressCallback, ) { let trimmed = line.trim_start(); if trimmed.starts_with("Download completed") { downloads_done.fetch_add(1, Ordering::Relaxed); } else if trimmed.starts_with("Processor: ") && trimmed.matches(':').count() == 2 { // Exactly two colons is the processor *header* line // ("Processor: net.neoforged.installertools:jarsplitter"); its // sub-step lines ("Processor: ...: Loading patch files") have three. processors_done.fetch_add(1, Ordering::Relaxed); } else { return; } let current = downloads_done.load(Ordering::Relaxed).min(total_libraries) + processors_done.load(Ordering::Relaxed); on_progress(current.min(total), total); } fn truncate_tail(text: &str) -> String { text.chars() .rev() .take(4000) .collect::() .chars() .rev() .collect() } /// Runs the installer with piped output, reporting live progress as its own /// log lines confirm each library download and processor step, instead of /// blocking silently until the whole (often minutes-long) run finishes. /// Returns the process's exit code and its combined stdout+stderr, which the /// caller uses to build a diagnostic if the install turns out to have failed /// silently (exit 0 but no version JSON produced). fn run_installer_with_progress( java_executable: &Path, installer_path: &Path, game_dir: &Path, cache_dir: &Path, total_libraries: u64, total: u64, on_progress: &ProgressCallback, ) -> Result<(Option, String), NeoForgeError> { let mut child = Command::new(java_executable) .arg("-jar") .arg(installer_path) .arg("--installClient") .arg(game_dir) .current_dir(cache_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; let stdout = child.stdout.take().expect("stdout was piped"); let stderr = child.stderr.take().expect("stderr was piped"); let combined_log = Arc::new(Mutex::new(String::new())); let downloads_done = Arc::new(AtomicU64::new(0)); let processors_done = Arc::new(AtomicU64::new(0)); let stdout_thread = { let combined_log = Arc::clone(&combined_log); let downloads_done = Arc::clone(&downloads_done); let processors_done = Arc::clone(&processors_done); let on_progress = Arc::clone(on_progress); thread::spawn(move || { for line in BufReader::new(stdout).lines().map_while(Result::ok) { observe_installer_line( &line, &downloads_done, &processors_done, total_libraries, total, &on_progress, ); let mut log = combined_log.lock().unwrap(); log.push_str(&line); log.push('\n'); } }) }; let stderr_thread = { let combined_log = Arc::clone(&combined_log); thread::spawn(move || { for line in BufReader::new(stderr).lines().map_while(Result::ok) { let mut log = combined_log.lock().unwrap(); log.push_str(&line); log.push('\n'); } }) }; let status = child.wait()?; stdout_thread.join().ok(); stderr_thread.join().ok(); let tail = truncate_tail(&combined_log.lock().unwrap()); if !status.success() { return Err(NeoForgeError::InstallerFailed { exit_code: status.code(), output_tail: tail, }); } Ok((status.code(), tail)) } /// Ensures NeoForge `loader_version` is installed into the shared /// `game_dir` (vanilla libraries/version must already be there so the /// installer can reuse them). No-op if already installed. Runs the /// installer headlessly with `java_executable`; its own network calls go /// straight to `maven.neoforged.net`/Mojang, outside our control, which is /// an accepted trust delegation to NeoForge's official tooling once the /// installer binary itself is SHA-256 verified. `on_progress` reports real /// progress (installer-confirmed library downloads plus patch-processor /// steps, read from the installer's own `install_profile.json`) while it /// runs; it fires once with `(1, 1)` when already installed. pub fn ensure_client_installed( client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, on_progress: &ProgressCallback, ) -> Result { let version_json_path = installed_version_json_path(game_dir, loader_version); if !installation_complete(game_dir, loader_version) { ensure_launcher_profiles_stub(game_dir)?; let installer_path = ensure_installer(client, cache_dir, loader_version)?; // A leftover version JSON makes some installer versions treat the // profile as already installed even when the patched client was // deleted or quarantined. Remove only that generated marker so the // official installer is forced to rebuild the incomplete profile. match fs::remove_file(&version_json_path) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(NeoForgeError::Io(error)), } let (total_libraries, total_processors) = read_install_profile_counts(&installer_path).unwrap_or((0, 0)); let total = (total_libraries + total_processors).max(1); on_progress(0, total); let (exit_code, tail) = run_installer_with_progress( java_executable, &installer_path, game_dir, cache_dir, total_libraries, total, on_progress, )?; if !installation_complete(game_dir, loader_version) { return Err(NeoForgeError::InstallerFailed { exit_code, output_tail: tail, }); } on_progress(total, total); } else { on_progress(1, 1); } let bytes = fs::read(&version_json_path)?; serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson) } #[cfg(test)] mod tests { use super::*; #[test] fn installer_url_matches_maven_layout() { assert_eq!( installer_jar_url("21.1.248"), "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar" ); } #[test] fn rejects_non_neoforge_hosts() { assert!(!is_allowed_host("https://example.com/evil.jar")); assert!(is_allowed_host( "https://maven.neoforged.net/releases/x.jar" )); } #[test] fn launcher_profiles_stub_is_idempotent() { let dir = std::env::temp_dir().join(format!("shacraft-neoforge-test-{}", std::process::id())); ensure_launcher_profiles_stub(&dir).unwrap(); let first = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap(); fs::write(dir.join("launcher_profiles.json"), "custom-content").unwrap(); ensure_launcher_profiles_stub(&dir).unwrap(); let second = fs::read_to_string(dir.join("launcher_profiles.json")).unwrap(); assert_eq!(second, "custom-content"); assert!(first.contains("\"profiles\"")); fs::remove_dir_all(&dir).unwrap(); } #[test] fn incomplete_install_is_not_accepted() { let dir = std::env::temp_dir().join(format!( "shacraft-neoforge-completeness-test-{}", std::process::id() )); let version = "21.1.248"; let json = installed_version_json_path(&dir, version); fs::create_dir_all(json.parent().unwrap()).unwrap(); fs::write(&json, b"{}").unwrap(); assert!(!installation_complete(&dir, version)); let client = patched_client_path(&dir, version); fs::create_dir_all(client.parent().unwrap()).unwrap(); fs::write(&client, b"patched").unwrap(); assert!(installation_complete(&dir, version)); fs::remove_dir_all(dir).unwrap(); } #[test] fn observe_installer_line_counts_downloads_and_processor_headers() { let downloads_done = AtomicU64::new(0); let processors_done = AtomicU64::new(0); let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); let on_progress: ProgressCallback = { let calls = Arc::clone(&calls); Arc::new(move |current, total| calls.lock().unwrap().push((current, total))) }; let total_libraries = 2; let total = 3; // 2 libraries + 1 processor // A "Downloading library from ..." start line reports nothing by // itself; only its "Download completed" confirmation counts. observe_installer_line( "Downloading library from https://example/a.jar", &downloads_done, &processors_done, total_libraries, total, &on_progress, ); observe_installer_line( "Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress, ); observe_installer_line( "Download completed: Checksum validated.", &downloads_done, &processors_done, total_libraries, total, &on_progress, ); observe_installer_line( "Processor: net.neoforged.installertools:jarsplitter", &downloads_done, &processors_done, total_libraries, total, &on_progress, ); // A processor's sub-step lines (three colons) must not double-count. observe_installer_line( "Processor: net.neoforged.installertools:jarsplitter: Loading patch files", &downloads_done, &processors_done, total_libraries, total, &on_progress, ); assert_eq!(*calls.lock().unwrap(), vec![(1, 3), (2, 3), (3, 3)]); } /// Full live pipeline: provisions a real Java 21 (runtime.rs) if none /// is already usable, then runs the real NeoForge 21.1.248 installer /// into an empty game dir (it fetches and patches vanilla 1.21.1 /// itself — confirmed manually, no pre-seeding needed) and checks the /// installed profile merges into a launch-shaped spec together with a /// separately-fetched vanilla version JSON (mojang.rs), exactly as /// `lib.rs`'s `ensure_game_installed` command will do it. Not run by /// default; `cargo test -- --ignored live_full_pipeline`. #[test] #[ignore] fn live_full_pipeline_installs_neoforge() { use crate::{java, mojang}; let client = Client::builder().build().unwrap(); let root = std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); let game_dir = root.join("game"); let cache_dir = root.join("cache"); fs::create_dir_all(&cache_dir).unwrap(); let manifest = mojang::fetch_version_manifest(&client).unwrap(); let entry = mojang::find_version(&manifest, "1.21.1").unwrap(); let vanilla = mojang::fetch_version_json(&client, entry).unwrap(); let no_progress: ProgressCallback = Arc::new(|_, _| {}); let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap(); // The installer fetches and patches vanilla itself; we don't // pre-download it. It only needs a Java runtime and an empty dir. let progress_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); let progress: ProgressCallback = { let progress_calls = Arc::clone(&progress_calls); Arc::new(move |current, total| progress_calls.lock().unwrap().push((current, total))) }; let neoforge_version = ensure_client_installed( &client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &progress, ) .unwrap(); let merged = mojang::merge_versions(&vanilla, Some(&neoforge_version)).unwrap(); assert_eq!( merged.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher" ); assert!( merged.libraries.len() > 100, "expected vanilla (97) + neoforge (47) libraries, got {}", merged.libraries.len() ); let patched_client = game_dir.join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar"); assert!( patched_client.exists(), "FancyModLoader needs this at runtime even though it is not on the generic classpath" ); let calls = progress_calls.lock().unwrap(); assert!( calls.len() > 5, "expected many incremental progress calls, got {}", calls.len() ); let (last_current, last_total) = *calls.last().unwrap(); assert_eq!( last_current, last_total, "progress must reach 100% on success" ); assert!( calls.windows(2).all(|pair| pair[0].0 <= pair[1].0), "reported progress must never go backwards" ); drop(calls); // Re-running must skip straight to reading the cached version JSON // rather than invoking the installer again. let neoforge_again = ensure_client_installed( &client, Path::new(&java_install.executable), &game_dir, &cache_dir, "21.1.248", &no_progress, ) .unwrap(); assert_eq!( neoforge_again.libraries.len(), neoforge_version.libraries.len() ); fs::remove_dir_all(&root).ok(); } }