Compare commits
3 Commits
4acbdedf70
...
a87a871e43
| Author | SHA1 | Date | |
|---|---|---|---|
| a87a871e43 | |||
| c51d441743 | |||
| 7235017493 |
+35
@@ -44,6 +44,8 @@ public class FabricInstaller {
|
|||||||
ProgressBar.finish("Fabric Installer downloaded");
|
ProgressBar.finish("Fabric Installer downloaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
|
System.out.println(ZAnsi.cyan("Running Fabric Installer..."));
|
||||||
|
|
||||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||||
@@ -85,6 +87,8 @@ public class FabricInstaller {
|
|||||||
|
|
||||||
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
|
ensureAssetIndexInFabricVersion(fabricVersionDir, assetIndex);
|
||||||
|
|
||||||
|
ZHttpClient.repairMissingLibraries(instancePath);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.brightRed("Fabric Installer ran, but version not found."));
|
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 {
|
private void ensureAssetIndexInFabricVersion(Path fabricVersionDir, String assetIndex) throws IOException {
|
||||||
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
|
Path versionJson = fabricVersionDir.resolve(fabricVersionDir.getFileName() + ".json");
|
||||||
|
|
||||||
|
|||||||
+22
-88
@@ -3,25 +3,16 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
import java.io.*;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.Arrays;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class ForgeInstaller {
|
public class ForgeInstaller {
|
||||||
|
|
||||||
private final Instance instance;
|
private final Instance instance;
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(java.time.Duration.ofSeconds(30))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
public ForgeInstaller(Instance instance) {
|
public ForgeInstaller(Instance instance) {
|
||||||
this.instance = instance;
|
this.instance = instance;
|
||||||
@@ -100,48 +91,8 @@ public class ForgeInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
ProgressBar.show("Downloading Forge Installer", 0, 100, "%");
|
||||||
.uri(URI.create(url))
|
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||||
.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.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
ProgressBar.finish("Forge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +147,23 @@ public class ForgeInstaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int exitCode = process.waitFor();
|
int exitCode;
|
||||||
|
if (hasErrors) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
exitCode = 1;
|
||||||
|
} else {
|
||||||
|
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
System.out.println(ZAnsi.brightRed("Forge Installer timed out after 10 minutes"));
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
attempt++;
|
||||||
|
Thread.sleep(5000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
exitCode = process.exitValue();
|
||||||
|
}
|
||||||
|
|
||||||
// If successful or no download errors
|
// If successful or no download errors
|
||||||
if (exitCode == 0 && !hasErrors) {
|
if (exitCode == 0 && !hasErrors) {
|
||||||
@@ -241,39 +208,6 @@ public class ForgeInstaller {
|
|||||||
|
|
||||||
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
|
private void downloadMissingLibraries(String mcVersion, String forgeVersion) throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||||
|
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-41
@@ -217,46 +217,6 @@ public class ModLoaderInstaller {
|
|||||||
|
|
||||||
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||||
|
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-80
@@ -3,24 +3,17 @@ package me.sashegdev.zernmc.launcher.minecraft.installer;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
import java.io.*;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.HashMap;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class NeoForgeInstaller {
|
public class NeoForgeInstaller {
|
||||||
|
|
||||||
private final Instance instance;
|
private final Instance instance;
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(java.time.Duration.ofSeconds(30))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
public NeoForgeInstaller(Instance instance) {
|
public NeoForgeInstaller(Instance instance) {
|
||||||
this.instance = instance;
|
this.instance = instance;
|
||||||
@@ -109,47 +102,8 @@ public class NeoForgeInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
ProgressBar.show("Downloading NeoForge Installer", 0, 100, "%");
|
||||||
.uri(URI.create(url))
|
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||||
.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.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(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) {
|
if (exitCode == 0 && !hasErrors) {
|
||||||
return true;
|
return true;
|
||||||
@@ -239,34 +198,6 @@ public class NeoForgeInstaller {
|
|||||||
|
|
||||||
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
System.out.println(ZAnsi.cyan("Checking and downloading missing libraries..."));
|
||||||
|
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-19
@@ -34,8 +34,8 @@ public class VersionInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<MinecraftVersion> getAvailableVersions() throws Exception {
|
public List<MinecraftVersion> getAvailableVersions() throws Exception {
|
||||||
String jsonString = ZHttpClient.getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json");
|
// Prefers Zern server Mojang proxy (reachable + cached), falls back to direct piston-meta
|
||||||
JSONObject root = new JSONObject(jsonString);
|
JSONObject root = ZHttpClient.getMojangVersionManifest();
|
||||||
JSONArray versionsArray = root.getJSONArray("versions");
|
JSONArray versionsArray = root.getJSONArray("versions");
|
||||||
|
|
||||||
List<MinecraftVersion> versions = new ArrayList<>();
|
List<MinecraftVersion> versions = new ArrayList<>();
|
||||||
@@ -61,18 +61,21 @@ public class VersionInstaller {
|
|||||||
Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
|
Path versionDir = minecraftDir.resolve("versions").resolve(versionId);
|
||||||
Files.createDirectories(versionDir);
|
Files.createDirectories(versionDir);
|
||||||
|
|
||||||
String versionUrl = getVersionUrl(versionId);
|
|
||||||
if (versionUrl == null) throw new Exception("Version " + versionId + " not found");
|
|
||||||
|
|
||||||
ProgressBar.show("Fetching version info", 0, 1, "files");
|
ProgressBar.show("Fetching version info", 0, 1, "files");
|
||||||
String versionJson;
|
String versionJson;
|
||||||
try {
|
try {
|
||||||
versionJson = ZHttpClient.getWithSmartProxy(versionUrl);
|
// Prefers Zern server Mojang proxy, falls back to direct piston-meta
|
||||||
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
versionJson = ZHttpClient.getMojangVersionJson(versionId).toString();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage()));
|
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage()));
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.red("[VERSION] Failed to write version info: " + e.getMessage()));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
ProgressBar.show("Version info", 1, 1, "files");
|
ProgressBar.show("Version info", 1, 1, "files");
|
||||||
|
|
||||||
JSONObject versionData = new JSONObject(versionJson);
|
JSONObject versionData = new JSONObject(versionJson);
|
||||||
@@ -300,22 +303,11 @@ public class VersionInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String getAssetIndexId(String versionId) throws Exception {
|
public String getAssetIndexId(String versionId) throws Exception {
|
||||||
String versionUrl = getVersionUrl(versionId);
|
JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId);
|
||||||
if (versionUrl == null) throw new Exception("Version not found");
|
|
||||||
|
|
||||||
String versionJson = ZHttpClient.getWithSmartProxy(versionUrl);
|
|
||||||
JSONObject versionData = new JSONObject(versionJson);
|
|
||||||
|
|
||||||
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
|
if (versionData.has("assetIndex") && versionData.getJSONObject("assetIndex").has("id")) {
|
||||||
return versionData.getJSONObject("assetIndex").getString("id");
|
return versionData.getJSONObject("assetIndex").getString("id");
|
||||||
}
|
}
|
||||||
return versionData.getString("assets");
|
return versionData.getString("assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getVersionUrl(String versionId) throws Exception {
|
|
||||||
for (MinecraftVersion v : getAvailableVersions()) {
|
|
||||||
if (v.getId().equals(versionId)) return v.getUrl();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ public class JFXLauncher extends Application {
|
|||||||
server.createContext("/api/launch", this::handleLaunch);
|
server.createContext("/api/launch", this::handleLaunch);
|
||||||
server.createContext("/api/install", this::handleInstall);
|
server.createContext("/api/install", this::handleInstall);
|
||||||
server.createContext("/api/install/progress", this::handleInstallProgress);
|
server.createContext("/api/install/progress", this::handleInstallProgress);
|
||||||
|
server.createContext("/api/network/status", this::handleNetworkStatus);
|
||||||
server.createContext("/api/logs", this::handleLogs);
|
server.createContext("/api/logs", this::handleLogs);
|
||||||
server.createContext("/api/logs/stream", this::handleLogsStream);
|
server.createContext("/api/logs/stream", this::handleLogsStream);
|
||||||
server.createContext("/api/game-logs", this::handleGameLogs);
|
server.createContext("/api/game-logs", this::handleGameLogs);
|
||||||
@@ -741,7 +742,12 @@ public class JFXLauncher extends Application {
|
|||||||
Thread installThread = new Thread(() -> {
|
Thread installThread = new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
boolean success = false;
|
boolean success = false;
|
||||||
|
|
||||||
|
if (!ZHttpClient.isNetworkInitialized()) {
|
||||||
|
log("Network init not finished, checking Mojang services synchronously...");
|
||||||
|
ZHttpClient.forceCheckMojangServices();
|
||||||
|
}
|
||||||
|
|
||||||
if ("zernmc".equalsIgnoreCase(loader)) {
|
if ("zernmc".equalsIgnoreCase(loader)) {
|
||||||
log("[DEBUG] Starting zernmc pack install for version=" + version);
|
log("[DEBUG] Starting zernmc pack install for version=" + version);
|
||||||
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5);
|
setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5);
|
||||||
@@ -842,6 +848,14 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleNetworkStatus(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", ZHttpClient.getNetworkStatus()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handleLogs(HttpExchange exchange) {
|
private void handleLogs(HttpExchange exchange) {
|
||||||
sendJson(exchange, Map.of("success", true, "data", getLauncherLogs()));
|
sendJson(exchange, Map.of("success", true, "data", getLauncherLogs()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public class ZHttpClient {
|
public class ZHttpClient {
|
||||||
|
|
||||||
@@ -33,6 +37,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
public static void setBaseUrl(String url) {
|
public static void setBaseUrl(String url) {
|
||||||
BASE_URL = url;
|
BASE_URL = url;
|
||||||
@@ -70,7 +75,7 @@ public class ZHttpClient {
|
|||||||
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
|
private static final Map<ServiceType, Long> serviceLastCheckTime = new ConcurrentHashMap<>();
|
||||||
private static final Map<ServiceType, Boolean> serviceHealthy = 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 HEALTH_CHECK_INTERVAL_MS = 60000;
|
||||||
private static final long CHECK_TIMEOUT_MS = 7000;
|
private static final long CHECK_TIMEOUT_MS = 7000;
|
||||||
|
|
||||||
@@ -136,8 +141,8 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
proxyTested.set(true);
|
proxyTested.set(true);
|
||||||
|
startHealthCheckThread();
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
startHealthCheckThread();
|
|
||||||
printStats();
|
printStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ public class ZHttpClient {
|
|||||||
.header("User-Agent", "ZernMC-Launcher/HealthCheck")
|
.header("User-Agent", "ZernMC-Launcher/HealthCheck")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 10);
|
||||||
int code = response.statusCode();
|
int code = response.statusCode();
|
||||||
return code == 200 || code == 404;
|
return code == 200 || code == 404;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -191,6 +196,8 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void startHealthCheckThread() {
|
private static void startHealthCheckThread() {
|
||||||
|
if (!healthThreadStarted.compareAndSet(false, true)) return;
|
||||||
|
|
||||||
Thread healthThread = new Thread(() -> {
|
Thread healthThread = new Thread(() -> {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -235,7 +242,7 @@ public class ZHttpClient {
|
|||||||
if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com"))
|
if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com"))
|
||||||
return ServiceType.MOJANG_META;
|
return ServiceType.MOJANG_META;
|
||||||
if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
|
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("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
|
||||||
if (url.contains("google.com")) return ServiceType.GOOGLE;
|
if (url.contains("google.com")) return ServiceType.GOOGLE;
|
||||||
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
|
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
|
||||||
@@ -251,7 +258,9 @@ public class ZHttpClient {
|
|||||||
ServiceType service = detectService(url);
|
ServiceType service = detectService(url);
|
||||||
if (service == null || service.isAlwaysDirect()) return false;
|
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) {
|
private static boolean isConnectionError(Throwable e) {
|
||||||
@@ -270,6 +279,16 @@ public class ZHttpClient {
|
|||||||
msg.contains("abort");
|
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) {
|
private static void markServiceAsBlocked(String url) {
|
||||||
ServiceType service = detectService(url);
|
ServiceType service = detectService(url);
|
||||||
if (service == null || service.isAlwaysDirect()) return;
|
if (service == null || service.isAlwaysDirect()) return;
|
||||||
@@ -284,42 +303,46 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static <T> HttpResponse<T> sendBounded(HttpRequest request, HttpResponse.BodyHandler<T> handler, long timeoutSeconds) throws IOException, InterruptedException {
|
||||||
|
try {
|
||||||
|
return client.sendAsync(request, handler).get(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw e;
|
||||||
|
} catch (java.util.concurrent.ExecutionException e) {
|
||||||
|
Throwable cause = e.getCause() != null ? e.getCause() : e;
|
||||||
|
if (cause instanceof IOException) throw (IOException) cause;
|
||||||
|
if (cause instanceof InterruptedException) throw (InterruptedException) cause;
|
||||||
|
throw new IOException(cause);
|
||||||
|
} catch (java.util.concurrent.TimeoutException e) {
|
||||||
|
throw new IOException("Request timed out after " + timeoutSeconds + "s", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
|
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
|
||||||
if (!shouldUseProxyForUrl(url)) {
|
if (!shouldUseProxyForUrl(url)) {
|
||||||
int directRetries = 3;
|
try {
|
||||||
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
try {
|
.uri(URI.create(url))
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
.timeout(Duration.ofSeconds(25))
|
||||||
.uri(URI.create(url))
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
.timeout(Duration.ofSeconds(25))
|
.GET()
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
.build();
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 30);
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
directSuccessCount++;
|
directSuccessCount++;
|
||||||
return response.body();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +359,7 @@ public class ZHttpClient {
|
|||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 45);
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
throw new IOException("Proxy HTTP " + response.statusCode());
|
throw new IOException("Proxy HTTP " + response.statusCode());
|
||||||
@@ -346,7 +369,7 @@ public class ZHttpClient {
|
|||||||
return response.body();
|
return response.body();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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);
|
throw new IOException("Failed to fetch data directly or via proxy: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
|
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
|
||||||
@@ -358,44 +381,36 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
||||||
if (!shouldUseProxyForUrl(url)) {
|
if (!shouldUseProxyForUrl(url)) {
|
||||||
int directRetries = 3;
|
try {
|
||||||
for (int directAttempt = 1; directAttempt <= directRetries; directAttempt++) {
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
try {
|
.uri(URI.create(url))
|
||||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
.timeout(Duration.ofSeconds(40))
|
||||||
.uri(URI.create(url))
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
.timeout(Duration.ofSeconds(40))
|
.GET();
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
|
||||||
.GET();
|
|
||||||
|
|
||||||
if (url.startsWith(BASE_URL)) {
|
if (url.startsWith(BASE_URL)) {
|
||||||
String accessToken = AuthManager.getAccessToken();
|
String accessToken = AuthManager.getAccessToken();
|
||||||
if (accessToken != null && !accessToken.equals("0")) {
|
if (accessToken != null && !accessToken.equals("0")) {
|
||||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpRequest request = requestBuilder.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +427,7 @@ public class ZHttpClient {
|
|||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
HttpResponse<Path> response = sendBounded(request, HttpResponse.BodyHandlers.ofFile(target), 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());
|
||||||
@@ -422,7 +437,7 @@ public class ZHttpClient {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (attempt == maxRetries || !isConnectionError(e)) {
|
if (attempt == maxRetries || !isRetryableError(e)) {
|
||||||
throw new IOException("Proxy download failed: " + e.getMessage(), e);
|
throw new IOException("Proxy download failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
|
try { Thread.sleep(1000 * attempt); } catch (InterruptedException ie) { break; }
|
||||||
@@ -430,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 {
|
public static String get(String endpoint) throws IOException, InterruptedException {
|
||||||
try {
|
try {
|
||||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
@@ -444,7 +484,7 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HttpRequest request = requestBuilder.build();
|
HttpRequest request = requestBuilder.build();
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 20);
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
throw new IOException("HTTP " + response.statusCode());
|
throw new IOException("HTTP " + response.statusCode());
|
||||||
@@ -462,22 +502,46 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException {
|
public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException {
|
||||||
String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
try {
|
||||||
String response = getWithSmartProxy(url);
|
return getMojangVersionManifestViaServer();
|
||||||
return new JSONObject(response);
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.yellow("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
|
||||||
|
return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the Mojang version manifest through the Zern server proxy.
|
||||||
|
* 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(getRetry("/proxy/mojang/version_manifest", 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single Mojang version JSON through the Zern server proxy.
|
||||||
|
* Avoids direct connections to piston-meta.mojang.com entirely.
|
||||||
|
*/
|
||||||
|
public static JSONObject getMojangVersionViaServer(String versionId) throws IOException, InterruptedException {
|
||||||
|
return new JSONObject(getRetry("/proxy/mojang/version/" + URLEncoder.encode(versionId, StandardCharsets.UTF_8), 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
|
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
|
||||||
JSONObject manifest = getMojangVersionManifest();
|
try {
|
||||||
JSONArray versions = manifest.getJSONArray("versions");
|
return getMojangVersionViaServer(versionId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.yellow("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
|
||||||
|
JSONObject manifest = getMojangVersionManifest();
|
||||||
|
JSONArray versions = manifest.getJSONArray("versions");
|
||||||
|
|
||||||
for (int i = 0; i < versions.length(); i++) {
|
for (int i = 0; i < versions.length(); i++) {
|
||||||
JSONObject v = versions.getJSONObject(i);
|
JSONObject v = versions.getJSONObject(i);
|
||||||
if (v.getString("id").equals(versionId)) {
|
if (v.getString("id").equals(versionId)) {
|
||||||
return new JSONObject(getWithSmartProxy(v.getString("url")));
|
return new JSONObject(getWithSmartProxy(v.getString("url")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
throw new IOException("Version " + versionId + " not found");
|
||||||
}
|
}
|
||||||
throw new IOException("Version " + versionId + " not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getForgeVersionsXml() throws IOException, InterruptedException {
|
public static String getForgeVersionsXml() throws IOException, InterruptedException {
|
||||||
@@ -498,6 +562,132 @@ public class ZHttpClient {
|
|||||||
return getWithSmartProxy(url);
|
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) {
|
private static List<String> parseFabricVersionsFromJson(String json) {
|
||||||
JSONArray array = new JSONArray(json);
|
JSONArray array = new JSONArray(json);
|
||||||
List<String> versions = new ArrayList<>();
|
List<String> versions = new ArrayList<>();
|
||||||
@@ -534,6 +724,33 @@ public class ZHttpClient {
|
|||||||
return useProxyMode.get();
|
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());
|
||||||
|
status.put("globalProxy", useProxyMode.get());
|
||||||
|
status.put("directSuccess", directSuccessCount);
|
||||||
|
status.put("directFail", directFailCount);
|
||||||
|
status.put("proxySuccess", proxySuccessCount);
|
||||||
|
|
||||||
|
List<Map<String, Object>> services = new ArrayList<>();
|
||||||
|
for (ServiceType type : ServiceType.values()) {
|
||||||
|
if (type.isAlwaysDirect()) continue;
|
||||||
|
Map<String, Object> s = new HashMap<>();
|
||||||
|
s.put("name", type.name());
|
||||||
|
s.put("baseUrl", type.getBaseUrl());
|
||||||
|
s.put("healthy", serviceHealthy.getOrDefault(type, false));
|
||||||
|
s.put("proxy", serviceProxyMode.getOrDefault(type, false));
|
||||||
|
s.put("fails", serviceFailCount.getOrDefault(type, 0));
|
||||||
|
services.add(s);
|
||||||
|
}
|
||||||
|
status.put("services", services);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
public static void printStats() {
|
public static void printStats() {
|
||||||
System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
|
System.out.println(ZAnsi.cyan("\n=== Network Stats ==="));
|
||||||
System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
|
System.out.println(ZAnsi.white("Global proxy: ") + (useProxyMode.get() ? "ON" : "OFF"));
|
||||||
|
|||||||
@@ -438,6 +438,15 @@
|
|||||||
<span class="setting-badge" id="server-status">Checking...</span>
|
<span class="setting-badge" id="server-status">Checking...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-card" id="network-card">
|
||||||
|
<div class="setting-info">
|
||||||
|
<h4 data-i18n="settings.network.title">Network</h4>
|
||||||
|
<p data-i18n="settings.network.desc">Per-service connection mode (direct / via proxy)</p>
|
||||||
|
</div>
|
||||||
|
<div class="setting-network-list" id="network-list">
|
||||||
|
<span class="setting-badge" data-i18n="settings.network.loading">Checking...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="setting-card">
|
<div class="setting-card">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<h4 data-i18n="settings.language.title">Language</h4>
|
<h4 data-i18n="settings.language.title">Language</h4>
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ const LOCALES = {
|
|||||||
'settings.server.checking': 'Checking...',
|
'settings.server.checking': 'Checking...',
|
||||||
'settings.server.connected': 'Connected',
|
'settings.server.connected': 'Connected',
|
||||||
'settings.server.disconnected': 'Disconnected',
|
'settings.server.disconnected': 'Disconnected',
|
||||||
|
'settings.network.title': 'Network',
|
||||||
|
'settings.network.desc': 'Per-service connection mode (direct / via proxy)',
|
||||||
|
'settings.network.direct': 'Direct',
|
||||||
|
'settings.network.proxy': 'Proxy',
|
||||||
|
'settings.network.down': 'Unavailable',
|
||||||
|
'settings.network.loading': 'Checking...',
|
||||||
|
'settings.network.unavailable': 'Not available',
|
||||||
'settings.language.title': 'Language',
|
'settings.language.title': 'Language',
|
||||||
'settings.language.desc': 'Interface language',
|
'settings.language.desc': 'Interface language',
|
||||||
'settings.systemJvm.title': 'System-based JVM Optimization',
|
'settings.systemJvm.title': 'System-based JVM Optimization',
|
||||||
@@ -222,6 +229,13 @@ const LOCALES = {
|
|||||||
'settings.server.checking': 'Проверка...',
|
'settings.server.checking': 'Проверка...',
|
||||||
'settings.server.connected': 'Подключено',
|
'settings.server.connected': 'Подключено',
|
||||||
'settings.server.disconnected': 'Отключено',
|
'settings.server.disconnected': 'Отключено',
|
||||||
|
'settings.network.title': 'Сеть',
|
||||||
|
'settings.network.desc': 'Режим подключения по сервисам (напрямую / через прокси)',
|
||||||
|
'settings.network.direct': 'Напрямую',
|
||||||
|
'settings.network.proxy': 'Прокси',
|
||||||
|
'settings.network.down': 'Недоступен',
|
||||||
|
'settings.network.loading': 'Проверка...',
|
||||||
|
'settings.network.unavailable': 'Недоступно',
|
||||||
'settings.language.title': 'Язык',
|
'settings.language.title': 'Язык',
|
||||||
'settings.language.desc': 'Язык интерфейса',
|
'settings.language.desc': 'Язык интерфейса',
|
||||||
'settings.systemJvm.title': 'Системная оптимизация JVM',
|
'settings.systemJvm.title': 'Системная оптимизация JVM',
|
||||||
@@ -759,6 +773,40 @@ class ZernMCLauncher {
|
|||||||
switchView(view) {
|
switchView(view) {
|
||||||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||||||
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view));
|
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === 'view-' + view));
|
||||||
|
if (view === 'settings') {
|
||||||
|
this.loadNetworkStatus();
|
||||||
|
if (!this.networkPoller) {
|
||||||
|
this.networkPoller = setInterval(() => this.loadNetworkStatus(), 15000);
|
||||||
|
}
|
||||||
|
} else if (this.networkPoller) {
|
||||||
|
clearInterval(this.networkPoller);
|
||||||
|
this.networkPoller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNetworkStatus() {
|
||||||
|
const list = document.getElementById('network-list');
|
||||||
|
if (!list) return;
|
||||||
|
try {
|
||||||
|
const r = await this.req('/network/status');
|
||||||
|
if (!r.success || !r.data) return;
|
||||||
|
const services = r.data.services || [];
|
||||||
|
if (!services.length) {
|
||||||
|
list.innerHTML = '<span class="setting-badge">' + t('settings.network.unavailable') + '</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
services.forEach(function(s) {
|
||||||
|
const state = s.proxy ? 'proxy' : (s.healthy ? 'direct' : 'down');
|
||||||
|
const labels = { direct: t('settings.network.direct'), proxy: t('settings.network.proxy'), down: t('settings.network.down') };
|
||||||
|
const colors = { direct: 'var(--success)', proxy: 'var(--warning)', down: 'var(--error)' };
|
||||||
|
html += '<div class="network-row">'
|
||||||
|
+ '<span class="network-name">' + s.name.toLowerCase() + '</span>'
|
||||||
|
+ '<span class="network-state" style="color:' + colors[state] + '">' + labels[state] + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
list.innerHTML = html;
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== INSTANCES ====================
|
// ==================== INSTANCES ====================
|
||||||
@@ -1860,7 +1908,8 @@ class ZernMCLauncher {
|
|||||||
stopProgressPoll() {
|
stopProgressPoll() {
|
||||||
if (this.progressPoller) {
|
if (this.progressPoller) {
|
||||||
clearInterval(this.progressPoller);
|
clearInterval(this.progressPoller);
|
||||||
this.progressPoller = null;
|
this.progressPoller = null;
|
||||||
|
this.networkPoller = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -527,6 +527,18 @@ body {
|
|||||||
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
|
background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
.setting-pass { display: flex; align-items: center; gap: 8px; }
|
.setting-pass { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.setting-network-list {
|
||||||
|
display: flex; flex-direction: column; gap: 6px; min-width: 260px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.network-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
font-size: 12px; padding: 4px 10px; border-radius: 4px;
|
||||||
|
background: var(--bg-surface); border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.network-name {
|
||||||
|
font-family: var(--mono); color: var(--text-secondary); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.network-state { font-weight: 600; }
|
||||||
.pass-input {
|
.pass-input {
|
||||||
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
|
width: 160px; padding: 6px 12px; border-radius: var(--radius-sm);
|
||||||
background: var(--bg-inset); border: 1px solid var(--border-light);
|
background: var(--bg-inset); border: 1px solid var(--border-light);
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>1.0.14</revision>
|
<revision>1.0.14</revision>
|
||||||
<hotfix>2</hotfix>
|
<hotfix>5</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user