591 lines
22 KiB
Rust
591 lines
22 KiB
Rust
//! 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 <dir>` downloads/patches everything itself and
|
|
//! writes a standard `versions/neoforge-<version>/neoforge-<version>.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/<version>/neoforge-<version>-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<i32>,
|
|
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<DownloadError> for NeoForgeError {
|
|
fn from(error: DownloadError) -> Self {
|
|
Self::Download(error)
|
|
}
|
|
}
|
|
impl From<io::Error> 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<Client, reqwest::Error> {
|
|
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<PathBuf, NeoForgeError> {
|
|
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::<String>()
|
|
.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<i32>, 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<VersionJson, NeoForgeError> {
|
|
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<Mutex<Vec<(u64, u64)>>> = 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<Mutex<Vec<(u64, u64)>>> = 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();
|
|
}
|
|
}
|