Слияние ui -> main #1

Merged
sasheg merged 145 commits from ui into main 2026-08-20 13:54:02 +00:00
3 changed files with 107 additions and 8 deletions
Showing only changes of commit 0ae1db3582 - Show all commits
@@ -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) { if (best == null) {
// nothing reachable: keep whatever is configured // nothing reachable: keep whatever is configured
System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain")); System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain"));
@@ -51,6 +51,24 @@ public class ZHttpClient {
private static String BASE_URL = "https://api.zern.cc"; 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<String> 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<String> extraProxyBases = new ArrayList<>();
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false); private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false); private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false); private static final AtomicBoolean healthThreadStarted = new AtomicBoolean(false);
@@ -59,6 +77,38 @@ public class ZHttpClient {
BASE_URL = url; 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<String> getProxyBases() {
List<String> 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() { public static String getBaseUrl() {
return BASE_URL; return BASE_URL;
} }
@@ -418,11 +468,15 @@ public class ZHttpClient {
} }
} }
List<String> proxyBases = getProxyBases();
int maxRetries = 3; int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) { 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 { try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); 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() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl)) .uri(URI.create(proxyUrl))
@@ -442,8 +496,10 @@ public class ZHttpClient {
} catch (Exception e) { } catch (Exception e) {
if (attempt == maxRetries || !isRetryableError(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; } 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 { 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)) { if (!shouldUseProxyForUrl(url)) {
try { try {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
@@ -469,12 +530,14 @@ public class ZHttpClient {
HttpRequest request = requestBuilder.build(); HttpRequest request = requestBuilder.build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 45); HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 45);
if (response.statusCode() == 200) { if (response.statusCode() == 200) {
moveIntoPlace(tmp, target);
directSuccessCount++; directSuccessCount++;
return; return;
} }
// non-200 response: fall through to proxy below
} catch (Exception e) { } catch (Exception e) {
if (isConnectionError(e)) { if (isConnectionError(e)) {
directFailCount++; directFailCount++;
@@ -486,11 +549,19 @@ public class ZHttpClient {
} }
} }
List<String> proxyBases = getProxyBases();
int maxRetries = 3; int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) { 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 { try {
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8); 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() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(proxyUrl)) .uri(URI.create(proxyUrl))
@@ -499,24 +570,45 @@ public class ZHttpClient {
.GET() .GET()
.build(); .build();
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 300); HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(tmp), 300);
if (response.statusCode() != 200) { if (response.statusCode() != 200) {
throw new IOException("Proxy download failed: HTTP " + response.statusCode()); throw new IOException("Proxy download failed: HTTP " + response.statusCode());
} }
moveIntoPlace(tmp, target);
proxySuccessCount++; proxySuccessCount++;
return; return;
} catch (Exception e) { } catch (Exception e) {
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {}
if (attempt == maxRetries || !isRetryableError(e)) { 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; } 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 * 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 * and may return transient 502/5xx when upstreams are flaky, so a single failure should
+2 -2
View File
@@ -19,8 +19,8 @@
</modules> </modules>
<properties> <properties>
<revision>1.0.16</revision> <revision>1.1.0</revision>
<hotfix>21</hotfix> <hotfix>0</hotfix>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>