diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java index f79029b..921850b 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java @@ -60,6 +60,13 @@ public final class DomainSelector { } } + if (best != null) { + // Make every candidate (incl. geo mirrors) available as a fallback proxy base. + for (String c : candidates) { + ZHttpClient.registerProxyBase(c); + } + } + if (best == null) { // nothing reachable: keep whatever is configured System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain")); 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 027fb5e..c47f6ce 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 @@ -51,6 +51,24 @@ public class ZHttpClient { private static String BASE_URL = "https://api.zern.cc"; + /** + * Alternate proxy endpoints (geo mirrors) used when BASE_URL's proxy fails with + * a connection error (e.g. CDN "Connection reset" from the current region). + * Stay in sync with LAUNCHER_MIRRORS in server/main.py. + */ + private static final List GEO_PROXY_BASES = List.of( + "https://api.zernmc.ru", + "https://api.zernmc.online", + "https://api.pl.zern.cc", + "https://api.swe.zern.cc" + ); + + /** + * Extra proxy bases discovered at runtime (e.g. mirrors fetched from + * /launcher/mirrors by DomainSelector). Kept in insertion order, deduplicated. + */ + private static final List extraProxyBases = new ArrayList<>(); + private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false); @@ -59,6 +77,38 @@ public class ZHttpClient { BASE_URL = url; } + /** + * Registers an additional proxy base URL (mirror/geo domain) that the smart + * proxy fallback may rotate through. No-op if already present. + */ + public static void registerProxyBase(String url) { + if (url == null || url.isBlank()) return; + String normalized = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + synchronized (extraProxyBases) { + if (!extraProxyBases.contains(normalized)) { + extraProxyBases.add(normalized); + } + } + } + + /** + * Ordered, deduplicated list of proxy endpoints to attempt: the primary + * BASE_URL first, then geo mirrors and any runtime-discovered mirrors. + */ + private static List getProxyBases() { + List bases = new ArrayList<>(); + bases.add(BASE_URL); + for (String geo : GEO_PROXY_BASES) { + if (!bases.contains(geo)) bases.add(geo); + } + synchronized (extraProxyBases) { + for (String m : extraProxyBases) { + if (!bases.contains(m)) bases.add(m); + } + } + return bases; + } + public static String getBaseUrl() { return BASE_URL; } @@ -418,11 +468,15 @@ public class ZHttpClient { } } + List proxyBases = getProxyBases(); int maxRetries = 3; for (int attempt = 1; attempt <= maxRetries; attempt++) { + // Rotate through every proxy base so one region's Connection Reset does not + // burn all retries on the same endpoint. + String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size()); try { String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); - String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; + String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(proxyUrl)) @@ -442,8 +496,10 @@ public class ZHttpClient { } catch (Exception e) { if (attempt == maxRetries || !isRetryableError(e)) { - throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e); + throw new IOException("Failed to fetch data directly or via proxy (" + proxyBase + "): " + e.getMessage(), e); } + LauncherLogger.warn("[NET] Proxy fetch attempt " + attempt + "/" + maxRetries + + " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base"); try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; } } } @@ -452,6 +508,11 @@ public class ZHttpClient { } public static void downloadFileWithSmartProxy(String url, Path target) throws Exception { + Path tmp = target.resolveSibling(target.getFileName() + ".part"); + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} + if (!shouldUseProxyForUrl(url)) { try { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() @@ -469,12 +530,14 @@ public class ZHttpClient { HttpRequest request = requestBuilder.build(); - HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45); + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45); if (response.statusCode() == 200) { + moveIntoPlace(tmp, target); directSuccessCount++; return; } + // non-200 response: fall through to proxy below } catch (Exception e) { if (isConnectionError(e)) { directFailCount++; @@ -486,11 +549,19 @@ public class ZHttpClient { } } + List proxyBases = getProxyBases(); int maxRetries = 3; for (int attempt = 1; attempt <= maxRetries; attempt++) { + // Rotate through every proxy base so one region's Connection Reset does not + // burn all retries on the same endpoint. + String proxyBase = proxyBases.get((attempt - 1) % proxyBases.size()); try { String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); - String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl; + String proxyUrl = proxyBase + "/proxy/download?url=" + encodedUrl; + + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(proxyUrl)) @@ -499,24 +570,45 @@ public class ZHttpClient { .GET() .build(); - HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 300); + HttpResponse response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300); if (response.statusCode() != 200) { throw new IOException("Proxy download failed: HTTP " + response.statusCode()); } + moveIntoPlace(tmp, target); proxySuccessCount++; return; } catch (Exception e) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) {} if (attempt == maxRetries || !isRetryableError(e)) { - throw new IOException("Proxy download failed: " + e.getMessage(), e); + throw new IOException("Proxy download failed (" + proxyBase + "): " + e.getMessage(), e); } + LauncherLogger.warn("[NET] Proxy download attempt " + attempt + "/" + maxRetries + + " via " + proxyBase + " failed (" + e.getMessage() + "), next proxy base"); try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; } } } } + /** + * Atomically replaces the destination with the fully-downloaded temp file. + * Prevents a Connection Reset mid-transfer from leaving a corrupt partial + * file at the final path that later "file exists" checks would treat as installed. + */ + private static void moveIntoPlace(Path tmp, Path target) throws IOException { + Files.createDirectories(target.getParent()); + try { + Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + /** * 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 diff --git a/launcher/pom.xml b/launcher/pom.xml index a5401cc..26429df 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -19,8 +19,8 @@ - 1.0.16 - 21 + 1.1.0 + 0 21 21 UTF-8