diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java index e5781f3..dc7014b 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java @@ -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"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java index 0d257d4..931c3c7 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ForgeInstaller.java @@ -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 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> 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> 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()); } } \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java index 65fc356..dcbb3ae 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java @@ -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> 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> 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()); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java index 1657aa7..ccf5836 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/NeoForgeInstaller.java @@ -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 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 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 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()); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index 3dcf791..31aa142 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -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); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index d775cf9..9761aa7 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -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 serviceLastCheckTime = new ConcurrentHashMap<>(); private static final Map 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 response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 30); + HttpResponse 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 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 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 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 parseFabricVersionsFromJson(String json) { JSONArray array = new JSONArray(json); List versions = new ArrayList<>(); @@ -579,6 +724,10 @@ public class ZHttpClient { return useProxyMode.get(); } + public static boolean isNetworkInitialized() { + return proxyTested.get(); + } + public static Map getNetworkStatus() { Map status = new HashMap<>(); status.put("initialized", proxyTested.get()); diff --git a/launcher/pom.xml b/launcher/pom.xml index a9497da..ac46ac1 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -19,7 +19,7 @@ 1.0.14 - 4 + 5 21 21 UTF-8