v1.0.14.5 — instant proxy fallback for all downloads, server proxy retries, loader library repair

- ZHttpClient: single direct attempt per resource, then immediate retry via /proxy/download (getWithSmartProxy, downloadFileWithSmartProxy)
- getRetry() for server proxy endpoints incl. transient 502/5xx; isRetryableError classification
- MAX_FAILS_BEFORE_PROXY 1 -> service switches to proxy-first after first failure
- detectService: files.minecraftforge.net routed through proxy
- repairMissingLibraries/repairLibrariesFromJson: re-download missing libs (incl. maven-coordinates format with -v2 fallback)
- JFX: force Mojang service check synchronously before install if network init not finished
- Forge/NeoForge: installer + downloads via ZHttpClient (no more bare HttpClient without request timeout), NeoForge subprocess 10-min timeout
- Fabric: pre-download loader libs via proxy, repair after install
This commit is contained in:
SashegDev
2026-08-01 11:52:23 +00:00
parent c51d441743
commit a87a871e43
7 changed files with 280 additions and 284 deletions
@@ -44,6 +44,8 @@ public class FabricInstaller {
ProgressBar.finish("Fabric Installer downloaded");
}
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
@@ -85,6 +87,8 @@ public class FabricInstaller {
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
ZHttpClient.repairMissingLibraries(instancePath);
return true;
} else {
System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
@@ -101,6 +105,37 @@ public class FabricInstaller {
}
}
/**
* Pre-downloads the Fabric loader jar and its dependency manifest through the smart proxy
* so the installer subprocess finds them already on disk and skips direct downloads.
* Non-fatal: if this fails the subprocess will still try to download itself.
*/
private void preDownloadFabricLibraries(String minecraftVersion, String loaderVersion) {
try {
Path libRoot = instance.getPath().resolve("libraries");
String loaderVersionPath = "net/fabricmc/fabric-loader/" + loaderVersion;
Path loaderJson = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".json");
if (!Files.exists(loaderJson)) {
Files.createDirectories(loaderJson.getParent());
ZHttpClient.downloadFileWithSmartProxy(
"https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".json",
loaderJson);
}
Path loaderJar = libRoot.resolve(loaderVersionPath).resolve("fabric-loader-" + loaderVersion + ".jar");
if (!Files.exists(loaderJar)) {
ZHttpClient.downloadFileWithSmartProxy(
"https://maven.fabricmc.net/" + loaderVersionPath + "/fabric-loader-" + loaderVersion + ".jar",
loaderJar);
}
ZHttpClient.repairLibrariesFromJson(loaderJson, instance.getPath());
} catch (Exception e) {
System.out.println(ZAnsi.yellow("Pre-download of Fabric libraries incomplete: " + e.getMessage()));
}
}
private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException {
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
@@ -3,26 +3,16 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class ForgeInstaller {
private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public ForgeInstaller(Instance instance) {
this.instance = instance;
@@ -101,49 +91,8 @@ public class ForgeInstaller {
}
private void downloadFileWithProgress(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(java.time.Duration.ofMinutes(5))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(target.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
int lastPercent = -1;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (contentLength > 0) {
int percent = (int) ((totalRead * 100) / contentLength);
if (percent != lastPercent) {
String downloaded = ProgressBar.formatBytes(totalRead);
String total = ProgressBar.formatBytes(contentLength);
ProgressBar.show("Forge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
lastPercent = percent;
}
} else {
// If size unknown, show animation
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rDownloading Forge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
}
}
}
ProgressBar.show("Downloading Forge Installer", 0, 100, "%");
ZHttpClient.downloadFileWithSmartProxy(url, target);
ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
}
@@ -259,39 +208,6 @@ public class ForgeInstaller {
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
// List of problematic libraries and their alternate URLs
Path librariesDir = instance.getPath().resolve("libraries");
// Map from maven path to list of mirror URLs (tried in order)
Map<String, List<String>> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", Arrays.asList(
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
));
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
boolean downloaded = false;
for (String mirrorUrl : entry.getValue()) {
for (int attempt = 1; attempt <= 3; attempt++) {
try {
downloadFileWithProgress(mirrorUrl, target);
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
try { Thread.sleep(2000); } catch (InterruptedException ignored) {}
}
}
if (downloaded) break;
}
}
}
ZHttpClient.repairMissingLibraries(instance.getPath());
}
}
@@ -217,46 +217,6 @@ public class ModLoaderInstaller {
private void downloadMissingLibraries(LoaderType type) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
Map<String, List<String>> alternativeUrls = new LinkedHashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar", List.of(
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://mirrors.huaweicloud.com/repository/maven/org/ow2/asm/asm/9.6/asm-9.6.jar"
));
if (type == LoaderType.NEOFORGE) {
alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar", List.of(
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar"
));
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar", List.of(
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar"
));
}
Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, List<String>> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
boolean downloaded = false;
for (String mirrorUrl : entry.getValue()) {
for (int attempt = 1; attempt <= 3; attempt++) {
try {
ZHttpClient.downloadFileWithSmartProxy(mirrorUrl, target);
downloaded = true;
break;
} catch (Exception e) {
if (attempt == 3 && mirrorUrl.equals(entry.getValue().get(entry.getValue().size() - 1))) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
Thread.sleep(2000);
}
}
if (downloaded) break;
}
}
}
ZHttpClient.repairMissingLibraries(instance.getPath());
}
}
@@ -3,24 +3,17 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
import java.io.*;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class NeoForgeInstaller {
private final Instance instance;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(30))
.build();
public NeoForgeInstaller(Instance instance) {
this.instance = instance;
@@ -109,47 +102,8 @@ public class NeoForgeInstaller {
}
private void downloadFileWithProgress(String url, Path target) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
try (InputStream in = response.body();
FileOutputStream out = new FileOutputStream(target.toFile())) {
byte[] buffer = new byte[8192];
int bytesRead;
long totalRead = 0;
int lastPercent = -1;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
if (contentLength > 0) {
int percent = (int) ((totalRead * 100) / contentLength);
if (percent != lastPercent) {
String downloaded = ProgressBar.formatBytes(totalRead);
String total = ProgressBar.formatBytes(contentLength);
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
lastPercent = percent;
}
} else {
char[] spinner = {'|', '/', '-', '\\'};
int idx = (int) (totalRead / 1024) % 4;
System.out.print("\rDownloading NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
}
}
}
ProgressBar.show("Downloading NeoForge Installer", 0, 100, "%");
ZHttpClient.downloadFileWithSmartProxy(url, target);
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
}
@@ -199,7 +153,12 @@ public class NeoForgeInstaller {
}
}
int exitCode = process.waitFor();
if (!process.waitFor(10, TimeUnit.MINUTES)) {
process.destroyForcibly();
System.out.println(ZAnsi.brightRed("NeoForge Installer timed out after 10 minutes"));
return false;
}
int exitCode = process.exitValue();
if (exitCode == 0 && !hasErrors) {
return true;
@@ -239,34 +198,6 @@ public class NeoForgeInstaller {
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
Map<String, String> alternativeUrls = new HashMap<>();
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar");
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar",
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar");
Path librariesDir = instance.getPath().resolve("libraries");
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
Path target = librariesDir.resolve(entry.getKey());
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
System.out.println(ZAnsi.yellow("Downloading: " + target.getFileName()));
for (int attempt = 1; attempt <= 3; attempt++) {
try {
downloadFileWithProgress(entry.getValue(), target);
break;
} catch (Exception e) {
if (attempt == 3) throw e;
System.out.println(ZAnsi.yellow("Retry " + attempt + "/3..."));
Thread.sleep(2000);
}
}
}
}
ZHttpClient.repairMissingLibraries(instance.getPath());
}
}
@@ -742,7 +742,12 @@ public class JFXLauncher extends Application {
Thread installThread = new Thread(() -> {
try {
boolean success = false;
if (!ZHttpClient.isNetworkInitialized()) {
log("Network init not finished, checking Mojang services synchronously...");
ZHttpClient.forceCheckMojangServices();
}
if ("zernmc".equalsIgnoreCase(loader)) {
log("[DEBUG] Starting zernmc pack install for version=" + version);
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5);
@@ -12,6 +12,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
@@ -22,6 +23,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public class ZHttpClient {
@@ -73,7 +75,7 @@ public class ZHttpClient {
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
private static final Map<ServiceType, Boolean> serviceHealthy = new ConcurrentHashMap<>();
private static final int MAX_FAILS_BEFORE_PROXY = 2;
private static final int MAX_FAILS_BEFORE_PROXY = 1;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000;
private static final long CHECK_TIMEOUT_MS = 7000;
@@ -240,7 +242,7 @@ public class ZHttpClient {
if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com"))
return ServiceType.MOJANG_META;
if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN;
if (url.contains("maven.minecraftforge.net") || url.contains("files.minecraftforge.net")) return ServiceType.FORGE_MAVEN;
if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
if (url.contains("google.com")) return ServiceType.GOOGLE;
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
@@ -256,7 +258,9 @@ public class ZHttpClient {
ServiceType service = detectService(url);
if (service == null || service.isAlwaysDirect()) return false;
return serviceProxyMode.getOrDefault(service, false);
if (serviceProxyMode.getOrDefault(service, false)) return true;
return serviceFailCount.getOrDefault(service, 0) >= MAX_FAILS_BEFORE_PROXY;
}
private static boolean isConnectionError(Throwable e) {
@@ -275,6 +279,16 @@ public class ZHttpClient {
msg.contains("abort");
}
/**
* Whether an error should be retried. Includes connection errors and transient
* server-side failures (5xx / 429) from the Zern proxy or upstream services.
*/
private static boolean isRetryableError(Throwable e) {
if (isConnectionError(e)) return true;
String msg = e.getMessage() != null ? e.getMessage().toLowerCase() : "";
return msg.contains("http 5") || msg.contains("http 429");
}
private static void markServiceAsBlocked(String url) {
ServiceType service = detectService(url);
if (service == null || service.isAlwaysDirect()) return;
@@ -307,40 +321,28 @@ public class ZHttpClient {
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
if (!shouldUseProxyForUrl(url)) {
int directRetries = 3;
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 30);
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 30);
if (response.statusCode() == 200) {
directSuccessCount++;
return response.body();
}
if (response.statusCode() >= 400) {
throw new IOException("HTTP " + response.statusCode());
}
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
if (directAttempt < directRetries) {
System.out.println(ZAnsi.yellow("[NET] Direct retry " + directAttempt + "/" + directRetries + " for " + url.substring(0, Math.min(60, url.length())) + "..."));
try { Thread.sleep(1000 * directAttempt); } catch (InterruptedException ie) { break; }
continue;
}
markServiceAsBlocked(url);
} else {
throw e;
}
if (response.statusCode() == 200) {
directSuccessCount++;
return response.body();
}
break;
} catch (Exception e) {
if (e instanceof IOException && isConnectionError(e)) {
directFailCount++;
markServiceAsBlocked(url);
} else if (!(e instanceof IOException)) {
throw e;
}
// any direct failure: the same resource is retried via the proxy below
}
}
@@ -367,7 +369,7 @@ public class ZHttpClient {
return response.body();
} catch (Exception e) {
if (attempt == maxRetries || !isConnectionError(e)) {
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
}
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
@@ -379,44 +381,36 @@ public class ZHttpClient {
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
if (!shouldUseProxyForUrl(url)) {
int directRetries = 3;
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET();
if (url.startsWith(BASE_URL)) {
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
}
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45);
if (response.statusCode() == 200) {
directSuccessCount++;
return;
}
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
if (directAttempt < directRetries) {
System.out.println(ZAnsi.yellow("[NET] Direct download retry " + directAttempt + "/" + directRetries + " for " + url.substring(0, Math.min(60, url.length())) + "..."));
try { Thread.sleep(1000 * directAttempt); } catch (InterruptedException ie) { break; }
continue;
}
markServiceAsBlocked(url);
} else {
throw e;
if (url.startsWith(BASE_URL)) {
String accessToken = AuthManager.getAccessToken();
if (accessToken != null && !accessToken.equals("0")) {
requestBuilder.header("Authorization", "Bearer " + accessToken);
}
}
break;
HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45);
if (response.statusCode() == 200) {
directSuccessCount++;
return;
}
} catch (Exception e) {
if (isConnectionError(e)) {
directFailCount++;
markServiceAsBlocked(url);
} else {
throw e;
}
// any direct failure: the same resource is retried via the proxy below
}
}
@@ -443,7 +437,7 @@ public class ZHttpClient {
return;
} catch (Exception e) {
if (attempt == maxRetries || !isConnectionError(e)) {
if (attempt == maxRetries || !isRetryableError(e)) {
throw new IOException("Proxy download failed: " + e.getMessage(), e);
}
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
@@ -451,6 +445,31 @@ public class ZHttpClient {
}
}
/**
* Retries a request against the Zern server. The server proxies are slow on cold cache
* and may return transient 502/5xx when upstreams are flaky, so a single failure should
* not immediately force a direct fallback.
*/
public static String getRetry(String endpoint, int maxAttempts) throws IOException, InterruptedException {
IOException last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return get(endpoint);
} catch (IOException e) {
last = e;
if (!isRetryableError(e)) throw e;
if (attempt < maxAttempts) {
System.out.println(ZAnsi.yellow("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint));
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw ie;
}
}
}
}
throw last;
}
public static String get(String endpoint) throws IOException, InterruptedException {
try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
@@ -496,7 +515,7 @@ public class ZHttpClient {
* The launcher's own API is reliably reachable, and the manifest is cached server-side.
*/
public static JSONObject getMojangVersionManifestViaServer() throws IOException, InterruptedException {
return new JSONObject(get("/proxy/mojang/version_manifest"));
return new JSONObject(getRetry("/proxy/mojang/version_manifest", 3));
}
/**
@@ -504,7 +523,7 @@ public class ZHttpClient {
* Avoids direct connections to piston-meta.mojang.com entirely.
*/
public static JSONObject getMojangVersionViaServer(String versionId) throws IOException, InterruptedException {
return new JSONObject(get("/proxy/mojang/version/" + URLEncoder.encode(versionId, StandardCharsets.UTF_8)));
return new JSONObject(getRetry("/proxy/mojang/version/" + URLEncoder.encode(versionId, StandardCharsets.UTF_8), 3));
}
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
@@ -543,6 +562,132 @@ public class ZHttpClient {
return getWithSmartProxy(url);
}
/**
* Scans every installed version JSON in the instance and downloads any library file
* that is missing. Used after loader installers run (their subprocess downloads can
* time out on restricted networks), so remaining libraries are pulled through the smart proxy.
*/
public static void repairMissingLibraries(Path minecraftDir) {
Path versionsDir = minecraftDir.resolve("versions");
if (!Files.isDirectory(versionsDir)) return;
try (Stream<Path> stream = Files.list(versionsDir)) {
stream.filter(Files::isDirectory).forEach(versionDir -> {
Path versionJson = versionDir.resolve(versionDir.getFileName() + ".json");
if (Files.exists(versionJson)) {
try {
repairLibrariesFromJson(versionJson, minecraftDir);
} catch (Exception e) {
System.out.println(ZAnsi.yellow("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage()));
}
}
});
} catch (IOException e) {
System.out.println(ZAnsi.yellow("[LIB] Repair scan failed: " + e.getMessage()));
}
}
/**
* Downloads every missing library listed in a version/loadermod JSON into minecraftDir/libraries.
* Supports both the Minecraft format (downloads.artifact.path/url) and the maven-coordinates
* format (name + base url) used by Fabric loadermod JSONs.
*/
public static void repairLibrariesFromJson(Path jsonPath, Path minecraftDir) throws Exception {
JSONObject root = new JSONObject(Files.readString(jsonPath));
if (!root.has("libraries")) return;
JSONArray libraries = root.getJSONArray("libraries");
int repaired = 0;
for (int i = 0; i < libraries.length(); i++) {
JSONObject lib = libraries.getJSONObject(i);
try {
if (lib.has("downloads") && lib.getJSONObject("downloads").has("artifact")) {
JSONObject art = lib.getJSONObject("downloads").getJSONObject("artifact");
String url = art.getString("url");
String path = art.getString("path");
Path target = minecraftDir.resolve("libraries").resolve(path);
if (!Files.exists(target)) {
Files.createDirectories(target.getParent());
downloadFileWithSmartProxy(url, target);
repaired++;
}
} else if (lib.has("name") && lib.has("url")) {
Path target = resolveCoordinatesTarget(lib);
if (target != null && !Files.exists(target)) {
Files.createDirectories(target.getParent());
downloadCoordinatesArtifact(lib, target);
repaired++;
}
}
} catch (Exception e) {
String libName = lib.optString("name", lib.optString("path", "?"));
System.out.println(ZAnsi.yellow("[LIB] Repair failed for " + libName + ": " + e.getMessage()));
}
}
if (repaired > 0) {
System.out.println(ZAnsi.green("[LIB] Repaired " + repaired + " missing libraries"));
}
}
private static String resolveCoordinatesUrl(JSONObject lib) {
String name = lib.getString("name");
String base = lib.getString("url");
String[] parts = name.split(":");
String group = parts[0];
String artifact = parts[1];
String version = parts[2];
String classifier = parts.length > 3 ? parts[3] : null;
String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/";
String fileName = artifact + "-" + version + (classifier != null ? "-" + classifier : "") + ".jar";
return base.endsWith("/") ? base + basePath + fileName : base + "/" + basePath + fileName;
}
private static String resolveCoordinatesUrlV2(JSONObject lib) {
String name = lib.getString("name");
String base = lib.getString("url");
String[] parts = name.split(":");
String group = parts[0];
String artifact = parts[1];
String version = parts[2];
String classifier = parts.length > 3 ? parts[3] : null;
String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/";
String fileName = artifact + "-" + version + "-v2" + (classifier != null ? "-" + classifier : "") + ".jar";
return base.endsWith("/") ? base + basePath + fileName : base + "/" + basePath + fileName;
}
private static void downloadCoordinatesArtifact(JSONObject lib, Path target) throws Exception {
IOException last = null;
for (String candidateUrl : List.of(resolveCoordinatesUrl(lib), resolveCoordinatesUrlV2(lib))) {
try {
downloadFileWithSmartProxy(candidateUrl, target);
return;
} catch (IOException e) {
last = e;
Files.deleteIfExists(target);
}
}
throw last != null ? last : new IOException("Could not resolve coordinates artifact: " + lib.optString("name", "?"));
}
private static Path resolveCoordinatesTarget(JSONObject lib) {
String name = lib.getString("name");
String[] parts = name.split(":");
if (parts.length < 3) return null;
String group = parts[0];
String artifact = parts[1];
String version = parts[2];
String classifier = parts.length > 3 ? parts[3] : null;
String basePath = group.replace('.', '/') + "/" + artifact + "/" + version + "/";
String fileName = artifact + "-" + version + (classifier != null ? "-" + classifier : "") + ".jar";
return Path.of(basePath, fileName);
}
private static List<String> parseFabricVersionsFromJson(String json) {
JSONArray array = new JSONArray(json);
List<String> versions = new ArrayList<>();
@@ -579,6 +724,10 @@ public class ZHttpClient {
return useProxyMode.get();
}
public static boolean isNetworkInitialized() {
return proxyTested.get();
}
public static Map<String, Object> getNetworkStatus() {
Map<String, Object> status = new HashMap<>();
status.put("initialized", proxyTested.get());
+1 -1
View File
@@ -19,7 +19,7 @@
<properties>
<revision>1.0.14</revision>
<hotfix>4</hotfix>
<hotfix>5</hotfix>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>