v1.0.16.13 — TSPU/SNI-obkhod: domain selection at login + dynamic mirrors
- add DomainSelector: probe every API candidate at launcher startup, pick
fastest reachable (primary api.zern.cc, legacy .ru/.online, geo pl/swe);
integrate into CLI start and JFX network init
- Config.setServerUrl() + persist chosen domain in launcher.properties
- ZHttpClient: default BASE_URL and ZERN_SERVER health-check follow the
selected domain (runtime failover instead of hardcoded api.zernmc.ru)
- server: LAUNCHER_MIRRORS main=api.zern.cc (primary), legacy .ru/.online,
geo-pl api.pl.zern.cc, geo-swe api.swe.zern.cc; /launcher/mirrors exposes them
- diag: check all TLD+geo API hosts (api.{pl,ru,swe}.zern.cc, .ru, .online)
for DNS/TCP/HTTP, known server IPs for all 4 geo nodes
- reverse-proxy geo boxes (pl, swe) -> main:1582 so clients bypass TSPU via
an unblocked SNI; ru left as-is (VLESS VPN box, not proxying API)
This commit is contained in:
@@ -20,3 +20,4 @@ packs/
|
|||||||
.__pycache__
|
.__pycache__
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.venvtodo.txt
|
.venvtodo.txt
|
||||||
|
*.bak
|
||||||
|
|||||||
@@ -77,6 +77,19 @@ public class Bootstrap {
|
|||||||
log("Local version: " + currentVersion);
|
log("Local version: " + currentVersion);
|
||||||
log("Server version: " + serverVersion);
|
log("Server version: " + serverVersion);
|
||||||
setVersionInfo(currentVersion, serverVersion);
|
setVersionInfo(currentVersion, serverVersion);
|
||||||
|
|
||||||
|
if ("unknown".equals(serverVersion)) {
|
||||||
|
boolean ranDiagnostics = runDiagnosticsIfPossible();
|
||||||
|
if (ranDiagnostics) {
|
||||||
|
serverVersion = getServerVersion();
|
||||||
|
log("Server version after diagnostics: " + serverVersion);
|
||||||
|
setVersionInfo(currentVersion, serverVersion);
|
||||||
|
}
|
||||||
|
if ("unknown".equals(serverVersion) && !argList.contains("--offline")) {
|
||||||
|
log("Server still unreachable. Launcher will start in offline mode.");
|
||||||
|
argList.add("--offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadMirrors();
|
loadMirrors();
|
||||||
log("Primary server: " + BASE_URL);
|
log("Primary server: " + BASE_URL);
|
||||||
@@ -124,6 +137,119 @@ public class Bootstrap {
|
|||||||
launchInNewProcess(args);
|
launchInNewProcess(args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean runDiagnosticsIfPossible() {
|
||||||
|
Path diagJar = binDir.resolve("zernmcdiag.jar");
|
||||||
|
if (!Files.exists(diagJar)) {
|
||||||
|
log("Diagnostics tool not found: bin/zernmcdiag.jar is missing.");
|
||||||
|
log("Please download the latest launcher manually from the ZernMC website.");
|
||||||
|
showMessageUi("ZernMC — нет связи с сервером",
|
||||||
|
"Не удалось связаться с серверами Zern.\n\n"
|
||||||
|
+ "Файл диагностики (bin/zernmcdiag.jar) отсутствует.\n"
|
||||||
|
+ "Скачайте свежий лаунчер вручную с официального сайта ZernMC.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean confirmed;
|
||||||
|
if (isCliMode) {
|
||||||
|
confirmed = askCliConfirm();
|
||||||
|
} else {
|
||||||
|
confirmed = confirmUi("ZernMC — нет связи с сервером",
|
||||||
|
"Не удалось связаться с серверами Zern.\nПровести диагностику сети?");
|
||||||
|
}
|
||||||
|
if (!confirmed) {
|
||||||
|
log("Diagnostics declined by user.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
launchDiag(diagJar);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("Failed to launch diagnostics: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchDiag(Path diagJar) throws Exception {
|
||||||
|
String os = System.getProperty("os.name").toLowerCase();
|
||||||
|
Path javaBin = findJava(false);
|
||||||
|
if (os.contains("windows")) {
|
||||||
|
Path javawPath = javaBin.resolveSibling("javaw.exe");
|
||||||
|
if (Files.exists(javawPath)) {
|
||||||
|
javaBin = javawPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(javaBin.toAbsolutePath().toString());
|
||||||
|
cmd.add("-Dfile.encoding=UTF-8");
|
||||||
|
cmd.add("-jar");
|
||||||
|
cmd.add(diagJar.toAbsolutePath().toString());
|
||||||
|
if (isCliMode) {
|
||||||
|
cmd.add("--cli");
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Starting diagnostics: " + String.join(" ", cmd));
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.redirectErrorStream(false);
|
||||||
|
|
||||||
|
Process p = pb.start();
|
||||||
|
drainOutput(p.getInputStream(), "diag");
|
||||||
|
drainOutput(p.getErrorStream(), "diag-err");
|
||||||
|
int exit = p.waitFor();
|
||||||
|
log("Diagnostics finished, exit code: " + exit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drainOutput(InputStream stream, String tag) {
|
||||||
|
Thread t = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
log("[" + tag + "] " + line);
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}, "diag-drain-" + tag);
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean askCliConfirm() {
|
||||||
|
System.out.println("Server unreachable. Run network diagnostics? [y/N]");
|
||||||
|
try {
|
||||||
|
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||||
|
String line = reader.readLine();
|
||||||
|
return line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean confirmUi(String title, String message) {
|
||||||
|
final boolean[] result = new boolean[1];
|
||||||
|
try {
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
int choice = JOptionPane.showConfirmDialog(null, message, title,
|
||||||
|
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||||
|
result[0] = choice == JOptionPane.YES_OPTION;
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("UI confirm error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void showMessageUi(String title, String message) {
|
||||||
|
try {
|
||||||
|
SwingUtilities.invokeAndWait(() ->
|
||||||
|
JOptionPane.showMessageDialog(null, message, title, JOptionPane.INFORMATION_MESSAGE));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("UI message error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void launchInProcess(String[] args) throws Exception {
|
private static void launchInProcess(String[] args) throws Exception {
|
||||||
ClassLoader parent = Bootstrap.class.getClassLoader();
|
ClassLoader parent = Bootstrap.class.getClassLoader();
|
||||||
@@ -171,6 +297,12 @@ public class Bootstrap {
|
|||||||
cmd.add("-jar");
|
cmd.add("-jar");
|
||||||
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
||||||
cmd.add("--jfx");
|
cmd.add("--jfx");
|
||||||
|
|
||||||
|
for (String arg : args) {
|
||||||
|
if (!"--jfx".equals(arg) && !"--cli".equals(arg) && !cmd.contains(arg)) {
|
||||||
|
cmd.add(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
pb.directory(baseDir.toFile());
|
pb.directory(baseDir.toFile());
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>me.sashegdev</groupId>
|
||||||
|
<artifactId>ZernMCLauncher</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>zernmc-diag</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>ZernMC Network Diagnostics</name>
|
||||||
|
<description>Standalone network diagnostics utility (DNS, connectivity, report upload)</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dnsjava</groupId>
|
||||||
|
<artifactId>dnsjava</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.json</groupId>
|
||||||
|
<artifactId>json</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.10.1</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outputFile>../../server/builds/bin/zernmcdiag.jar</outputFile>
|
||||||
|
<filters>
|
||||||
|
<filter>
|
||||||
|
<artifact>dnsjava:dnsjava</artifact>
|
||||||
|
<excludes>
|
||||||
|
<exclude>META-INF/services/java.net.spi.InetAddressResolverProvider</exclude>
|
||||||
|
<exclude>META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor</exclude>
|
||||||
|
</excludes>
|
||||||
|
</filter>
|
||||||
|
</filters>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>me.sashegdev.zernmc.launcher.diag.DiagMain</mainClass>
|
||||||
|
<manifestEntries>
|
||||||
|
<Implementation-Version>${project.version}.${hotfix}</Implementation-Version>
|
||||||
|
<Implementation-Title>ZernMC Network Diagnostics</Implementation-Title>
|
||||||
|
<Implementation-Vendor>SashegDev</Implementation-Vendor>
|
||||||
|
</manifestEntries>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
public record DiagCheck(String category, String name, String status, String detail, long latencyMs) {
|
||||||
|
|
||||||
|
public boolean ok() {
|
||||||
|
return "OK".equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String renderLine() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if ("OK".equals(status)) {
|
||||||
|
sb.append(" [OK] ");
|
||||||
|
} else if ("FAIL".equals(status)) {
|
||||||
|
sb.append(" [FAIL]");
|
||||||
|
} else {
|
||||||
|
sb.append(" [SKIP]");
|
||||||
|
}
|
||||||
|
sb.append(' ').append(category).append(" / ").append(name);
|
||||||
|
if (latencyMs >= 0) {
|
||||||
|
sb.append(" (").append(latencyMs).append(" ms)");
|
||||||
|
}
|
||||||
|
if (detail != null && !detail.isEmpty()) {
|
||||||
|
sb.append(" -> ").append(detail);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class DiagConstants {
|
||||||
|
|
||||||
|
public static final String HOST_API = "api.zern.cc";
|
||||||
|
|
||||||
|
public static final List<String> HOSTS = List.of(
|
||||||
|
HOST_API,
|
||||||
|
"api.zernmc.ru",
|
||||||
|
"api.zernmc.online",
|
||||||
|
"api.pl.zern.cc",
|
||||||
|
"api.swe.zern.cc",
|
||||||
|
"api.ru.zern.cc"
|
||||||
|
);
|
||||||
|
|
||||||
|
public static final List<String> KNOWN_SERVER_IPS = List.of(
|
||||||
|
"87.120.187.36", // main (Frankfurt)
|
||||||
|
"212.22.82.243", // ru geo (Moscow)
|
||||||
|
"2.26.5.202", // pl geo (Warsaw)
|
||||||
|
"2.26.55.218" // swe geo (Stockholm)
|
||||||
|
);
|
||||||
|
|
||||||
|
public static final String PRIMARY_API = "https://" + HOST_API;
|
||||||
|
public static final String DIRECT_API = "http://87.120.187.36:1582";
|
||||||
|
|
||||||
|
public static final List<Integer> TLS_PORTS = List.of(443, 80);
|
||||||
|
public static final int DIRECT_PORT = 1582;
|
||||||
|
|
||||||
|
public static final String VERSION_PATH = "/launcher/version";
|
||||||
|
public static final String CLIENT_IP_PATH = "/launcher/ip";
|
||||||
|
public static final String DIAG_UPLOAD_PATH = "/diag/upload";
|
||||||
|
|
||||||
|
public static final String DOH_GOOGLE_URL = "https://dns.google/resolve?name=%s&type=A";
|
||||||
|
public static final String DOH_CLOUDFLARE_URL = "https://cloudflare-dns.com/dns-query?name=%s&type=A";
|
||||||
|
|
||||||
|
public static final Map<String, String> DNS_PROVIDERS = Map.ofEntries(
|
||||||
|
Map.entry("Google 8.8.8.8", "8.8.8.8"),
|
||||||
|
Map.entry("Google 8.8.4.4", "8.8.4.4"),
|
||||||
|
Map.entry("Cloudflare 1.1.1.1", "1.1.1.1"),
|
||||||
|
Map.entry("Cloudflare 1.0.0.1", "1.0.0.1"),
|
||||||
|
Map.entry("Yandex 77.88.8.8", "77.88.8.8"),
|
||||||
|
Map.entry("Yandex 77.88.8.1", "77.88.8.1"),
|
||||||
|
Map.entry("OpenDNS 208.67.222.222", "208.67.222.222"),
|
||||||
|
Map.entry("Quad9 9.9.9.9", "9.9.9.9")
|
||||||
|
);
|
||||||
|
|
||||||
|
public static final int CONNECT_TIMEOUT_MS = 8000;
|
||||||
|
public static final int READ_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
private DiagConstants() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.awt.event.WindowAdapter;
|
||||||
|
import java.awt.event.WindowEvent;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.UIManager;
|
||||||
|
|
||||||
|
public final class DiagMain {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
List<String> argList = Arrays.asList(args);
|
||||||
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
Path baseDir = baseDirFromArgs(argList);
|
||||||
|
|
||||||
|
if (cliMode) {
|
||||||
|
runCli(baseDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
try {
|
||||||
|
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
DiagnosticsUI ui = new DiagnosticsUI(baseDir);
|
||||||
|
ui.addWindowListener(new WindowAdapter() {
|
||||||
|
@Override
|
||||||
|
public void windowClosed(WindowEvent e) {
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.setVisible(true);
|
||||||
|
ui.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path baseDirFromArgs(List<String> args) {
|
||||||
|
int idx = args.indexOf("--dir");
|
||||||
|
if (idx >= 0 && idx + 1 < args.size()) {
|
||||||
|
return Path.of(args.get(idx + 1)).toAbsolutePath();
|
||||||
|
}
|
||||||
|
return Path.of("").toAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runCli(Path baseDir) {
|
||||||
|
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir, System.out::println, null);
|
||||||
|
Path log = diag.run();
|
||||||
|
System.out.println("Diagnostics log: " + (log != null ? log.toAbsolutePath() : "n/a"));
|
||||||
|
System.out.println("Client IP: " + diag.getClientIp());
|
||||||
|
if (log != null) {
|
||||||
|
System.out.print("Send report to the Zern server? [y/N]: ");
|
||||||
|
System.out.flush();
|
||||||
|
try {
|
||||||
|
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||||
|
String line = reader.readLine();
|
||||||
|
if (line != null && (line.equalsIgnoreCase("y") || line.equalsIgnoreCase("yes"))) {
|
||||||
|
ReportSender.SendResult r = ReportSender.send(log, diag.getClientIp());
|
||||||
|
System.out.println("Send result: " + (r.ok() ? "OK via " + r.endpoint() : "FAILED (" + r.detail() + ")"));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("Send skipped: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int exitCode = 0;
|
||||||
|
for (DiagCheck c : diag.getChecks()) {
|
||||||
|
if ("FAIL".equals(c.status()) && c.category().equals("HTTP")) {
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.exit(exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Desktop;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.FlowLayout;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.GridLayout;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JFrame;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JOptionPane;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTextArea;
|
||||||
|
import javax.swing.SwingWorker;
|
||||||
|
import javax.swing.border.EmptyBorder;
|
||||||
|
|
||||||
|
public final class DiagnosticsUI extends JFrame {
|
||||||
|
|
||||||
|
private static final Color BG = new Color(0x15151a);
|
||||||
|
private static final Color PANEL = new Color(0x1e1e24);
|
||||||
|
private static final Color TEXT = new Color(0xe8e8ea);
|
||||||
|
private static final Color ACCENT = new Color(0x5dade2);
|
||||||
|
|
||||||
|
private final Path baseDir;
|
||||||
|
private final JTextArea output = new JTextArea();
|
||||||
|
private final JButton sendButton = new JButton("Отправить на сервер");
|
||||||
|
private final JLabel statusLabel = new JLabel("Запуск диагностики...");
|
||||||
|
private volatile Path diagLog;
|
||||||
|
private volatile String clientIp = "unknown";
|
||||||
|
|
||||||
|
public DiagnosticsUI(Path baseDir) {
|
||||||
|
this.baseDir = baseDir;
|
||||||
|
setTitle("ZernMC — Диагностика сети");
|
||||||
|
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||||
|
setSize(760, 560);
|
||||||
|
setMinimumSize(new Dimension(600, 420));
|
||||||
|
setLocationRelativeTo(null);
|
||||||
|
|
||||||
|
getContentPane().setBackground(BG);
|
||||||
|
((JPanel) getContentPane()).setBorder(new EmptyBorder(16, 16, 16, 16));
|
||||||
|
|
||||||
|
JLabel header = new JLabel("Проверка связи с серверами Zern");
|
||||||
|
header.setFont(new Font("Segoe UI", Font.BOLD, 17));
|
||||||
|
header.setForeground(ACCENT);
|
||||||
|
header.setBorder(new EmptyBorder(0, 0, 10, 0));
|
||||||
|
|
||||||
|
output.setEditable(false);
|
||||||
|
output.setFont(new Font("Consolas", Font.PLAIN, 12));
|
||||||
|
output.setBackground(PANEL);
|
||||||
|
output.setForeground(TEXT);
|
||||||
|
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||||
|
JScrollPane scroll = new JScrollPane(output);
|
||||||
|
scroll.setBorder(BorderFactory.createLineBorder(PANEL));
|
||||||
|
|
||||||
|
statusLabel.setForeground(TEXT);
|
||||||
|
statusLabel.setBorder(new EmptyBorder(8, 0, 0, 0));
|
||||||
|
|
||||||
|
JButton openButton = new JButton("Открыть папку с логом");
|
||||||
|
JButton closeButton = new JButton("Закрыть");
|
||||||
|
sendButton.setEnabled(false);
|
||||||
|
|
||||||
|
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 0));
|
||||||
|
buttons.setOpaque(false);
|
||||||
|
buttons.add(openButton);
|
||||||
|
buttons.add(sendButton);
|
||||||
|
buttons.add(closeButton);
|
||||||
|
|
||||||
|
JPanel south = new JPanel(new BorderLayout());
|
||||||
|
south.setOpaque(false);
|
||||||
|
south.add(statusLabel, BorderLayout.NORTH);
|
||||||
|
south.add(buttons, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
setLayout(new BorderLayout());
|
||||||
|
add(header, BorderLayout.NORTH);
|
||||||
|
add(scroll, BorderLayout.CENTER);
|
||||||
|
add(south, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
openButton.addActionListener(e -> openFolder());
|
||||||
|
closeButton.addActionListener(e -> dispose());
|
||||||
|
sendButton.addActionListener(e -> sendReport());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
SwingWorker<Void, String> worker = new SwingWorker<>() {
|
||||||
|
@Override
|
||||||
|
protected Void doInBackground() {
|
||||||
|
publish("\u2014 Сервер Zern недоступен, выполняем диагностику \u2014");
|
||||||
|
NetworkDiagnostics diag = new NetworkDiagnostics(baseDir,
|
||||||
|
this::publish,
|
||||||
|
c -> publish(renderCheck(c)));
|
||||||
|
diagLog = diag.run();
|
||||||
|
clientIp = diag.getClientIp();
|
||||||
|
publish("Диагностика завершена. Лог: " + (diagLog != null ? diagLog.toAbsolutePath() : "n/a"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void process(java.util.List<String> chunks) {
|
||||||
|
for (String line : chunks) {
|
||||||
|
appendLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void done() {
|
||||||
|
sendButton.setEnabled(diagLog != null);
|
||||||
|
statusLabel.setText("Готово. Можете отправить лог на сервер или сохранить его.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String renderCheck(DiagCheck c) {
|
||||||
|
String prefix = switch (c.status()) {
|
||||||
|
case "OK" -> "[OK] ";
|
||||||
|
case "FAIL" -> "[FAIL]";
|
||||||
|
default -> "[SKIP]";
|
||||||
|
};
|
||||||
|
StringBuilder sb = new StringBuilder(prefix).append(' ').append(c.category()).append(" / ").append(c.name());
|
||||||
|
if (c.latencyMs() >= 0) {
|
||||||
|
sb.append(" (").append(c.latencyMs()).append(" ms)");
|
||||||
|
}
|
||||||
|
if (c.detail() != null && !c.detail().isEmpty()) {
|
||||||
|
sb.append(" -> ").append(c.detail());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendLine(String line) {
|
||||||
|
output.append(line);
|
||||||
|
if (!line.endsWith("\n")) {
|
||||||
|
output.append("\n");
|
||||||
|
}
|
||||||
|
output.setCaretPosition(output.getDocument().getLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openFolder() {
|
||||||
|
try {
|
||||||
|
Path dir = diagLog != null ? diagLog.getParent() : baseDir;
|
||||||
|
if (dir != null && Files.exists(dir)) {
|
||||||
|
Desktop.getDesktop().open(dir.toFile());
|
||||||
|
} else {
|
||||||
|
JOptionPane.showMessageDialog(this, "Папка ещё не создана.");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
JOptionPane.showMessageDialog(this, "Не удалось открыть папку: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendReport() {
|
||||||
|
if (diagLog == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendButton.setEnabled(false);
|
||||||
|
statusLabel.setText("Отправка отчёта на сервер...");
|
||||||
|
SwingWorker<ReportSender.SendResult, Void> worker = new SwingWorker<>() {
|
||||||
|
@Override
|
||||||
|
protected ReportSender.SendResult doInBackground() throws Exception {
|
||||||
|
return ReportSender.send(diagLog, clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void done() {
|
||||||
|
sendButton.setEnabled(true);
|
||||||
|
try {
|
||||||
|
ReportSender.SendResult r = get();
|
||||||
|
if (r.ok()) {
|
||||||
|
statusLabel.setText("Отчёт отправлен: " + r.endpoint());
|
||||||
|
JOptionPane.showMessageDialog(DiagnosticsUI.this,
|
||||||
|
"Отчёт успешно отправлен на сервер Zern.",
|
||||||
|
"Отправлено", JOptionPane.INFORMATION_MESSAGE);
|
||||||
|
} else {
|
||||||
|
statusLabel.setText("Не удалось отправить отчёт.");
|
||||||
|
JOptionPane.showMessageDialog(DiagnosticsUI.this,
|
||||||
|
"Не удалось отправить отчёт на сервер.\n"
|
||||||
|
+ "Сохраните лог и отправьте его вручную:\n" + diagLog.toAbsolutePath()
|
||||||
|
+ "\n\nОшибка: " + r.detail(),
|
||||||
|
"Ошибка отправки", JOptionPane.WARNING_MESSAGE);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
statusLabel.setText("Ошибка отправки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.net.URLConnection;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.xbill.DNS.ARecord;
|
||||||
|
import org.xbill.DNS.Lookup;
|
||||||
|
import org.xbill.DNS.Record;
|
||||||
|
import org.xbill.DNS.SimpleResolver;
|
||||||
|
import org.xbill.DNS.Type;
|
||||||
|
|
||||||
|
public final class DnsResolver {
|
||||||
|
|
||||||
|
private DnsResolver() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> resolveSystem(String host) {
|
||||||
|
try {
|
||||||
|
InetAddress[] addrs = InetAddress.getAllByName(host);
|
||||||
|
List<String> ips = new ArrayList<>();
|
||||||
|
for (InetAddress a : addrs) {
|
||||||
|
String ip = a.getHostAddress();
|
||||||
|
if (ip != null && !ips.contains(ip)) {
|
||||||
|
ips.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> resolveDoHGoogle(String host) {
|
||||||
|
try {
|
||||||
|
String url = String.format(DiagConstants.DOH_GOOGLE_URL, host);
|
||||||
|
String body = doGet(url, "application/json");
|
||||||
|
JSONObject json = new JSONObject(body);
|
||||||
|
JSONArray answers = json.optJSONArray("Answer");
|
||||||
|
if (answers == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> ips = new ArrayList<>();
|
||||||
|
for (int i = 0; i < answers.length(); i++) {
|
||||||
|
JSONObject a = answers.optJSONObject(i);
|
||||||
|
if (a != null && a.optInt("type", 0) == 1) {
|
||||||
|
String data = a.optString("data", null);
|
||||||
|
if (data != null && !ips.contains(data)) {
|
||||||
|
ips.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> resolveDoHCloudflare(String host) {
|
||||||
|
try {
|
||||||
|
String url = String.format(DiagConstants.DOH_CLOUDFLARE_URL, host);
|
||||||
|
String body = doGet(url, "application/dns-json");
|
||||||
|
JSONObject json = new JSONObject(body);
|
||||||
|
JSONArray answers = json.optJSONArray("Answer");
|
||||||
|
if (answers == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> ips = new ArrayList<>();
|
||||||
|
for (int i = 0; i < answers.length(); i++) {
|
||||||
|
JSONObject a = answers.optJSONObject(i);
|
||||||
|
if (a != null && a.optInt("type", 0) == 1) {
|
||||||
|
String data = a.optString("data", null);
|
||||||
|
if (data != null && !ips.contains(data)) {
|
||||||
|
ips.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> resolveUdp(String providerIp, String host) {
|
||||||
|
try {
|
||||||
|
SimpleResolver resolver = new SimpleResolver(providerIp);
|
||||||
|
resolver.setTimeout(Duration.ofSeconds(5));
|
||||||
|
Lookup lookup = new Lookup(org.xbill.DNS.Name.fromString(host + "."), Type.A);
|
||||||
|
lookup.setResolver(resolver);
|
||||||
|
lookup.setCache(null);
|
||||||
|
Record[] records = lookup.run();
|
||||||
|
if (records == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> ips = new ArrayList<>();
|
||||||
|
for (Record r : records) {
|
||||||
|
if (r instanceof ARecord a) {
|
||||||
|
String ip = a.getAddress().getHostAddress();
|
||||||
|
if (ip != null && !ips.contains(ip)) {
|
||||||
|
ips.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String doGet(String url, String accept) throws Exception {
|
||||||
|
URLConnection conn = new URL(url).openConnection();
|
||||||
|
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
|
||||||
|
conn.setRequestProperty("Accept", accept);
|
||||||
|
conn.setRequestProperty("User-Agent", "ZernMC-Diag");
|
||||||
|
conn.connect();
|
||||||
|
byte[] buf;
|
||||||
|
try (var in = conn.getInputStream()) {
|
||||||
|
buf = in.readAllBytes();
|
||||||
|
}
|
||||||
|
return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public final class NetProbe {
|
||||||
|
|
||||||
|
public record TcpResult(boolean ok, long latencyMs, String detail) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record HttpResult(boolean ok, int status, long latencyMs, String bodySnippet, String detail) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private NetProbe() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TcpResult tcpConnect(String host, int port, int timeoutMs) {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
try (Socket socket = new Socket()) {
|
||||||
|
socket.connect(new InetSocketAddress(host, port), timeoutMs);
|
||||||
|
return new TcpResult(true, System.currentTimeMillis() - t0, host + ":" + port);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new TcpResult(false, System.currentTimeMillis() - t0, host + ":" + port + " -> " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpResult httpGet(String url, int timeoutMs) {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
|
||||||
|
conn.setConnectTimeout(timeoutMs);
|
||||||
|
conn.setReadTimeout(timeoutMs);
|
||||||
|
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
InputStream in = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
||||||
|
String snippet = "";
|
||||||
|
if (in != null) {
|
||||||
|
byte[] bytes = in.readAllBytes();
|
||||||
|
String body = new String(bytes, StandardCharsets.UTF_8).trim();
|
||||||
|
snippet = body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||||
|
}
|
||||||
|
boolean ok = status >= 200 && status < 400;
|
||||||
|
return new HttpResult(ok, status, System.currentTimeMillis() - t0, snippet, "HTTP " + status);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new HttpResult(false, 0, System.currentTimeMillis() - t0, "", e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (conn != null) {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
public final class NetworkDiagnostics {
|
||||||
|
|
||||||
|
private final Path baseDir;
|
||||||
|
private final Consumer<String> onLine;
|
||||||
|
private final Consumer<DiagCheck> onCheck;
|
||||||
|
private final List<DiagCheck> checks = new CopyOnWriteArrayList<>();
|
||||||
|
private final StringBuilder report = new StringBuilder();
|
||||||
|
private String clientIp = "unknown";
|
||||||
|
|
||||||
|
public NetworkDiagnostics(Path baseDir, Consumer<String> onLine, Consumer<DiagCheck> onCheck) {
|
||||||
|
this.baseDir = baseDir;
|
||||||
|
this.onLine = onLine;
|
||||||
|
this.onCheck = onCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DiagCheck> getChecks() {
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClientIp() {
|
||||||
|
return clientIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path run() {
|
||||||
|
checks.clear();
|
||||||
|
report.setLength(0);
|
||||||
|
|
||||||
|
report.append("=== ZernMC Network Diagnostics Report ===\n");
|
||||||
|
report.append("Timestamp: ").append(timestamp()).append('\n');
|
||||||
|
report.append("Base dir: ").append(baseDir.toAbsolutePath()).append('\n');
|
||||||
|
report.append("Zern server known IP(s): ").append(String.join(", ", DiagConstants.KNOWN_SERVER_IPS)).append('\n');
|
||||||
|
report.append("Primary API: ").append(DiagConstants.PRIMARY_API).append('\n');
|
||||||
|
report.append("Direct API: ").append(DiagConstants.DIRECT_API).append('\n');
|
||||||
|
|
||||||
|
runDnsChecks();
|
||||||
|
runConnectivityChecks();
|
||||||
|
runHttpChecks();
|
||||||
|
clientIp = determineClientIp();
|
||||||
|
|
||||||
|
int ok = 0, fail = 0, skip = 0;
|
||||||
|
for (DiagCheck c : checks) {
|
||||||
|
switch (c.status()) {
|
||||||
|
case "OK" -> ok++;
|
||||||
|
case "FAIL" -> fail++;
|
||||||
|
default -> skip++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report.append("=== Summary ===\n");
|
||||||
|
report.append("OK: ").append(ok).append(", FAIL: ").append(fail).append(", SKIP: ").append(skip).append('\n');
|
||||||
|
|
||||||
|
report.append("--- JSON ---\n");
|
||||||
|
JSONObject json = new JSONObject();
|
||||||
|
json.put("client_ip", clientIp);
|
||||||
|
json.put("timestamp", timestamp());
|
||||||
|
JSONObject jsonChecks = new JSONObject();
|
||||||
|
for (DiagCheck c : checks) {
|
||||||
|
JSONObject j = new JSONObject();
|
||||||
|
j.put("status", c.status());
|
||||||
|
j.put("detail", c.detail() == null ? "" : c.detail());
|
||||||
|
j.put("latency_ms", c.latencyMs());
|
||||||
|
jsonChecks.put(c.category() + "/" + c.name(), j);
|
||||||
|
}
|
||||||
|
json.put("checks", jsonChecks);
|
||||||
|
report.append(json.toString(2)).append('\n');
|
||||||
|
|
||||||
|
return writeLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path writeLog() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(baseDir);
|
||||||
|
Path log = baseDir.resolve("diag.log");
|
||||||
|
Files.writeString(log, report.toString(), StandardCharsets.UTF_8);
|
||||||
|
if (onLine != null) {
|
||||||
|
onLine.accept("\nDiagnostic log written to: " + log.toAbsolutePath());
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (onLine != null) {
|
||||||
|
onLine.accept("Failed to write diag.log: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runDnsChecks() {
|
||||||
|
report.append("\n=== DNS resolution ===\n");
|
||||||
|
for (String host : DiagConstants.HOSTS) {
|
||||||
|
report.append("Host: ").append(host).append('\n');
|
||||||
|
submit(resolveToCheck(host, "system", DnsResolver.resolveSystem(host)));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Callable<DiagCheck>> tasks = new ArrayList<>();
|
||||||
|
List<String> taskKeys = new ArrayList<>();
|
||||||
|
|
||||||
|
for (String host : DiagConstants.HOSTS) {
|
||||||
|
tasks.add(() -> resolveToCheck(host, "DoH Google", DnsResolver.resolveDoHGoogle(host)));
|
||||||
|
taskKeys.add("DoH Google/" + host);
|
||||||
|
tasks.add(() -> resolveToCheck(host, "DoH Cloudflare", DnsResolver.resolveDoHCloudflare(host)));
|
||||||
|
taskKeys.add("DoH Cloudflare/" + host);
|
||||||
|
for (String provider : DiagConstants.DNS_PROVIDERS.keySet()) {
|
||||||
|
String providerIp = DiagConstants.DNS_PROVIDERS.get(provider);
|
||||||
|
tasks.add(() -> resolveToCheck(host, "UDP " + provider, DnsResolver.resolveUdp(providerIp, host)));
|
||||||
|
taskKeys.add("UDP " + provider + "/" + host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(Math.min(8, tasks.size()), r -> {
|
||||||
|
Thread t = new Thread(r, "diag-dns");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
List<Future<DiagCheck>> futures = new ArrayList<>();
|
||||||
|
for (Callable<DiagCheck> task : tasks) {
|
||||||
|
futures.add(pool.submit(task));
|
||||||
|
}
|
||||||
|
int idx = 0;
|
||||||
|
for (Future<DiagCheck> f : futures) {
|
||||||
|
String key = taskKeys.get(idx);
|
||||||
|
try {
|
||||||
|
DiagCheck c = f.get(20, TimeUnit.SECONDS);
|
||||||
|
submit(c);
|
||||||
|
} catch (Exception e) {
|
||||||
|
submit(new DiagCheck("DNS", key, "FAIL", "query error: " + e.getMessage(), -1));
|
||||||
|
}
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pool.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DiagCheck resolveToCheck(String host, String source, List<String> ips) {
|
||||||
|
if (ips.isEmpty()) {
|
||||||
|
return new DiagCheck("DNS", host + " / " + source, "FAIL", "no A records", -1);
|
||||||
|
}
|
||||||
|
boolean matchesKnown = false;
|
||||||
|
for (String ip : ips) {
|
||||||
|
if (DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
|
||||||
|
matchesKnown = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchesKnown) {
|
||||||
|
return new DiagCheck("DNS", host + " / " + source, "OK", String.join(", ", ips), -1);
|
||||||
|
}
|
||||||
|
String detail = "resolved to " + String.join(", ", ips) + " (DIFFERENT from known " + String.join(", ", DiagConstants.KNOWN_SERVER_IPS) + ")";
|
||||||
|
return new DiagCheck("DNS", host + " / " + source, "FAIL", detail, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runConnectivityChecks() {
|
||||||
|
report.append("\n=== Connectivity ===\n");
|
||||||
|
for (String ip : DiagConstants.KNOWN_SERVER_IPS) {
|
||||||
|
for (int port : DiagConstants.TLS_PORTS) {
|
||||||
|
submit(tcpToCheck(ip, port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submit(tcpToCheck("87.120.187.36", DiagConstants.DIRECT_PORT));
|
||||||
|
|
||||||
|
List<String> systemIps = DnsResolver.resolveSystem(DiagConstants.HOST_API);
|
||||||
|
for (String ip : systemIps) {
|
||||||
|
if (!DiagConstants.KNOWN_SERVER_IPS.contains(ip)) {
|
||||||
|
submit(tcpToCheck(ip, 443));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagCheck tcpToCheck(String host, int port) {
|
||||||
|
NetProbe.TcpResult r = NetProbe.tcpConnect(host, port, DiagConstants.CONNECT_TIMEOUT_MS);
|
||||||
|
return new DiagCheck("TCP", host + ":" + port, r.ok() ? "OK" : "FAIL", r.detail(), r.latencyMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runHttpChecks() {
|
||||||
|
report.append("\n=== HTTP ===\n");
|
||||||
|
for (String host : DiagConstants.HOSTS) {
|
||||||
|
submit(httpToCheck("https://" + host + DiagConstants.VERSION_PATH));
|
||||||
|
}
|
||||||
|
submit(httpToCheck(DiagConstants.DIRECT_API + DiagConstants.VERSION_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagCheck httpToCheck(String url) {
|
||||||
|
NetProbe.HttpResult r = NetProbe.httpGet(url, DiagConstants.READ_TIMEOUT_MS);
|
||||||
|
String detail = r.ok() ? r.detail() + (r.bodySnippet().isEmpty() ? "" : " body=" + r.bodySnippet()) : r.detail();
|
||||||
|
return new DiagCheck("HTTP", url, r.ok() ? "OK" : "FAIL", detail, r.latencyMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String determineClientIp() {
|
||||||
|
String ip = null;
|
||||||
|
NetProbe.HttpResult direct = NetProbe.httpGet(DiagConstants.DIRECT_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
|
||||||
|
if (direct.ok()) {
|
||||||
|
ip = parseIp(direct.bodySnippet());
|
||||||
|
}
|
||||||
|
if (ip == null) {
|
||||||
|
NetProbe.HttpResult primary = NetProbe.httpGet(DiagConstants.PRIMARY_API + DiagConstants.CLIENT_IP_PATH, DiagConstants.READ_TIMEOUT_MS);
|
||||||
|
if (primary.ok()) {
|
||||||
|
ip = parseIp(primary.bodySnippet());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String result = ip != null ? ip : "unknown";
|
||||||
|
submit(new DiagCheck("INFO", "client public IP", ip != null ? "OK" : "FAIL", ip != null ? ip : "could not determine", -1));
|
||||||
|
report.append("=== Client IP ===\nClient public IP: ").append(result).append('\n');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String parseIp(String snippet) {
|
||||||
|
if (snippet == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int start = snippet.indexOf("\"ip\"");
|
||||||
|
if (start < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int colon = snippet.indexOf(':', start);
|
||||||
|
int q1 = snippet.indexOf('"', colon);
|
||||||
|
int q2 = snippet.indexOf('"', q1 + 1);
|
||||||
|
if (q1 < 0 || q2 < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String ip = snippet.substring(q1 + 1, q2);
|
||||||
|
return ip.matches("[\\d.]+") ? ip : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void submit(DiagCheck c) {
|
||||||
|
checks.add(c);
|
||||||
|
report.append(c.renderLine()).append('\n');
|
||||||
|
if (onCheck != null) {
|
||||||
|
onCheck.accept(c);
|
||||||
|
}
|
||||||
|
if (onLine != null) {
|
||||||
|
onLine.accept(c.renderLine());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String timestamp() {
|
||||||
|
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ReportSender {
|
||||||
|
|
||||||
|
public record SendResult(boolean ok, String endpoint, String detail) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReportSender() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String buildFilename(String clientIp) {
|
||||||
|
String ts = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
|
||||||
|
String ip = (clientIp == null || clientIp.isBlank()) ? "unknown" : clientIp;
|
||||||
|
return "diag_" + ts + "_" + ip + ".log";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SendResult send(Path logFile, String clientIp) throws IOException {
|
||||||
|
String content = Files.readString(logFile, StandardCharsets.UTF_8);
|
||||||
|
String name = buildFilename(clientIp);
|
||||||
|
String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
List<String> endpoints = List.of(
|
||||||
|
DiagConstants.PRIMARY_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded,
|
||||||
|
DiagConstants.DIRECT_API + DiagConstants.DIAG_UPLOAD_PATH + "?name=" + encoded
|
||||||
|
);
|
||||||
|
|
||||||
|
String lastDetail = null;
|
||||||
|
for (String endpoint : endpoints) {
|
||||||
|
try {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) URI.create(endpoint).toURL().openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setConnectTimeout(DiagConstants.CONNECT_TIMEOUT_MS);
|
||||||
|
conn.setReadTimeout(DiagConstants.READ_TIMEOUT_MS);
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestProperty("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
conn.setRequestProperty("User-Agent", "ZernMC-Diag/1.0");
|
||||||
|
byte[] body = content.getBytes(StandardCharsets.UTF_8);
|
||||||
|
conn.setFixedLengthStreamingMode(body.length);
|
||||||
|
try (OutputStream out = conn.getOutputStream()) {
|
||||||
|
out.write(body);
|
||||||
|
}
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
boolean ok = status >= 200 && status < 300;
|
||||||
|
String detail = "HTTP " + status;
|
||||||
|
conn.disconnect();
|
||||||
|
return new SendResult(ok, endpoint, detail);
|
||||||
|
} catch (Exception e) {
|
||||||
|
lastDetail = e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new SendResult(false, lastEndpointUsed(endpoints), lastDetail != null ? lastDetail : "all endpoints failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lastEndpointUsed(List<String> endpoints) {
|
||||||
|
return endpoints.get(endpoints.size() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class DiagCheckTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renderLine_ok() {
|
||||||
|
DiagCheck c = new DiagCheck("DNS", "api.zernmc.ru / system", "OK", "87.120.187.36", 12);
|
||||||
|
String line = c.renderLine();
|
||||||
|
assertTrue(line.contains("[OK]"));
|
||||||
|
assertTrue(line.contains("api.zernmc.ru / system"));
|
||||||
|
assertTrue(line.contains("12 ms"));
|
||||||
|
assertTrue(line.contains("87.120.187.36"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renderLine_fail() {
|
||||||
|
DiagCheck c = new DiagCheck("TCP", "1.2.3.4:443", "FAIL", "connection refused", -1);
|
||||||
|
String line = c.renderLine();
|
||||||
|
assertTrue(line.contains("[FAIL]"));
|
||||||
|
assertFalse(line.contains("ms)"));
|
||||||
|
assertTrue(line.contains("connection refused"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ok_returnsStatus() {
|
||||||
|
assertTrue(new DiagCheck("a", "b", "OK", null, -1).ok());
|
||||||
|
assertFalse(new DiagCheck("a", "b", "FAIL", null, -1).ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class DnsResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveToCheck_emptyIsFail() {
|
||||||
|
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "Google 8.8.8.8", List.of());
|
||||||
|
assertEquals("FAIL", c.status());
|
||||||
|
assertEquals("no A records", c.detail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveToCheck_knownIpIsOk() {
|
||||||
|
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("87.120.187.36"));
|
||||||
|
assertEquals("OK", c.status());
|
||||||
|
assertTrue(c.detail().contains("87.120.187.36"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveToCheck_unknownIpIsFail() {
|
||||||
|
DiagCheck c = NetworkDiagnostics.resolveToCheck("api.zernmc.ru", "system", List.of("1.2.3.4"));
|
||||||
|
assertEquals("FAIL", c.status());
|
||||||
|
assertTrue(c.detail().contains("DIFFERENT from known"));
|
||||||
|
assertTrue(c.detail().contains("1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveToCheck_multipleIpsOneKnown() {
|
||||||
|
DiagCheck c = NetworkDiagnostics.resolveToCheck("zern.cc", "system", List.of("1.2.3.4", "87.120.187.36"));
|
||||||
|
assertEquals("OK", c.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseIp_valid() {
|
||||||
|
assertEquals("1.2.3.4", NetworkDiagnostics.parseIp("{\"ip\":\"1.2.3.4\"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseIp_invalidReturnsNull() {
|
||||||
|
assertEquals(null, NetworkDiagnostics.parseIp("not json"));
|
||||||
|
assertEquals(null, NetworkDiagnostics.parseIp("{\"other\":\"x\"}"));
|
||||||
|
assertEquals(null, NetworkDiagnostics.parseIp(null));
|
||||||
|
assertEquals(null, NetworkDiagnostics.parseIp("{\"ip\":\"not-an-ip\"}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.diag;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ReportSenderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildFilename_containsTimestampAndIp() {
|
||||||
|
String name = ReportSender.buildFilename("1.2.3.4");
|
||||||
|
assertTrue(name.matches("diag_\\d{8}_\\d{6}_[\\d.]+[.]log"), name);
|
||||||
|
assertTrue(name.contains("1.2.3.4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildFilename_unknownIp() {
|
||||||
|
String name = ReportSender.buildFilename(null);
|
||||||
|
assertTrue(name.contains("unknown"), name);
|
||||||
|
assertTrue(name.matches("diag_\\d{8}_\\d{6}_unknown[.]log"), name);
|
||||||
|
String blank = ReportSender.buildFilename(" ");
|
||||||
|
assertTrue(blank.contains("unknown"), blank);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ public class Main {
|
|||||||
boolean jfxMode = argList.contains("--jfx");
|
boolean jfxMode = argList.contains("--jfx");
|
||||||
boolean cliMode = argList.contains("--cli");
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
|
||||||
|
if (argList.contains("--offline")) {
|
||||||
|
System.setProperty("zernmc.offline", "true");
|
||||||
|
LauncherLogger.info("Offline mode requested (--offline)");
|
||||||
|
}
|
||||||
|
|
||||||
if (jfxMode) {
|
if (jfxMode) {
|
||||||
launchJFX();
|
launchJFX();
|
||||||
return;
|
return;
|
||||||
@@ -65,6 +70,7 @@ public class Main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void startCLI() throws IOException {
|
private static void startCLI() throws IOException {
|
||||||
|
DomainSelector.selectBest();
|
||||||
ZHttpClient.checkAllServicesOnStartup(true);
|
ZHttpClient.checkAllServicesOnStartup(true);
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Checking authorization..."));
|
System.out.println(ZAnsi.cyan("Checking authorization..."));
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ public class LauncherAPI {
|
|||||||
}
|
}
|
||||||
return ApiResponse.success(mcVersions);
|
return ApiResponse.success(mcVersions);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[API] MC versions fetch failed: " + e.getMessage());
|
LauncherLogger.warn("[API] MC versions fetch failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
return ApiResponse.error("Failed to load Minecraft versions");
|
return ApiResponse.error("Failed to load Minecraft versions");
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ public class LauncherAPI {
|
|||||||
}
|
}
|
||||||
return ApiResponse.success(versions);
|
return ApiResponse.success(versions);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[API] Loader versions fetch failed: " + e.getMessage());
|
LauncherLogger.warn("[API] Loader versions fetch failed: " + e.getMessage());
|
||||||
return ApiResponse.error("Failed to load loader versions");
|
return ApiResponse.error("Failed to load loader versions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -26,10 +26,15 @@ public class AuthService {
|
|||||||
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
|
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String msg = e.getMessage();
|
String msg = e.getMessage();
|
||||||
if (msg != null && msg.contains("HTTP 409")) {
|
if (msg != null) {
|
||||||
return ApiResponse.error("Username already taken");
|
java.util.regex.Matcher m = java.util.regex.Pattern.compile("HTTP (\\d{3})").matcher(msg);
|
||||||
|
if (m.find()) {
|
||||||
|
int code = Integer.parseInt(m.group(1));
|
||||||
|
String body = msg.substring(m.end()).trim();
|
||||||
|
return ApiResponse.error(AuthManager.friendlyHttpError(code, body));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ApiResponse.error("Registration error: " + msg);
|
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +45,9 @@ public class AuthService {
|
|||||||
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||||
return ApiResponse.success(loginResult);
|
return ApiResponse.success(loginResult);
|
||||||
}
|
}
|
||||||
return ApiResponse.error(result.error != null ? result.error : "Invalid login or password");
|
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ApiResponse.error("Auth error: " + e.getMessage());
|
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-3
@@ -24,7 +24,7 @@ public class LaunchService {
|
|||||||
|
|
||||||
static {
|
static {
|
||||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||||
System.out.println("[LAUNCH] Shutting down all running processes...");
|
LauncherLogger.info("[LAUNCH] Shutting down all running processes...");
|
||||||
runningProcesses.values().forEach(p -> {
|
runningProcesses.values().forEach(p -> {
|
||||||
try {
|
try {
|
||||||
p.destroy();
|
p.destroy();
|
||||||
@@ -67,13 +67,44 @@ public class LaunchService {
|
|||||||
|
|
||||||
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
|
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
|
||||||
|
|
||||||
long t0 = System.currentTimeMillis();
|
|
||||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
|
||||||
LaunchOptions options = createOptions();
|
LaunchOptions options = createOptions();
|
||||||
options.setUsername(AuthManager.getUsername());
|
options.setUsername(AuthManager.getUsername());
|
||||||
options.setAccessToken(AuthManager.getAccessToken());
|
options.setAccessToken(AuthManager.getAccessToken());
|
||||||
options.setUuid(AuthManager.getUuid());
|
options.setUuid(AuthManager.getUuid());
|
||||||
|
|
||||||
|
return launchProcess(instance, instanceName, options);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LauncherLogger.error("Launch error for " + instanceName, e);
|
||||||
|
return ApiResponse.error("Launch error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<ProcessInfo> launchOffline(String instanceName, String nickname) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Pack not found: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherLogger.info("Offline launch: " + instanceName);
|
||||||
|
String safeName = (nickname == null || nickname.isBlank()) ? "Player" : nickname.trim();
|
||||||
|
LaunchOptions options = createOptions();
|
||||||
|
options.setUsername(safeName);
|
||||||
|
options.setAccessToken("0");
|
||||||
|
options.setUuid(java.util.UUID.nameUUIDFromBytes(
|
||||||
|
("OfflinePlayer:" + safeName).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString());
|
||||||
|
|
||||||
|
return launchProcess(instance, instanceName, options);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LauncherLogger.error("Offline launch error for " + instanceName, e);
|
||||||
|
return ApiResponse.error("Offline launch error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApiResponse<ProcessInfo> launchProcess(Instance instance, String instanceName, LaunchOptions options) {
|
||||||
|
try {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||||
List<String> command = builder.build(options);
|
List<String> command = builder.build(options);
|
||||||
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
|
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
|
||||||
LauncherLogger.info("Generated command for " + instanceName + ":");
|
LauncherLogger.info("Generated command for " + instanceName + ":");
|
||||||
|
|||||||
@@ -113,17 +113,62 @@ public class AuthManager {
|
|||||||
saveSession();
|
saveSession();
|
||||||
userInfo = fetchUserInfo();
|
userInfo = fetchUserInfo();
|
||||||
return AuthResult.ok();
|
return AuthResult.ok();
|
||||||
} else if (resp.statusCode() == 422) {
|
|
||||||
return AuthResult.fail("Validation error: " + extractError(resp.body()));
|
|
||||||
} else {
|
} else {
|
||||||
return AuthResult.fail(extractError(resp.body()));
|
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + e.getMessage());
|
||||||
return AuthResult.fail("Connection error: " + e.getMessage());
|
return AuthResult.fail(friendlyConnectionError(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps an HTTP status code + raw body to a short, human-readable error message
|
||||||
|
* (in Russian) suitable for showing directly to the user.
|
||||||
|
*/
|
||||||
|
public static String friendlyHttpError(int statusCode, String body) {
|
||||||
|
switch (statusCode) {
|
||||||
|
case 401:
|
||||||
|
case 403:
|
||||||
|
return "Неверный логин или пароль";
|
||||||
|
case 404:
|
||||||
|
return "Сервис временно недоступен, попробуйте позже";
|
||||||
|
case 409:
|
||||||
|
return "Логин уже занят";
|
||||||
|
case 422:
|
||||||
|
return "Некорректные данные: " + extractError(body);
|
||||||
|
case 429:
|
||||||
|
return "Слишком много попыток. Подождите немного.";
|
||||||
|
default:
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
return "Сервер временно недоступен, попробуйте позже";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String detail = extractError(body);
|
||||||
|
if (detail.isEmpty() || detail.startsWith("<")) {
|
||||||
|
return "Ошибка сервера (код " + statusCode + ")";
|
||||||
|
}
|
||||||
|
if (detail.length() > 120) {
|
||||||
|
detail = detail.substring(0, 117) + "...";
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a network/IO exception to a short, human-readable message.
|
||||||
|
*/
|
||||||
|
public static String friendlyConnectionError(Exception e) {
|
||||||
|
String m = e == null ? "" : String.valueOf(e.getMessage()).toLowerCase();
|
||||||
|
if (e instanceof java.net.SocketTimeoutException
|
||||||
|
|| e instanceof java.net.ConnectException
|
||||||
|
|| e instanceof java.net.UnknownHostException
|
||||||
|
|| m.contains("timeout") || m.contains("connect") || m.contains("unknownhost")
|
||||||
|
|| m.contains("refused")) {
|
||||||
|
return "Нет соединения с сервером. Проверьте интернет.";
|
||||||
|
}
|
||||||
|
return "Ошибка соединения с сервером. Попробуйте позже.";
|
||||||
|
}
|
||||||
|
|
||||||
public static void logout() {
|
public static void logout() {
|
||||||
if (session != null && session.refreshToken != null) {
|
if (session != null && session.refreshToken != null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package me.sashegdev.zernmc.launcher.minecraft;
|
package me.sashegdev.zernmc.launcher.minecraft;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -142,7 +144,7 @@ public class Instance {
|
|||||||
this.serverPackName = meta.serverPackName;
|
this.serverPackName = meta.serverPackName;
|
||||||
this.presetId = meta.presetId;
|
this.presetId = meta.presetId;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
|
LauncherLogger.warn("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
package me.sashegdev.zernmc.launcher.minecraft;
|
package me.sashegdev.zernmc.launcher.minecraft;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -51,7 +53,7 @@ public class InstanceManager {
|
|||||||
try {
|
try {
|
||||||
Files.deleteIfExists(path);
|
Files.deleteIfExists(path);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("Не удалось удалить: " + path);
|
LauncherLogger.warn("Не удалось удалить: " + path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+16
-17
@@ -71,12 +71,12 @@ public class MinecraftLib {
|
|||||||
* Stub - will be expanded
|
* Stub - will be expanded
|
||||||
*/
|
*/
|
||||||
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
|
public boolean installPack(String packName, String minecraftVersion, String loaderType, String loaderVersion) throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Starting full pack install: " + packName));
|
LauncherLogger.info("Starting full pack install: " + packName);
|
||||||
|
|
||||||
// 1. Install Minecraft
|
// 1. Install Minecraft
|
||||||
boolean mcInstalled = installMinecraft(minecraftVersion);
|
boolean mcInstalled = installMinecraft(minecraftVersion);
|
||||||
if (!mcInstalled) {
|
if (!mcInstalled) {
|
||||||
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
|
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,40 +84,39 @@ public class MinecraftLib {
|
|||||||
if ("fabric".equalsIgnoreCase(loaderType)) {
|
if ("fabric".equalsIgnoreCase(loaderType)) {
|
||||||
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
||||||
if (!fabricInstalled) {
|
if (!fabricInstalled) {
|
||||||
System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
|
LauncherLogger.error("Failed to install Fabric");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||||
if (!forgeInstalled) {
|
if (!forgeInstalled) {
|
||||||
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
|
LauncherLogger.error("Failed to install Forge");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||||
if (!neoforgeInstalled) {
|
if (!neoforgeInstalled) {
|
||||||
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
LauncherLogger.error("Failed to install NeoForge");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. In the future: diff and mod download
|
// 3. In the future: diff and mod download
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Basic pack install complete!"));
|
LauncherLogger.info("Basic pack install complete!");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Launch
|
//Launch
|
||||||
public void launch(LaunchOptions options) throws Exception {
|
public void launch(LaunchOptions options) throws Exception {
|
||||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
LauncherLogger.info("Launching pack: " + instance.getName());
|
||||||
cleanupOldLoaders();
|
cleanupOldLoaders();
|
||||||
validateJarFiles();
|
validateJarFiles();
|
||||||
|
|
||||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||||
List<String> command = builder.build(options);
|
List<String> command = builder.build(options);
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
|
LauncherLogger.info("Launch command (" + command.size() + " args) written below");
|
||||||
command.forEach(arg -> System.out.println(" " + arg));
|
|
||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(command);
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
pb.directory(instance.getPath().toFile());
|
pb.directory(instance.getPath().toFile());
|
||||||
@@ -126,12 +125,12 @@ public class MinecraftLib {
|
|||||||
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
|
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
|
||||||
try {
|
try {
|
||||||
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
|
Files.writeString(cmdLogFile, String.join(" \\\n ", command));
|
||||||
System.out.println(ZAnsi.green(" Launch command written to " + cmdLogFile.toAbsolutePath()));
|
LauncherLogger.info("Launch command written to " + cmdLogFile.toAbsolutePath());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow(" Failed to write launch command log: " + e.getMessage()));
|
LauncherLogger.warn("Failed to write launch command log: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("\nStarting Minecraft...\n"));
|
LauncherLogger.info("Starting Minecraft...");
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
|
|
||||||
Process process = pb.start();
|
Process process = pb.start();
|
||||||
@@ -168,7 +167,7 @@ public class MinecraftLib {
|
|||||||
outThread.join(1000);
|
outThread.join(1000);
|
||||||
errThread.join(1000);
|
errThread.join(1000);
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow("\nMinecraft exited with code: " + exitCode));
|
LauncherLogger.info("Minecraft exited with code: " + exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void safeDeleteDirectory(Path dir) {
|
private void safeDeleteDirectory(Path dir) {
|
||||||
@@ -215,7 +214,7 @@ public class MinecraftLib {
|
|||||||
|
|
||||||
if (currentLoaderVer == null) return;
|
if (currentLoaderVer == null) return;
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow("Cleaning old loader versions..."));
|
LauncherLogger.info("Cleaning old loader versions...");
|
||||||
|
|
||||||
// Delete all old fabric-loader / forge
|
// Delete all old fabric-loader / forge
|
||||||
Path libraries = instance.getPath().resolve("libraries");
|
Path libraries = instance.getPath().resolve("libraries");
|
||||||
@@ -247,14 +246,14 @@ public class MinecraftLib {
|
|||||||
.filter(p -> !isValidJar(p))
|
.filter(p -> !isValidJar(p))
|
||||||
.forEach(p -> {
|
.forEach(p -> {
|
||||||
try {
|
try {
|
||||||
System.out.println(ZAnsi.yellow(" Removing corrupt JAR: " + instance.getPath().relativize(p)));
|
LauncherLogger.warn("Removing corrupt JAR: " + instance.getPath().relativize(p));
|
||||||
Files.delete(p);
|
Files.delete(p);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.out.println(ZAnsi.red(" Failed to delete: " + p.getFileName()));
|
LauncherLogger.error("Failed to delete: " + p.getFileName());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow(" Error scanning for corrupt JARs: " + e.getMessage()));
|
LauncherLogger.warn("Error scanning for corrupt JARs: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-33
@@ -184,8 +184,7 @@ public class PackDownloader {
|
|||||||
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||||
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
|
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
|
LauncherLogger.error("[INSTALL] Failed to install Fabric");
|
||||||
LauncherLogger.error("installOrUpdatePack: Fabric install failed");
|
|
||||||
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
|
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -195,8 +194,7 @@ public class PackDownloader {
|
|||||||
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||||
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
|
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
LauncherLogger.error("[INSTALL] Failed to install NeoForge");
|
||||||
LauncherLogger.error("installOrUpdatePack: NeoForge install failed");
|
|
||||||
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
|
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -206,8 +204,7 @@ public class PackDownloader {
|
|||||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||||
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
|
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
|
LauncherLogger.error("[INSTALL] Failed to install Forge");
|
||||||
LauncherLogger.error("installOrUpdatePack: Forge install failed");
|
|
||||||
reportProgress("Failed to install Forge", 0, "error", 0, 1);
|
reportProgress("Failed to install Forge", 0, "error", 0, 1);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -217,16 +214,14 @@ public class PackDownloader {
|
|||||||
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
|
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
|
||||||
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
|
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
|
LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft");
|
||||||
LauncherLogger.error("installOrUpdatePack: Vanilla install failed");
|
|
||||||
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
|
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
|
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
|
||||||
} else {
|
} else {
|
||||||
LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping");
|
LauncherLogger.info("Minecraft already installed, skipping...");
|
||||||
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
|
reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5);
|
||||||
@@ -239,7 +234,7 @@ public class PackDownloader {
|
|||||||
// If pack has no files (vanilla/loader only), skip diff
|
// If pack has no files (vanilla/loader only), skip diff
|
||||||
if (manifest.files == null || manifest.files.isEmpty()) {
|
if (manifest.files == null || manifest.files.isEmpty()) {
|
||||||
LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff");
|
LauncherLogger.info("installOrUpdatePack: no files in manifest, skipping diff");
|
||||||
System.out.println(ZAnsi.green("Pack contains no additional files"));
|
LauncherLogger.info("Pack contains no additional files");
|
||||||
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
|
reportProgress("Installing...", 50, "Installing pack files", 4, 5);
|
||||||
|
|
||||||
saveUserFilesSnapshot(localFiles);
|
saveUserFilesSnapshot(localFiles);
|
||||||
@@ -253,7 +248,7 @@ public class PackDownloader {
|
|||||||
instance.setAssetIndex(resolveAssetIndex(manifest));
|
instance.setAssetIndex(resolveAssetIndex(manifest));
|
||||||
|
|
||||||
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
|
reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5);
|
||||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
LauncherLogger.info("Pack installed successfully!");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
|
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
|
||||||
@@ -302,7 +297,7 @@ public class PackDownloader {
|
|||||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||||
instance.setAssetIndex(resolveAssetIndex(manifest));
|
instance.setAssetIndex(resolveAssetIndex(manifest));
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
LauncherLogger.info("Pack installed successfully!");
|
||||||
LauncherLogger.info("installOrUpdatePack: SUCCESS");
|
LauncherLogger.info("installOrUpdatePack: SUCCESS");
|
||||||
} else {
|
} else {
|
||||||
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
|
LauncherLogger.error("installOrUpdatePack: FAILED - applyDiff returned false without exception");
|
||||||
@@ -332,17 +327,17 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean updatePack(String packName, String preset) throws Exception {
|
public boolean updatePack(String packName, String preset) throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
|
LauncherLogger.info("Checking updates for " + instance.getName() + "...");
|
||||||
|
|
||||||
PackManifest manifest = getPackManifest(packName);
|
PackManifest manifest = getPackManifest(packName);
|
||||||
int serverVersion = manifest.getVersion();
|
int serverVersion = manifest.getVersion();
|
||||||
|
|
||||||
if (serverVersion <= instance.getServerVersion()) {
|
if (serverVersion <= instance.getServerVersion()) {
|
||||||
System.out.println(ZAnsi.green("Pack is already up to date (v" + instance.getServerVersion() + ")"));
|
LauncherLogger.info("Pack is already up to date (v" + instance.getServerVersion() + ")");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion));
|
LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion);
|
||||||
|
|
||||||
// Scan local files
|
// Scan local files
|
||||||
Map<String, String> localFiles = scanLocalFiles();
|
Map<String, String> localFiles = scanLocalFiles();
|
||||||
@@ -351,7 +346,7 @@ public class PackDownloader {
|
|||||||
Set<String> protectedFiles = getUserModifiedFiles(localFiles);
|
Set<String> protectedFiles = getUserModifiedFiles(localFiles);
|
||||||
if (!protectedFiles.isEmpty()) {
|
if (!protectedFiles.isEmpty()) {
|
||||||
LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)");
|
LauncherLogger.info("[DEBUG] Found " + protectedFiles.size() + " user-modified files (will not overwrite)");
|
||||||
System.out.println(ZAnsi.yellow("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten"));
|
LauncherLogger.info("Found " + protectedFiles.size() + " user-modified files, they will not be overwritten");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get diff
|
// Get diff
|
||||||
@@ -366,7 +361,7 @@ public class PackDownloader {
|
|||||||
saveUserFilesSnapshot(updatedHashes);
|
saveUserFilesSnapshot(updatedHashes);
|
||||||
|
|
||||||
instance.setServerVersion(serverVersion);
|
instance.setServerVersion(serverVersion);
|
||||||
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
|
LauncherLogger.info("Pack updated to v" + serverVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
@@ -519,34 +514,30 @@ public class PackDownloader {
|
|||||||
|
|
||||||
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
|
private boolean applyDiff(DiffResponse diff, String packName, Set<String> protectedFiles, String preset) {
|
||||||
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
|
LauncherLogger.info("applyDiff: start. toDownload=" + diff.getToDownload().size() + " toDelete=" + diff.getToDelete().size() + " protected=" + protectedFiles.size());
|
||||||
System.out.println(ZAnsi.cyan("\nApplying changes:"));
|
LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files"
|
||||||
System.out.println(" Download: " + diff.getToDownload().size() + " files");
|
+ (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : ""));
|
||||||
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
|
|
||||||
if (!protectedFiles.isEmpty()) {
|
|
||||||
System.out.println(ZAnsi.yellow(" Protected: " + protectedFiles.size() + " user-modified files (skipped)"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create directories if needed
|
// Create directories if needed
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(instance.getPath());
|
Files.createDirectories(instance.getPath());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
|
LauncherLogger.error("Error creating directories: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete files (skip protected)
|
// Delete files (skip protected)
|
||||||
for (String filePath : diff.getToDelete()) {
|
for (String filePath : diff.getToDelete()) {
|
||||||
if (protectedFiles.contains(filePath)) {
|
if (protectedFiles.contains(filePath)) {
|
||||||
System.out.println(ZAnsi.yellow(" Skipped delete (user-modified): " + filePath));
|
LauncherLogger.info("Skipped delete (user-modified): " + filePath);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Path fullPath = instance.getPath().resolve(filePath);
|
Path fullPath = instance.getPath().resolve(filePath);
|
||||||
try {
|
try {
|
||||||
if (Files.deleteIfExists(fullPath)) {
|
if (Files.deleteIfExists(fullPath)) {
|
||||||
System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
|
LauncherLogger.info("Deleted: " + filePath);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println(ZAnsi.red(" Error deleting " + filePath + ": " + e.getMessage()));
|
LauncherLogger.error("Error deleting " + filePath + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,7 +556,7 @@ public class PackDownloader {
|
|||||||
|
|
||||||
if (protectedFiles.contains(path)) {
|
if (protectedFiles.contains(path)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
System.out.println(ZAnsi.yellow(" Skipped (user-modified): " + path));
|
LauncherLogger.info("Skipped (user-modified): " + path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +588,6 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
if (!hashMatch) {
|
if (!hashMatch) {
|
||||||
LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")");
|
LauncherLogger.warn("applyDiff: skipping file " + path + " due to hash mismatch (expected=" + file.getHash() + " got=" + actualHash + ")");
|
||||||
System.err.println(ZAnsi.yellow(" Skipped (hash mismatch): " + path));
|
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -642,8 +632,7 @@ public class PackDownloader {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage());
|
LauncherLogger.error("applyDiff: download FAILED for path=" + path + " error=" + e.getClass().getName() + ": " + e.getMessage());
|
||||||
System.err.println("\n" + ZAnsi.red(" Download error " + path + ": " + e.getMessage()));
|
LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file");
|
||||||
System.err.println(ZAnsi.yellow(" Skipping file " + path + " due to download error"));
|
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -653,7 +642,7 @@ public class PackDownloader {
|
|||||||
ProgressBar.finish("Download");
|
ProgressBar.finish("Download");
|
||||||
}
|
}
|
||||||
if (skipped > 0) {
|
if (skipped > 0) {
|
||||||
System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files"));
|
LauncherLogger.info("Skipped " + skipped + " user-modified files");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+47
-9
@@ -6,7 +6,9 @@ import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
|||||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -33,6 +35,8 @@ public class FabricInstaller {
|
|||||||
instance.setAssetIndex(assetIndex);
|
instance.setAssetIndex(assetIndex);
|
||||||
instance.setMinecraftVersion(minecraftVersion);
|
instance.setMinecraftVersion(minecraftVersion);
|
||||||
|
|
||||||
|
ProgressBar.showIndeterminate("Preparing Fabric installer");
|
||||||
|
|
||||||
String installerVersion = getLatestInstallerVersion();
|
String installerVersion = getLatestInstallerVersion();
|
||||||
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
|
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
|
||||||
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
|
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
|
||||||
@@ -40,7 +44,7 @@ public class FabricInstaller {
|
|||||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||||
|
|
||||||
if (!Files.exists(installerJar)) {
|
if (!Files.exists(installerJar)) {
|
||||||
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
|
ProgressBar.showIndeterminate("Downloading Fabric Installer");
|
||||||
downloadFileWithFallback(installerUrl, installerJar);
|
downloadFileWithFallback(installerUrl, installerJar);
|
||||||
ProgressBar.finish("Fabric Installer downloaded");
|
ProgressBar.finish("Fabric Installer downloaded");
|
||||||
}
|
}
|
||||||
@@ -48,6 +52,7 @@ public class FabricInstaller {
|
|||||||
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
|
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
|
||||||
|
|
||||||
LauncherLogger.info("Running Fabric Installer...");
|
LauncherLogger.info("Running Fabric Installer...");
|
||||||
|
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
|
||||||
|
|
||||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||||
|
|
||||||
@@ -60,17 +65,50 @@ public class FabricInstaller {
|
|||||||
"-noprofile"
|
"-noprofile"
|
||||||
);
|
);
|
||||||
|
|
||||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
pb.redirectErrorStream(true);
|
||||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
|
|
||||||
Process process = pb.start();
|
Process process = pb.start();
|
||||||
boolean finished = process.waitFor(10, TimeUnit.MINUTES);
|
|
||||||
if (!finished) {
|
boolean hasErrors = false;
|
||||||
process.destroyForcibly();
|
|
||||||
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
return false;
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.contains("Downloading") || line.contains("Extracting")
|
||||||
|
|| line.contains("Installing") || line.contains("Creating")
|
||||||
|
|| line.contains("SUCCESS") || line.contains("successfully")) {
|
||||||
|
ProgressBar.showIndeterminate("Fabric Installer: " + ProgressBar.cleanLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||||
|
LauncherLogger.info(" -> " + line);
|
||||||
|
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||||
|
LauncherLogger.info(" + " + line);
|
||||||
|
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||||
|
LauncherLogger.warn(" ! " + line);
|
||||||
|
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||||
|
LauncherLogger.error(" X " + line);
|
||||||
|
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
} else if (!line.isBlank()) {
|
||||||
|
LauncherLogger.info(" " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int exitCode;
|
||||||
|
if (hasErrors) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
exitCode = 1;
|
||||||
|
} else {
|
||||||
|
if (!process.waitFor(10, TimeUnit.MINUTES)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
LauncherLogger.error("Fabric Installer timed out after 10 minutes");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
exitCode = process.exitValue();
|
||||||
}
|
}
|
||||||
int exitCode = process.exitValue();
|
|
||||||
|
|
||||||
if (exitCode != 0) {
|
if (exitCode != 0) {
|
||||||
LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")");
|
LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")");
|
||||||
|
|||||||
+11
@@ -3,6 +3,7 @@ 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.JavaResolver;
|
import me.sashegdev.zernmc.launcher.utils.JavaResolver;
|
||||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
@@ -57,15 +58,19 @@ public class ModLoaderInstaller {
|
|||||||
instance.setAssetIndex(assetIndex);
|
instance.setAssetIndex(assetIndex);
|
||||||
createLauncherProfile();
|
createLauncherProfile();
|
||||||
|
|
||||||
|
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
|
||||||
|
|
||||||
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
|
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
|
||||||
String jarName = type.loaderName + "-installer.jar";
|
String jarName = type.loaderName + "-installer.jar";
|
||||||
Path installerJar = instance.getPath().resolve(jarName);
|
Path installerJar = instance.getPath().resolve(jarName);
|
||||||
|
|
||||||
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
|
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
|
||||||
|
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
|
||||||
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
|
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
|
||||||
|
|
||||||
LauncherLogger.info("Running " + type.loaderName + " Installer...");
|
LauncherLogger.info("Running " + type.loaderName + " Installer...");
|
||||||
LauncherLogger.info("This may take a few minutes. Please wait...");
|
LauncherLogger.info("This may take a few minutes. Please wait...");
|
||||||
|
ProgressBar.showIndeterminate("Running " + type.loaderName + " Installer (may take a few minutes)");
|
||||||
|
|
||||||
boolean success = runInstaller(installerJar, type);
|
boolean success = runInstaller(installerJar, type);
|
||||||
|
|
||||||
@@ -145,6 +150,11 @@ public class ModLoaderInstaller {
|
|||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
output.append(line).append("\n");
|
output.append(line).append("\n");
|
||||||
|
|
||||||
|
if (line.contains("Downloading") || line.contains("Extracting")
|
||||||
|
|| line.contains("Installing") || line.contains("Creating")
|
||||||
|
|| line.contains("SUCCESS") || line.contains("successfully")) {
|
||||||
|
ProgressBar.showIndeterminate(type.loaderName + " Installer: " + ProgressBar.cleanLine(line));
|
||||||
|
}
|
||||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||||
LauncherLogger.info(" -> " + line);
|
LauncherLogger.info(" -> " + line);
|
||||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||||
@@ -218,6 +228,7 @@ public class ModLoaderInstaller {
|
|||||||
|
|
||||||
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
||||||
LauncherLogger.info("Checking and downloading missing libraries...");
|
LauncherLogger.info("Checking and downloading missing libraries...");
|
||||||
|
ProgressBar.showIndeterminate("Downloading missing libraries");
|
||||||
ZHttpClient.repairMissingLibraries(instance.getPath());
|
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-6
@@ -86,15 +86,17 @@ public class VersionInstaller {
|
|||||||
String versionJson;
|
String versionJson;
|
||||||
try {
|
try {
|
||||||
// Prefers Zern server Mojang proxy, falls back to direct piston-meta
|
// Prefers Zern server Mojang proxy, falls back to direct piston-meta
|
||||||
|
LauncherLogger.info("[VERSION] Fetching version json for " + versionId + " (server proxy, then direct piston-meta)");
|
||||||
versionJson = ZHttpClient.getMojangVersionJson(versionId).toString();
|
versionJson = ZHttpClient.getMojangVersionJson(versionId).toString();
|
||||||
|
LauncherLogger.info("[VERSION] Version json fetched for " + versionId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.red("[VERSION] Failed to fetch version info: " + e.getMessage()));
|
LauncherLogger.error("[VERSION] Failed to fetch version info: " + e.getMessage());
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.red("[VERSION] Failed to write version info: " + e.getMessage()));
|
LauncherLogger.error("[VERSION] Failed to write version info: " + e.getMessage());
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
ProgressBar.show("Version info", 1, 1, "files");
|
ProgressBar.show("Version info", 1, 1, "files");
|
||||||
@@ -103,16 +105,20 @@ public class VersionInstaller {
|
|||||||
|
|
||||||
// client.jar
|
// client.jar
|
||||||
ProgressBar.show("Downloading client.jar", 0, 1, "files");
|
ProgressBar.show("Downloading client.jar", 0, 1, "files");
|
||||||
|
LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "...");
|
||||||
Path clientJar = versionDir.resolve(versionId + ".jar");
|
Path clientJar = versionDir.resolve(versionId + ".jar");
|
||||||
if (!Files.exists(clientJar)) {
|
if (!Files.exists(clientJar)) {
|
||||||
try {
|
try {
|
||||||
ZHttpClient.downloadFileWithSmartProxy(
|
ZHttpClient.downloadFileWithSmartProxy(
|
||||||
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
|
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
|
||||||
clientJar);
|
clientJar);
|
||||||
|
LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.red("[VERSION] Failed to download client.jar: " + e.getMessage()));
|
LauncherLogger.error("[VERSION] Failed to download client.jar: " + e.getMessage());
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
LauncherLogger.info("[VERSION] client.jar already present for " + versionId);
|
||||||
}
|
}
|
||||||
ProgressBar.show("Client.jar", 1, 1, "files");
|
ProgressBar.show("Client.jar", 1, 1, "files");
|
||||||
|
|
||||||
@@ -241,7 +247,7 @@ public class VersionInstaller {
|
|||||||
try {
|
try {
|
||||||
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.yellow("[LIB] Failed to download " + path + ": " + e.getMessage()));
|
LauncherLogger.warn("[LIB] Failed to download " + path + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
count++;
|
count++;
|
||||||
@@ -314,7 +320,7 @@ public class VersionInstaller {
|
|||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
failed[0]++;
|
failed[0]++;
|
||||||
}
|
}
|
||||||
System.err.println("Failed to download " + hash);
|
LauncherLogger.error("[VERSION] Failed to download asset " + hash);
|
||||||
} else {
|
} else {
|
||||||
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
|
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
|
||||||
}
|
}
|
||||||
@@ -330,7 +336,7 @@ public class VersionInstaller {
|
|||||||
.get(10, TimeUnit.MINUTES);
|
.get(10, TimeUnit.MINUTES);
|
||||||
} catch (TimeoutException e) {
|
} catch (TimeoutException e) {
|
||||||
futures.forEach(f -> f.cancel(true));
|
futures.forEach(f -> f.cancel(true));
|
||||||
System.err.println("Asset download timed out after 10 minutes");
|
LauncherLogger.error("[VERSION] Asset download timed out after 10 minutes");
|
||||||
} catch (CancellationException e) {
|
} catch (CancellationException e) {
|
||||||
// one of the futures was cancelled
|
// one of the futures was cancelled
|
||||||
} catch (CompletionException e) {
|
} catch (CompletionException e) {
|
||||||
|
|||||||
+47
-46
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.minecraft.launch;
|
|||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ public class LaunchCommandBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<String> build(LaunchOptions options) throws Exception {
|
public List<String> build(LaunchOptions options) throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
|
LauncherLogger.info(ZAnsi.cyan("Generating launch command for " + instance.getName() + "..."));
|
||||||
|
|
||||||
List<String> command = new ArrayList<>();
|
List<String> command = new ArrayList<>();
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ public class LaunchCommandBuilder {
|
|||||||
// with placeholder substitution. Do NOT manually add -p, -cp,
|
// with placeholder substitution. Do NOT manually add -p, -cp,
|
||||||
// --add-modules, --add-opens, or -DignoreList. The version.json
|
// --add-modules, --add-opens, or -DignoreList. The version.json
|
||||||
// defines all of these. This matches AstralRinth's approach.
|
// defines all of these. This matches AstralRinth's approach.
|
||||||
System.out.println(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
|
LauncherLogger.info(ZAnsi.cyan(" Forge/NeoForge: using version.json args with placeholder substitution"));
|
||||||
|
|
||||||
// Ensure version jar exists at versions/<versionId>/<versionId>.jar
|
// Ensure version jar exists at versions/<versionId>/<versionId>.jar
|
||||||
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name)
|
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name)
|
||||||
@@ -92,9 +93,9 @@ public class LaunchCommandBuilder {
|
|||||||
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
|
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
|
||||||
if (referencesClasspath) {
|
if (referencesClasspath) {
|
||||||
vars.put("classpath", writeClasspathFile(fullClasspath));
|
vars.put("classpath", writeClasspathFile(fullClasspath));
|
||||||
System.out.println(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
|
LauncherLogger.info(ZAnsi.green(" ${classpath} referenced, substituted full classpath"));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
|
LauncherLogger.info(ZAnsi.cyan(" ${classpath} not referenced by version.json, not substituting"));
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasCpInJson = containsCpArg(allJvmArgs);
|
boolean hasCpInJson = containsCpArg(allJvmArgs);
|
||||||
@@ -107,9 +108,9 @@ public class LaunchCommandBuilder {
|
|||||||
command.add(resolved);
|
command.add(resolved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.out.println(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
|
LauncherLogger.info(ZAnsi.green(" Using " + allJvmArgs.size() + " JVM args from version.json"));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
|
LauncherLogger.info(ZAnsi.yellow(" WARNING: No JVM args in version.json, using manual fallback"));
|
||||||
command.addAll(getJvmArguments(options));
|
command.addAll(getJvmArguments(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ public class LaunchCommandBuilder {
|
|||||||
if (!hasCpInJson) {
|
if (!hasCpInJson) {
|
||||||
command.add("-cp");
|
command.add("-cp");
|
||||||
command.add(writeClasspathFile(fullClasspath));
|
command.add(writeClasspathFile(fullClasspath));
|
||||||
System.out.println(ZAnsi.green(" Added -cp classpath for main class lookup"));
|
LauncherLogger.info(ZAnsi.green(" Added -cp classpath for main class lookup"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main class from version.json
|
// Main class from version.json
|
||||||
@@ -151,9 +152,9 @@ public class LaunchCommandBuilder {
|
|||||||
}
|
}
|
||||||
if (mainClass == null || mainClass.isEmpty()) {
|
if (mainClass == null || mainClass.isEmpty()) {
|
||||||
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
mainClass = "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
||||||
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.green(" Main class from manifest: " + mainClass));
|
LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass));
|
||||||
}
|
}
|
||||||
command.add(mainClass);
|
command.add(mainClass);
|
||||||
|
|
||||||
@@ -167,9 +168,9 @@ public class LaunchCommandBuilder {
|
|||||||
command.add(resolved);
|
command.add(resolved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.out.println(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
|
LauncherLogger.info(ZAnsi.green(" Using " + allGameArgs.size() + " game args from version.json"));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
|
LauncherLogger.info(ZAnsi.yellow(" WARNING: No game args in version.json, using manual fallback"));
|
||||||
command.addAll(getVanillaGameArguments(options));
|
command.addAll(getVanillaGameArguments(options));
|
||||||
command.addAll(getModloaderLaunchArgs());
|
command.addAll(getModloaderLaunchArgs());
|
||||||
}
|
}
|
||||||
@@ -183,24 +184,24 @@ public class LaunchCommandBuilder {
|
|||||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||||
|
|
||||||
if ("fabric".equals(loaderType)) {
|
if ("fabric".equals(loaderType)) {
|
||||||
System.out.println(ZAnsi.cyan(" Fabric: using vanilla classpath"));
|
LauncherLogger.info(ZAnsi.cyan(" Fabric: using vanilla classpath"));
|
||||||
command.add("-cp");
|
command.add("-cp");
|
||||||
command.add(writeClasspathFile(buildVanillaClasspath()));
|
command.add(writeClasspathFile(buildVanillaClasspath()));
|
||||||
String mainClass = null;
|
String mainClass = null;
|
||||||
if (manifest != null) {
|
if (manifest != null) {
|
||||||
mainClass = manifest.getMainClass();
|
mainClass = manifest.getMainClass();
|
||||||
System.out.println(ZAnsi.cyan(" Main class from manifest: " + mainClass));
|
LauncherLogger.info(ZAnsi.cyan(" Main class from manifest: " + mainClass));
|
||||||
}
|
}
|
||||||
if (mainClass == null || mainClass.isEmpty()) {
|
if (mainClass == null || mainClass.isEmpty()) {
|
||||||
mainClass = getVanillaMainClass();
|
mainClass = getVanillaMainClass();
|
||||||
System.out.println(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
LauncherLogger.info(ZAnsi.yellow(" Using fallback main class: " + mainClass));
|
||||||
}
|
}
|
||||||
command.add(mainClass);
|
command.add(mainClass);
|
||||||
command.addAll(getVanillaGameArguments(options));
|
command.addAll(getVanillaGameArguments(options));
|
||||||
} else if (manifest != null) {
|
} else if (manifest != null) {
|
||||||
String classpath = buildClasspathFromManifest(manifest, true);
|
String classpath = buildClasspathFromManifest(manifest, true);
|
||||||
if (classpath.isEmpty()) {
|
if (classpath.isEmpty()) {
|
||||||
System.out.println(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
|
LauncherLogger.info(ZAnsi.yellow(" manifest classpath empty, using vanilla classpath"));
|
||||||
command.add("-cp");
|
command.add("-cp");
|
||||||
command.add(writeClasspathFile(buildVanillaClasspath()));
|
command.add(writeClasspathFile(buildVanillaClasspath()));
|
||||||
command.add(getVanillaMainClass());
|
command.add(getVanillaMainClass());
|
||||||
@@ -229,20 +230,20 @@ public class LaunchCommandBuilder {
|
|||||||
if (versionJson != null && Files.exists(versionJson)) {
|
if (versionJson != null && Files.exists(versionJson)) {
|
||||||
String content = Files.readString(versionJson);
|
String content = Files.readString(versionJson);
|
||||||
JSONObject json = new JSONObject(content);
|
JSONObject json = new JSONObject(content);
|
||||||
System.out.println(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
|
LauncherLogger.info(ZAnsi.green("Found version.json: " + versionJson.getFileName()));
|
||||||
VersionManifest manifest = new VersionManifest(json);
|
VersionManifest manifest = new VersionManifest(json);
|
||||||
manifest.resolveParent(instance.getPath().resolve("versions"));
|
manifest.resolveParent(instance.getPath().resolve("versions"));
|
||||||
if (manifest.getInheritsFrom() != null) {
|
if (manifest.getInheritsFrom() != null) {
|
||||||
System.out.println(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
|
LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
|
||||||
}
|
}
|
||||||
return manifest;
|
return manifest;
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||||
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||||
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
|
LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
|
LauncherLogger.info(ZAnsi.yellow("Failed to load version.json: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -496,14 +497,14 @@ public class LaunchCommandBuilder {
|
|||||||
String assetIndex = instance.getAssetIndex();
|
String assetIndex = instance.getAssetIndex();
|
||||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||||
assetIndex = instance.getMinecraftVersion();
|
assetIndex = instance.getMinecraftVersion();
|
||||||
System.out.println(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
|
LauncherLogger.info(ZAnsi.yellow("Asset index not found, using version: " + assetIndex));
|
||||||
} else {
|
} else {
|
||||||
// Validate that the index file exists. A wrong/stale asset index
|
// Validate that the index file exists. A wrong/stale asset index
|
||||||
// (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game
|
// (e.g. the Zern-OBT "1.20.1" bug, real index is "5") makes the game
|
||||||
// fail to load the base resource pack (grey panorama, no sounds).
|
// fail to load the base resource pack (grey panorama, no sounds).
|
||||||
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
|
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
|
||||||
if (!Files.exists(indexPath)) {
|
if (!Files.exists(indexPath)) {
|
||||||
System.out.println(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
|
LauncherLogger.info(ZAnsi.yellow("Asset index file missing: " + assetIndex + ".json, searching for existing index..."));
|
||||||
try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) {
|
try (java.util.stream.Stream<Path> stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) {
|
||||||
java.util.List<Path> existing = stream
|
java.util.List<Path> existing = stream
|
||||||
.filter(p -> p.getFileName().toString().endsWith(".json"))
|
.filter(p -> p.getFileName().toString().endsWith(".json"))
|
||||||
@@ -512,16 +513,16 @@ public class LaunchCommandBuilder {
|
|||||||
if (!existing.isEmpty()) {
|
if (!existing.isEmpty()) {
|
||||||
String found = existing.get(existing.size() - 1).getFileName().toString()
|
String found = existing.get(existing.size() - 1).getFileName().toString()
|
||||||
.replace(".json", "");
|
.replace(".json", "");
|
||||||
System.out.println(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
|
LauncherLogger.info(ZAnsi.green("Using existing asset index: " + found + " (was " + assetIndex + ")"));
|
||||||
assetIndex = found;
|
assetIndex = found;
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow("No asset index files found in assets/indexes/"));
|
LauncherLogger.info(ZAnsi.yellow("No asset index files found in assets/indexes/"));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
|
LauncherLogger.info(ZAnsi.yellow("Failed to search asset indexes: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.out.println(ZAnsi.green("Using asset index: " + assetIndex));
|
LauncherLogger.info(ZAnsi.green("Using asset index: " + assetIndex));
|
||||||
}
|
}
|
||||||
args.add(assetIndex);
|
args.add(assetIndex);
|
||||||
args.add("--username");
|
args.add("--username");
|
||||||
@@ -612,7 +613,7 @@ public class LaunchCommandBuilder {
|
|||||||
List<String> paths = new ArrayList<>();
|
List<String> paths = new ArrayList<>();
|
||||||
Path librariesDir = instance.getPath().resolve("libraries");
|
Path librariesDir = instance.getPath().resolve("libraries");
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
|
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getAllLibraries().size() + " libraries in manifest (including inherited)"));
|
||||||
|
|
||||||
String loaderType = instance.getLoaderType().toLowerCase();
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
|
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
|
||||||
@@ -628,7 +629,7 @@ public class LaunchCommandBuilder {
|
|||||||
if (isValidJar(libPath)) {
|
if (isValidJar(libPath)) {
|
||||||
paths.add(libPath.toAbsolutePath().toString());
|
paths.add(libPath.toAbsolutePath().toString());
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||||
Files.delete(libPath);
|
Files.delete(libPath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -638,7 +639,7 @@ public class LaunchCommandBuilder {
|
|||||||
if (isValidJar(fallbackPath)) {
|
if (isValidJar(fallbackPath)) {
|
||||||
paths.add(fallbackPath.toAbsolutePath().toString());
|
paths.add(fallbackPath.toAbsolutePath().toString());
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||||
Files.delete(fallbackPath);
|
Files.delete(fallbackPath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -647,23 +648,23 @@ public class LaunchCommandBuilder {
|
|||||||
Path found = scanForJar(librariesDir, artifactName);
|
Path found = scanForJar(librariesDir, artifactName);
|
||||||
if (found != null) {
|
if (found != null) {
|
||||||
if (isValidJar(found)) {
|
if (isValidJar(found)) {
|
||||||
System.out.println(ZAnsi.green(" Found by scan: " + lib.name + " → " + librariesDir.relativize(found)));
|
LauncherLogger.info(ZAnsi.green(" Found by scan: " + lib.name + " → " + librariesDir.relativize(found)));
|
||||||
paths.add(found.toAbsolutePath().toString());
|
paths.add(found.toAbsolutePath().toString());
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
|
LauncherLogger.info(ZAnsi.yellow(" Corrupt library (scan), deleting: " + found.getFileName()));
|
||||||
Files.delete(found);
|
Files.delete(found);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
|
LauncherLogger.info(ZAnsi.yellow(" Library not found (even after scan): " + lib.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
LauncherLogger.info(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
||||||
if (isForgeLike) {
|
if (isForgeLike) {
|
||||||
System.out.println(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
|
LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
|
||||||
System.out.println(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
|
LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeVersionJar) {
|
if (includeVersionJar) {
|
||||||
@@ -671,9 +672,9 @@ public class LaunchCommandBuilder {
|
|||||||
if (versionJar != null) {
|
if (versionJar != null) {
|
||||||
if (isValidJar(versionJar)) {
|
if (isValidJar(versionJar)) {
|
||||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||||
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
|
LauncherLogger.info(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
|
LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
|
||||||
Files.delete(versionJar);
|
Files.delete(versionJar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,7 +786,7 @@ public class LaunchCommandBuilder {
|
|||||||
Path targetJar = targetDir.resolve(versionId + ".jar");
|
Path targetJar = targetDir.resolve(versionId + ".jar");
|
||||||
|
|
||||||
if (Files.exists(targetJar) && isValidJar(targetJar)) {
|
if (Files.exists(targetJar) && isValidJar(targetJar)) {
|
||||||
System.out.println(ZAnsi.green(" Version jar already exists: " + targetJar));
|
LauncherLogger.info(ZAnsi.green(" Version jar already exists: " + targetJar));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,11 +796,11 @@ public class LaunchCommandBuilder {
|
|||||||
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
|
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
|
||||||
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
|
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
|
||||||
Files.copy(vanillaJar, targetJar);
|
Files.copy(vanillaJar, targetJar);
|
||||||
System.out.println(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + " → " + targetJar));
|
LauncherLogger.info(ZAnsi.green(" Copied vanilla jar for securejarhandler: " + vanillaJar.getFileName() + " → " + targetJar));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
|
LauncherLogger.info(ZAnsi.yellow(" No vanilla jar found at " + vanillaJar));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -833,7 +834,7 @@ public class LaunchCommandBuilder {
|
|||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (found != null) {
|
if (found != null) {
|
||||||
System.out.println(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
|
LauncherLogger.info(ZAnsi.green(" Found SRG client jar: " + found + " (module name: " + getModuleName(found) + ")"));
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
} catch (Exception e) { /* continue */ }
|
} catch (Exception e) { /* continue */ }
|
||||||
@@ -848,7 +849,7 @@ public class LaunchCommandBuilder {
|
|||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (found != null) {
|
if (found != null) {
|
||||||
System.out.println(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
|
LauncherLogger.info(ZAnsi.green(" Found SRG client jar (fallback scan): " + found + " (module name: " + getModuleName(found) + ")"));
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
} catch (Exception e) { /* continue */ }
|
} catch (Exception e) { /* continue */ }
|
||||||
@@ -903,12 +904,12 @@ public class LaunchCommandBuilder {
|
|||||||
for (String candidate : candidates) {
|
for (String candidate : candidates) {
|
||||||
Path jarPath = librariesDir.resolve(candidate);
|
Path jarPath = librariesDir.resolve(candidate);
|
||||||
if (Files.exists(jarPath) && isValidJar(jarPath)) {
|
if (Files.exists(jarPath) && isValidJar(jarPath)) {
|
||||||
System.out.println(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
|
LauncherLogger.info(ZAnsi.green(" Found Forge jar: " + jarPath.getFileName()));
|
||||||
return jarPath;
|
return jarPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow(" No Forge client/universal jar found!"));
|
LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,7 +1048,7 @@ public class LaunchCommandBuilder {
|
|||||||
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
|
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
|
||||||
Files.writeString(tempFile, classpath);
|
Files.writeString(tempFile, classpath);
|
||||||
tempFiles.add(tempFile);
|
tempFiles.add(tempFile);
|
||||||
System.out.println(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
|
LauncherLogger.info(ZAnsi.cyan(" Classpath too long (" + classpath.length() + " chars), using argfile: " + tempFile));
|
||||||
return "@" + tempFile.toAbsolutePath();
|
return "@" + tempFile.toAbsolutePath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
|
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ public class VersionManifest {
|
|||||||
this.parent = new VersionManifest(json);
|
this.parent = new VersionManifest(json);
|
||||||
this.parent.resolveParent(versionsDir);
|
this.parent.resolveParent(versionsDir);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
|
LauncherLogger.warn("Failed to load parent version " + inheritsFrom + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.DomainSelector;
|
||||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||||
import me.sashegdev.zernmc.launcher.utils.Version;
|
import me.sashegdev.zernmc.launcher.utils.Version;
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ public class JFXLauncher extends Application {
|
|||||||
try {
|
try {
|
||||||
String existingVersion = Files.readString(versionFile).trim();
|
String existingVersion = Files.readString(versionFile).trim();
|
||||||
if (existingVersion.equals(currentVersion)) {
|
if (existingVersion.equals(currentVersion)) {
|
||||||
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")");
|
log("Assets up to date (v" + currentVersion + ")");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
@@ -233,16 +234,16 @@ public class JFXLauncher extends Application {
|
|||||||
|
|
||||||
String serverVersion = getServerVersion();
|
String serverVersion = getServerVersion();
|
||||||
if (serverVersion != null && !serverVersion.isEmpty()) {
|
if (serverVersion != null && !serverVersion.isEmpty()) {
|
||||||
System.out.println("[JFX] Loading assets via meta for version " + serverVersion);
|
log("Loading assets via meta for version " + serverVersion);
|
||||||
if (downloadAssetsFromMeta(serverVersion)) {
|
if (downloadAssetsFromMeta(serverVersion)) {
|
||||||
System.out.println("[JFX] Assets loaded via meta");
|
log("Assets loaded via meta");
|
||||||
Files.writeString(versionFile, currentVersion);
|
Files.writeString(versionFile, currentVersion);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
System.out.println("[JFX] Meta unavailable, using fallback");
|
log("Meta unavailable, using fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("[JFX] Extracting assets from JAR...");
|
log("Extracting assets from JAR...");
|
||||||
Files.createDirectories(assetsDir);
|
Files.createDirectories(assetsDir);
|
||||||
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||||
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
|
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
|
||||||
@@ -263,11 +264,11 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.out.println("[JFX] Assets extracted from JAR");
|
log("Assets extracted from JAR");
|
||||||
Files.writeString(versionFile, currentVersion);
|
Files.writeString(versionFile, currentVersion);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[JFX] Error extracting assets: " + e.getMessage());
|
log("Error extracting assets: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +333,7 @@ public class JFXLauncher extends Application {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[JFX] Error loading via meta: " + e.getMessage());
|
log("Error loading assets via meta: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,6 +364,7 @@ public class JFXLauncher extends Application {
|
|||||||
log("Starting background network init...");
|
log("Starting background network init...");
|
||||||
Thread netInitThread = new Thread(() -> {
|
Thread netInitThread = new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
|
DomainSelector.selectBest();
|
||||||
ZHttpClient.checkAllServicesOnStartup();
|
ZHttpClient.checkAllServicesOnStartup();
|
||||||
log("Network init complete");
|
log("Network init complete");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -391,9 +393,9 @@ public class JFXLauncher extends Application {
|
|||||||
engine.setOnAlert(e -> {
|
engine.setOnAlert(e -> {
|
||||||
String msg = e.getData();
|
String msg = e.getData();
|
||||||
if (msg.startsWith("[LOG] ")) {
|
if (msg.startsWith("[LOG] ")) {
|
||||||
System.out.println("[JS] " + msg.substring(6));
|
log("[JS] " + msg.substring(6));
|
||||||
} else if (msg.startsWith("[ERR] ")) {
|
} else if (msg.startsWith("[ERR] ")) {
|
||||||
System.err.println("[JS] " + msg.substring(6));
|
log("[JS ERROR] " + msg.substring(6));
|
||||||
} else {
|
} else {
|
||||||
log("[UI] Alert: " + msg);
|
log("[UI] Alert: " + msg);
|
||||||
}
|
}
|
||||||
@@ -493,6 +495,8 @@ public class JFXLauncher extends Application {
|
|||||||
server.createContext("/api/account", this::handleAccount);
|
server.createContext("/api/account", this::handleAccount);
|
||||||
server.createContext("/api/instances", this::handleInstances);
|
server.createContext("/api/instances", this::handleInstances);
|
||||||
server.createContext("/api/launch", this::handleLaunch);
|
server.createContext("/api/launch", this::handleLaunch);
|
||||||
|
server.createContext("/api/offline/status", this::handleOfflineStatus);
|
||||||
|
server.createContext("/api/offline-launch", this::handleOfflineLaunch);
|
||||||
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/network/status", this::handleNetworkStatus);
|
||||||
@@ -612,9 +616,8 @@ public class JFXLauncher extends Application {
|
|||||||
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log("Login error: " + e.getMessage());
|
log("Login error: " + AuthManager.friendlyConnectionError(e));
|
||||||
e.printStackTrace();
|
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
|
||||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +644,6 @@ public class JFXLauncher extends Application {
|
|||||||
try {
|
try {
|
||||||
boolean loggedIn = AuthManager.isLoggedIn();
|
boolean loggedIn = AuthManager.isLoggedIn();
|
||||||
boolean authExists = AuthManager.authFileExists();
|
boolean authExists = AuthManager.authFileExists();
|
||||||
System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
|
||||||
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
||||||
if (AuthManager.tryAutoLogin()) {
|
if (AuthManager.tryAutoLogin()) {
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
@@ -650,15 +652,12 @@ public class JFXLauncher extends Application {
|
|||||||
data.put("role", AuthManager.getRole());
|
data.put("role", AuthManager.getRole());
|
||||||
data.put("roleName", AuthManager.getRoleName());
|
data.put("roleName", AuthManager.getRoleName());
|
||||||
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
|
sendJson(exchange, Map.of("success", true, "data", data, "autoLogin", true));
|
||||||
System.out.println("[AUTH] Auto-login performed: " + AuthManager.getUsername());
|
|
||||||
log("Auto-login performed: " + AuthManager.getUsername());
|
log("Auto-login performed: " + AuthManager.getUsername());
|
||||||
} else {
|
} else {
|
||||||
System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
|
||||||
log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
||||||
sendJson(exchange, Map.of("success", false, "autoLogin", false));
|
sendJson(exchange, Map.of("success", false, "autoLogin", false));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage());
|
|
||||||
log("handleAutoLogin error: " + e.getMessage());
|
log("handleAutoLogin error: " + e.getMessage());
|
||||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
}
|
}
|
||||||
@@ -721,6 +720,54 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleOfflineStatus(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
boolean offline = isOfflineMode();
|
||||||
|
sendJson(exchange, Map.of("success", true, "offline", offline));
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleOfflineLaunch(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String name = body.get("name");
|
||||||
|
String nickname = body.get("nickname");
|
||||||
|
|
||||||
|
Instance instance = InstanceManager.getInstance(name);
|
||||||
|
if (instance == null) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Pack not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = api.launch().launchOffline(name, nickname);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("pid", result.getData().getPid());
|
||||||
|
data.put("status", result.getData().getStatus());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
log("Offline launched: " + name + " pid=" + result.getData().getPid());
|
||||||
|
} else {
|
||||||
|
log("Offline launch failed: " + result.getError());
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("handleOfflineLaunch error: " + e.getMessage());
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isOfflineMode() {
|
||||||
|
if ("true".equalsIgnoreCase(System.getProperty("zernmc.offline", "false"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (ZHttpClient.isNetworkInitialized() && !ZHttpClient.isZernServerHealthy()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void handleInstall(HttpExchange exchange) {
|
private void handleInstall(HttpExchange exchange) {
|
||||||
if (!installInProgress.compareAndSet(false, true)) {
|
if (!installInProgress.compareAndSet(false, true)) {
|
||||||
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
|
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
|
||||||
@@ -769,8 +816,8 @@ public class JFXLauncher extends Application {
|
|||||||
boolean success = false;
|
boolean success = false;
|
||||||
|
|
||||||
if (!ZHttpClient.isNetworkInitialized()) {
|
if (!ZHttpClient.isNetworkInitialized()) {
|
||||||
log("Network init not finished, checking Mojang services synchronously...");
|
log("Network init not finished, running full service check (CLI-style)...");
|
||||||
ZHttpClient.forceCheckMojangServices();
|
ZHttpClient.checkAllServicesOnStartup();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("zernmc".equalsIgnoreCase(loader)) {
|
if ("zernmc".equalsIgnoreCase(loader)) {
|
||||||
@@ -895,17 +942,16 @@ public class JFXLauncher extends Application {
|
|||||||
exchange.sendResponseHeaders(200, 0);
|
exchange.sendResponseHeaders(200, 0);
|
||||||
|
|
||||||
final OutputStream os = exchange.getResponseBody();
|
final OutputStream os = exchange.getResponseBody();
|
||||||
|
// Bounded queue: producers (LauncherLogger on the install thread) must
|
||||||
|
// never block on a slow/stalled SSE client. If the UI can't keep up,
|
||||||
|
// lines are dropped here - they still reach the log file and buffer.
|
||||||
|
final java.util.concurrent.BlockingQueue<String> queue =
|
||||||
|
new java.util.concurrent.LinkedBlockingQueue<>(5000);
|
||||||
|
|
||||||
LogConsumer consumer = new LogConsumer() {
|
LogConsumer consumer = new LogConsumer() {
|
||||||
@Override
|
@Override
|
||||||
public synchronized void onLog(String line) {
|
public void onLog(String line) {
|
||||||
try {
|
queue.offer(line);
|
||||||
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
|
|
||||||
os.write(data.getBytes(StandardCharsets.UTF_8));
|
|
||||||
os.flush();
|
|
||||||
} catch (Exception e) {
|
|
||||||
removeLogConsumer(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
consumerRef[0] = consumer;
|
consumerRef[0] = consumer;
|
||||||
@@ -915,9 +961,13 @@ public class JFXLauncher extends Application {
|
|||||||
Thread.currentThread().setName("sse-logs-stream");
|
Thread.currentThread().setName("sse-logs-stream");
|
||||||
while (!Thread.currentThread().isInterrupted()) {
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
try {
|
try {
|
||||||
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
if (line != null) {
|
||||||
|
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
} else {
|
||||||
|
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
os.flush();
|
os.flush();
|
||||||
Thread.sleep(10000);
|
|
||||||
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {} finally {
|
} catch (Exception ignored) {} finally {
|
||||||
@@ -939,17 +989,14 @@ public class JFXLauncher extends Application {
|
|||||||
exchange.sendResponseHeaders(200, 0);
|
exchange.sendResponseHeaders(200, 0);
|
||||||
|
|
||||||
final OutputStream os = exchange.getResponseBody();
|
final OutputStream os = exchange.getResponseBody();
|
||||||
|
// Bounded queue: producers must never block on a slow/stalled SSE client.
|
||||||
|
final java.util.concurrent.BlockingQueue<String> queue =
|
||||||
|
new java.util.concurrent.LinkedBlockingQueue<>(5000);
|
||||||
|
|
||||||
LogConsumer consumer = new LogConsumer() {
|
LogConsumer consumer = new LogConsumer() {
|
||||||
@Override
|
@Override
|
||||||
public synchronized void onLog(String line) {
|
public void onLog(String line) {
|
||||||
try {
|
queue.offer(line);
|
||||||
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
|
|
||||||
os.write(data.getBytes(StandardCharsets.UTF_8));
|
|
||||||
os.flush();
|
|
||||||
} catch (Exception e) {
|
|
||||||
removeGameLogConsumer(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
consumerRef[0] = consumer;
|
consumerRef[0] = consumer;
|
||||||
@@ -959,9 +1006,13 @@ public class JFXLauncher extends Application {
|
|||||||
Thread.currentThread().setName("sse-game-logs-stream");
|
Thread.currentThread().setName("sse-game-logs-stream");
|
||||||
while (!Thread.currentThread().isInterrupted()) {
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
try {
|
try {
|
||||||
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||||
|
if (line != null) {
|
||||||
|
os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
} else {
|
||||||
|
os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
os.flush();
|
os.flush();
|
||||||
Thread.sleep(10000);
|
|
||||||
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {} finally {
|
} catch (Exception ignored) {} finally {
|
||||||
@@ -1080,7 +1131,7 @@ public class JFXLauncher extends Application {
|
|||||||
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1723,7 +1774,7 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void log(String msg) {
|
private static void log(String msg) {
|
||||||
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
|
String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||||
String entry = "[" + timestamp + "] " + msg;
|
String entry = "[" + timestamp + "] " + msg;
|
||||||
synchronized (launcherLogBuffer) {
|
synchronized (launcherLogBuffer) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class Config {
|
|||||||
private static final Properties props = new Properties();
|
private static final Properties props = new Properties();
|
||||||
|
|
||||||
private static volatile int maxMemory = 4096;
|
private static volatile int maxMemory = 4096;
|
||||||
private static volatile String serverUrl = "https://api.zernmc.ru";
|
private static volatile String serverUrl = "https://api.zern.cc";
|
||||||
private static volatile String lastUsername = "Player";
|
private static volatile String lastUsername = "Player";
|
||||||
private static volatile int windowWidth = 1280;
|
private static volatile int windowWidth = 1280;
|
||||||
private static volatile int windowHeight = 720;
|
private static volatile int windowHeight = 720;
|
||||||
@@ -144,6 +144,11 @@ public class Config {
|
|||||||
return serverUrl;
|
return serverUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void setServerUrl(String url) {
|
||||||
|
serverUrl = url != null && !url.isBlank() ? url : serverUrl;
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
public static String getLastUsername() {
|
public static String getLastUsername() {
|
||||||
return lastUsername;
|
return lastUsername;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.utils;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the fastest reachable ZernMC API domain at launcher startup.
|
||||||
|
* TSPU/SNI-обход: если основной api.zern.cc недоступен (заблокирован ТСПУ),
|
||||||
|
* пробиваем legacy-домены и гео-зеркала и переключаемся на первый живой/быстрый.
|
||||||
|
*/
|
||||||
|
public final class DomainSelector {
|
||||||
|
|
||||||
|
private static final HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(8))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private static final List<String> FALLBACK_CANDIDATES = List.of(
|
||||||
|
"https://api.zern.cc",
|
||||||
|
"https://api.zernmc.ru",
|
||||||
|
"https://api.zernmc.online",
|
||||||
|
"https://api.pl.zern.cc",
|
||||||
|
"https://api.swe.zern.cc"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final String MIRRORS_PATH = "/launcher/mirrors";
|
||||||
|
private static final String VERSION_PATH = "/launcher/version";
|
||||||
|
|
||||||
|
private DomainSelector() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return выбранный base URL или null, если ни один домен не ответил.
|
||||||
|
*/
|
||||||
|
public static String selectBest() {
|
||||||
|
List<String> candidates = new ArrayList<>();
|
||||||
|
String configured = Config.getServerUrl();
|
||||||
|
if (configured != null && !configured.isBlank() && !candidates.contains(configured)) {
|
||||||
|
candidates.add(configured);
|
||||||
|
}
|
||||||
|
for (String c : FALLBACK_CANDIDATES) {
|
||||||
|
if (!candidates.contains(c)) {
|
||||||
|
candidates.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.addAll(fetchMirrors());
|
||||||
|
|
||||||
|
String best = null;
|
||||||
|
long bestMs = Long.MAX_VALUE;
|
||||||
|
for (String base : candidates) {
|
||||||
|
Probe r = probe(base);
|
||||||
|
if (r.ok && r.latencyMs >= 0 && r.latencyMs < bestMs) {
|
||||||
|
bestMs = r.latencyMs;
|
||||||
|
best = base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best == null) {
|
||||||
|
// nothing reachable: keep whatever is configured
|
||||||
|
System.err.println(ZAnsi.brightRed("DomainSelector: no reachable API domain"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best.equals(configured)) {
|
||||||
|
ZHttpClient.setBaseUrl(best);
|
||||||
|
Config.setServerUrl(best);
|
||||||
|
System.out.println(ZAnsi.cyan("Selected API domain: " + best)
|
||||||
|
+ " (" + bestMs + " ms)");
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> fetchMirrors() {
|
||||||
|
List<String> mirrors = new ArrayList<>();
|
||||||
|
for (String candidate : FALLBACK_CANDIDATES) {
|
||||||
|
Probe p = probe(candidate);
|
||||||
|
if (!p.ok || p.body == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
org.json.JSONObject json = new org.json.JSONObject(p.body);
|
||||||
|
var arr = json.optJSONArray("mirrors");
|
||||||
|
if (arr == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < arr.length(); i++) {
|
||||||
|
String url = arr.optJSONObject(i) != null
|
||||||
|
? arr.optJSONObject(i).optString("url", null) : null;
|
||||||
|
if (url != null && !url.isBlank() && !mirrors.contains(url)) {
|
||||||
|
mirrors.add(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break; // первый живой источник списка достаточен
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// continue to next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mirrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Probe probe(String baseUrl) {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + MIRRORS_PATH))
|
||||||
|
.timeout(Duration.ofSeconds(8))
|
||||||
|
.GET()
|
||||||
|
.header("User-Agent", "ZernMC-Launcher/DomainSelector")
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
long ms = System.currentTimeMillis() - t0;
|
||||||
|
boolean ok = response.statusCode() == 200;
|
||||||
|
return new Probe(ok, ms, ok ? response.body() : null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new Probe(false, System.currentTimeMillis() - t0, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Probe(boolean ok, long latencyMs, String body) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import java.util.concurrent.BlockingQueue;
|
|||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class LauncherLogger {
|
public class LauncherLogger {
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ public class LauncherLogger {
|
|||||||
private static final int STDOUT_QUEUE_CAPACITY = 2048;
|
private static final int STDOUT_QUEUE_CAPACITY = 2048;
|
||||||
private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
|
private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
|
||||||
private static final Thread stdoutWriterThread;
|
private static final Thread stdoutWriterThread;
|
||||||
|
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]");
|
||||||
|
|
||||||
static {
|
static {
|
||||||
stdoutWriterThread = new Thread(() -> {
|
stdoutWriterThread = new Thread(() -> {
|
||||||
@@ -97,9 +99,12 @@ public class LauncherLogger {
|
|||||||
// block application threads.
|
// block application threads.
|
||||||
offerStdout(line);
|
offerStdout(line);
|
||||||
|
|
||||||
|
// File and UI log never get ANSI escape codes; the console keeps them.
|
||||||
|
String plainLine = ANSI_PATTERN.matcher(line).replaceAll("");
|
||||||
|
|
||||||
// Forward to the JFX UI log so installer/progress output is visible
|
// Forward to the JFX UI log so installer/progress output is visible
|
||||||
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
|
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
|
||||||
JFXBridge.appendLauncherLogFromLogger(line);
|
JFXBridge.appendLauncherLogFromLogger(plainLine);
|
||||||
|
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
StringWriter sw = new StringWriter();
|
StringWriter sw = new StringWriter();
|
||||||
@@ -120,7 +125,7 @@ public class LauncherLogger {
|
|||||||
}
|
}
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
try {
|
try {
|
||||||
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
|
Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND);
|
||||||
if (t != null) {
|
if (t != null) {
|
||||||
StringWriter sw = new StringWriter();
|
StringWriter sw = new StringWriter();
|
||||||
PrintWriter pw = new PrintWriter(sw);
|
PrintWriter pw = new PrintWriter(sw);
|
||||||
|
|||||||
@@ -4,81 +4,107 @@ import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
|||||||
|
|
||||||
import java.text.DecimalFormat;
|
import java.text.DecimalFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress reporting for installs/downloads.
|
||||||
|
*
|
||||||
|
* IMPORTANT: never writes directly to System.out/System.err. In JFX mode the
|
||||||
|
* launcher runs under javaw.exe whose stdout pipe is drained by the parent exe;
|
||||||
|
* once that pipe fills (or the exe stops reading), a synchronous
|
||||||
|
* System.out.write/flush blocks the calling (install/UI) thread forever.
|
||||||
|
* All console/progress output must go through LauncherLogger, which is
|
||||||
|
* non-blocking by design (bounded stdout queue + tryLock file write + SSE queue).
|
||||||
|
*/
|
||||||
public class ProgressBar {
|
public class ProgressBar {
|
||||||
|
|
||||||
private static final int BAR_LENGTH = 40;
|
|
||||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||||
|
|
||||||
private static String currentLabel = "";
|
private static String currentLabel = "";
|
||||||
private static long currentTotal = 0;
|
private static long currentTotal = 0;
|
||||||
|
|
||||||
|
private static final long DOWNLOAD_LOG_INTERVAL_MS = 500;
|
||||||
|
private static volatile long lastDownloadLogMs = 0;
|
||||||
|
private static volatile long lastIndeterminateLogMs = 0;
|
||||||
|
|
||||||
public static void show(String label, long current, long total, String unit) {
|
public static void show(String label, long current, long total, String unit) {
|
||||||
currentLabel = label;
|
currentLabel = label;
|
||||||
currentTotal = total;
|
currentTotal = total;
|
||||||
|
|
||||||
JFXBridge.setInstallProgress(label, (int) current, (int) total);
|
JFXBridge.setInstallProgress(label, (int) current, (int) total);
|
||||||
|
|
||||||
if (total <= 0) {
|
if (total <= 0) {
|
||||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
LauncherLogger.info(label + " ...");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
double progress = (double) current / total;
|
int percent = (int) ((double) current / total * 100);
|
||||||
int filled = (int) (progress * BAR_LENGTH);
|
LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")");
|
||||||
String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled);
|
|
||||||
int percent = (int) (progress * 100);
|
|
||||||
|
|
||||||
String text = String.format("%s [%s] %3d%% (%d/%d %s)",
|
|
||||||
ZAnsi.cyan(label), bar, percent, current, total, unit);
|
|
||||||
|
|
||||||
System.out.print("\r" + text);
|
|
||||||
System.out.flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||||
currentLabel = label;
|
currentLabel = label;
|
||||||
currentTotal = totalBytes;
|
currentTotal = totalBytes;
|
||||||
|
|
||||||
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
||||||
|
|
||||||
if (totalBytes <= 0) {
|
// Throttle console/file logging: called once per chunk during downloads.
|
||||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
long now = System.currentTimeMillis();
|
||||||
return;
|
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||||
|
lastDownloadLogMs = now;
|
||||||
|
LauncherLogger.info(label + " " + formatBytes(downloaded)
|
||||||
|
+ (totalBytes > 0 ? "/" + formatBytes(totalBytes) : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
double progress = (double) downloaded / totalBytes;
|
|
||||||
int filled = (int) (progress * BAR_LENGTH);
|
|
||||||
String bar = "█".repeat(filled) + "░".repeat(BAR_LENGTH - filled);
|
|
||||||
String percent = DF.format(progress * 100);
|
|
||||||
|
|
||||||
String text = String.format("%s [%s] %6s%% %s / %s",
|
|
||||||
ZAnsi.cyan(label),
|
|
||||||
bar,
|
|
||||||
percent,
|
|
||||||
formatBytes(downloaded),
|
|
||||||
formatBytes(totalBytes));
|
|
||||||
|
|
||||||
System.out.print("\r" + text);
|
|
||||||
System.out.flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void showAnimated(String label, long current, long total, String unit) {
|
public static void showAnimated(String label, long current, long total, String unit) {
|
||||||
currentLabel = label;
|
currentLabel = label;
|
||||||
currentTotal = total;
|
currentTotal = total;
|
||||||
|
|
||||||
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
|
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
|
||||||
|
|
||||||
if (total <= 0) {
|
long now = System.currentTimeMillis();
|
||||||
char[] spinner = {'|', '/', '-', '\\'};
|
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||||
int idx = (int) (current / 1024) % 4;
|
lastDownloadLogMs = now;
|
||||||
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
|
LauncherLogger.info(label + ": " + formatBytes(current)
|
||||||
} else {
|
+ (total > 0 ? "/" + formatBytes(total) + " " + unit : ""));
|
||||||
show(label, (int) ((current * 100) / total), 100, unit);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void finish(String message) {
|
public static void finish(String message) {
|
||||||
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
|
LauncherLogger.info(message + " done");
|
||||||
System.out.flush();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a stage without a known total (e.g. running the Forge/NeoForge/Fabric
|
||||||
|
* installer subprocess). Always updates the UI label so the user sees what the
|
||||||
|
* launcher is doing right now; console/file logging is throttled to avoid spam.
|
||||||
|
*/
|
||||||
|
public static void showIndeterminate(String label) {
|
||||||
|
currentLabel = label;
|
||||||
|
currentTotal = 0;
|
||||||
|
|
||||||
|
JFXBridge.setInstallProgress(label, 0, 0);
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastIndeterminateLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||||
|
lastIndeterminateLogMs = now;
|
||||||
|
LauncherLogger.info(label + " ...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips ANSI codes, indentation and [category] prefixes from a child-process
|
||||||
|
* output line so it can be used as a short, human-readable progress label.
|
||||||
|
*/
|
||||||
|
public static String cleanLine(String line) {
|
||||||
|
if (line == null) return "";
|
||||||
|
String s = line.replaceAll("\\u001B\\[[;\\d]*m", "").trim();
|
||||||
|
int bracket = s.indexOf("] ");
|
||||||
|
if (s.startsWith("[") && bracket > 0) {
|
||||||
|
s = s.substring(bracket + 2).trim();
|
||||||
|
}
|
||||||
|
if (s.length() > 60) {
|
||||||
|
s = s.substring(0, 57) + "...";
|
||||||
|
}
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void setStage(String stageName, int stageIndex, int stageCount) {
|
public static void setStage(String stageName, int stageIndex, int stageCount) {
|
||||||
@@ -86,8 +112,7 @@ public class ProgressBar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void clearLine() {
|
public static void clearLine() {
|
||||||
System.out.print("\r" + " ".repeat(110) + "\r");
|
// No-op: console progress now goes through LauncherLogger.
|
||||||
System.out.flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String formatBytes(long bytes) {
|
public static String formatBytes(long bytes) {
|
||||||
@@ -95,4 +120,4 @@ public class ProgressBar {
|
|||||||
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB";
|
||||||
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
return DF.format(bytes / (1024.0 * 1024)) + " MB";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ public class ZHttpClient {
|
|||||||
.executor(CLIENT_EXECUTOR)
|
.executor(CLIENT_EXECUTOR)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private static String BASE_URL = "https://api.zernmc.ru";
|
private static String BASE_URL = "https://api.zern.cc";
|
||||||
|
|
||||||
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);
|
||||||
@@ -64,7 +64,7 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum ServiceType {
|
public enum ServiceType {
|
||||||
ZERN_SERVER("https://api.zernmc.ru", true),
|
ZERN_SERVER("https://api.zern.cc", true),
|
||||||
FABRIC_META("https://meta.fabricmc.net", false),
|
FABRIC_META("https://meta.fabricmc.net", false),
|
||||||
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
||||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||||
@@ -148,12 +148,19 @@ public class ZHttpClient {
|
|||||||
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LauncherLogger.info("[NET] " + service.name() + ": "
|
||||||
|
+ (isHealthy ? "OK" : "unavailable") +
|
||||||
|
(serviceProxyMode.get(service) ? " (proxy mode)" : " (direct)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
|
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
|
System.out.println(ZAnsi.brightRed("Critical error: Zern server is unreachable!"));
|
||||||
}
|
}
|
||||||
|
LauncherLogger.warn("[NET] Critical: Zern server unreachable");
|
||||||
|
} else {
|
||||||
|
String ip = getClientPublicIp();
|
||||||
|
LauncherLogger.info("[NET] Client public IP: " + (ip != null ? ip : "unknown"));
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyTested.set(true);
|
proxyTested.set(true);
|
||||||
@@ -163,6 +170,29 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the Zern server what public IP it sees for this client (used to
|
||||||
|
* attribute user logs when login/registration fails). Returns null on failure.
|
||||||
|
*/
|
||||||
|
public static String getClientPublicIp() {
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(BASE_URL + "/launcher/ip"))
|
||||||
|
.timeout(Duration.ofMillis(15000))
|
||||||
|
.GET()
|
||||||
|
.header("User-Agent", "ZernMC-Launcher/IPCheck")
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = sendBounded(request, HttpResponse.BodyHandlers.ofString(), 15);
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
JSONObject json = new JSONObject(response.body());
|
||||||
|
return json.optString("ip", null);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public static void forceCheckMojangServices() {
|
public static void forceCheckMojangServices() {
|
||||||
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
|
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
|
||||||
|
|
||||||
@@ -183,6 +213,9 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean checkServiceHealth(ServiceType service) {
|
private static boolean checkServiceHealth(ServiceType service) {
|
||||||
|
if (service == ServiceType.ZERN_SERVER) {
|
||||||
|
return checkDirectConnection(BASE_URL);
|
||||||
|
}
|
||||||
return checkDirectConnection(service.getBaseUrl());
|
return checkDirectConnection(service.getBaseUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +271,7 @@ public class ZHttpClient {
|
|||||||
if (isHealthy && serviceProxyMode.get(service)) {
|
if (isHealthy && serviceProxyMode.get(service)) {
|
||||||
serviceProxyMode.put(service, false);
|
serviceProxyMode.put(service, false);
|
||||||
serviceFailCount.put(service, 0);
|
serviceFailCount.put(service, 0);
|
||||||
System.out.println(ZAnsi.green("[NET] " + service.name() + " restored, switched to direct connection"));
|
LauncherLogger.info("[NET] " + service.name() + " restored, switched to direct connection");
|
||||||
} else if (!isHealthy && !serviceProxyMode.get(service)) {
|
} else if (!isHealthy && !serviceProxyMode.get(service)) {
|
||||||
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
||||||
serviceFailCount.put(service, fails);
|
serviceFailCount.put(service, fails);
|
||||||
@@ -246,7 +279,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
if (fails >= MAX_FAILS_BEFORE_PROXY) {
|
if (fails >= MAX_FAILS_BEFORE_PROXY) {
|
||||||
serviceProxyMode.put(service, true);
|
serviceProxyMode.put(service, true);
|
||||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " unavailable, proxy mode enabled"));
|
LauncherLogger.warn("[NET] " + service.name() + " unavailable, proxy mode enabled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +323,7 @@ public class ZHttpClient {
|
|||||||
msg.contains("connection") ||
|
msg.contains("connection") ||
|
||||||
msg.contains("timeout") ||
|
msg.contains("timeout") ||
|
||||||
msg.contains("refused") ||
|
msg.contains("refused") ||
|
||||||
|
msg.contains("received within") ||
|
||||||
msg.contains("closed") ||
|
msg.contains("closed") ||
|
||||||
msg.contains("reset") ||
|
msg.contains("reset") ||
|
||||||
msg.contains("abort");
|
msg.contains("abort");
|
||||||
@@ -315,7 +349,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
|
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
|
||||||
serviceProxyMode.put(service, true);
|
serviceProxyMode.put(service, true);
|
||||||
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " blocked, switching to proxy"));
|
LauncherLogger.warn("[NET] " + service.name() + " blocked, switching to proxy");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +531,7 @@ public class ZHttpClient {
|
|||||||
last = e;
|
last = e;
|
||||||
if (!isRetryableError(e)) throw e;
|
if (!isRetryableError(e)) throw e;
|
||||||
if (attempt < maxAttempts) {
|
if (attempt < maxAttempts) {
|
||||||
System.out.println(ZAnsi.yellow("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint));
|
LauncherLogger.warn("[NET] Server request retry " + attempt + "/" + maxAttempts + " for " + endpoint + " (" + e.getMessage() + ")");
|
||||||
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
|
try { Thread.sleep(500L * attempt); } catch (InterruptedException ie) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw ie;
|
throw ie;
|
||||||
@@ -543,7 +577,7 @@ public class ZHttpClient {
|
|||||||
try {
|
try {
|
||||||
return getMojangVersionManifestViaServer();
|
return getMojangVersionManifestViaServer();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("[NET] Server manifest proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
|
LauncherLogger.warn("[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"));
|
return new JSONObject(getWithSmartProxy("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,17 +599,30 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
|
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
|
||||||
|
long deadline = System.currentTimeMillis() + 90_000;
|
||||||
|
LauncherLogger.info("[NET] Fetching version info for " + versionId + " via server proxy...");
|
||||||
try {
|
try {
|
||||||
return getMojangVersionViaServer(versionId);
|
JSONObject result = getMojangVersionViaServer(versionId);
|
||||||
|
LauncherLogger.info("[NET] Version info for " + versionId + " fetched via proxy (" + (System.currentTimeMillis() - (deadline - 90_000)) + "ms)");
|
||||||
|
return result;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta..."));
|
if (System.currentTimeMillis() >= deadline) {
|
||||||
|
throw new IOException("Version " + versionId + " fetch timed out after 90s: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
LauncherLogger.warn("[NET] Server version proxy failed (" + e.getMessage() + "), trying direct piston-meta...");
|
||||||
JSONObject manifest = getMojangVersionManifest();
|
JSONObject manifest = getMojangVersionManifest();
|
||||||
JSONArray versions = manifest.getJSONArray("versions");
|
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")));
|
if (System.currentTimeMillis() >= deadline) {
|
||||||
|
throw new IOException("Version " + versionId + " fetch timed out after 90s");
|
||||||
|
}
|
||||||
|
LauncherLogger.info("[NET] Fetching version info for " + versionId + " directly...");
|
||||||
|
JSONObject result = new JSONObject(getWithSmartProxy(v.getString("url")));
|
||||||
|
LauncherLogger.info("[NET] Version info for " + versionId + " fetched directly");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new IOException("Version " + versionId + " not found");
|
throw new IOException("Version " + versionId + " not found");
|
||||||
@@ -616,12 +663,12 @@ public class ZHttpClient {
|
|||||||
try {
|
try {
|
||||||
repairLibrariesFromJson(versionJson, minecraftDir);
|
repairLibrariesFromJson(versionJson, minecraftDir);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage()));
|
LauncherLogger.warn("[LIB] Repair skipped for " + versionDir.getFileName() + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.out.println(ZAnsi.yellow("[LIB] Repair scan failed: " + e.getMessage()));
|
LauncherLogger.warn("[LIB] Repair scan failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,12 +708,12 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
String libName = lib.optString("name", lib.optString("path", "?"));
|
String libName = lib.optString("name", lib.optString("path", "?"));
|
||||||
System.out.println(ZAnsi.yellow("[LIB] Repair failed for " + libName + ": " + e.getMessage()));
|
LauncherLogger.warn("[LIB] Repair failed for " + libName + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repaired > 0) {
|
if (repaired > 0) {
|
||||||
System.out.println(ZAnsi.green("[LIB] Repaired " + repaired + " missing libraries"));
|
LauncherLogger.info("[LIB] Repaired " + repaired + " missing libraries");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,6 +813,10 @@ public class ZHttpClient {
|
|||||||
return proxyTested.get();
|
return proxyTested.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean isZernServerHealthy() {
|
||||||
|
return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false);
|
||||||
|
}
|
||||||
|
|
||||||
public static Map<String, Object> getNetworkStatus() {
|
public static Map<String, Object> getNetworkStatus() {
|
||||||
Map<String, Object> status = new HashMap<>();
|
Map<String, Object> status = new HashMap<>();
|
||||||
status.put("initialized", proxyTested.get());
|
status.put("initialized", proxyTested.get());
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password" data-i18n="login.password">Password</label>
|
<label for="password" data-i18n="login.password">Password</label>
|
||||||
<input type="password" id="password" autocomplete="current-password" required>
|
<input type="password" id="password" autocomplete="current-password" required>
|
||||||
|
<span class="field-hint hidden" id="password-hint" data-i18n="login.passTooShort"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field hidden" id="confirm-field">
|
<div class="field hidden" id="confirm-field">
|
||||||
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
|
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
|
||||||
@@ -57,6 +58,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Offline Screen -->
|
||||||
|
<div id="offline-screen" class="screen hidden">
|
||||||
|
<div class="offline-container">
|
||||||
|
<div class="offline-badge">
|
||||||
|
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="offline-title" data-i18n="offline.title">Offline Mode</h2>
|
||||||
|
<p class="offline-subtitle" data-i18n="offline.subtitle">The Zern server is unreachable. You can still launch locally installed packs without an account.</p>
|
||||||
|
|
||||||
|
<form id="offline-form" class="login-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="offline-nickname" data-i18n="offline.nickname">Nickname</label>
|
||||||
|
<input type="text" id="offline-nickname" maxlength="16" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="offline-instance" data-i18n="offline.instance">Pack</label>
|
||||||
|
<select id="offline-instance"></select>
|
||||||
|
</div>
|
||||||
|
<p id="offline-error" class="error-msg hidden"></p>
|
||||||
|
<button type="submit" class="btn-primary" id="offline-launch-btn">
|
||||||
|
<span class="btn-label" data-i18n="offline.launch">Launch Offline</span>
|
||||||
|
<div class="spinner hidden"></div>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="offline-footer">
|
||||||
|
<button type="button" class="btn-ghost" id="offline-retry-btn" data-i18n="offline.retry">Try again online</button>
|
||||||
|
<button type="button" class="btn-ghost" id="offline-exit-btn" data-i18n="offline.exit">Exit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
<!-- Loading Overlay -->
|
||||||
<div id="loading-overlay" class="overlay hidden">
|
<div id="loading-overlay" class="overlay hidden">
|
||||||
<div class="loader-ring"></div>
|
<div class="loader-ring"></div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const LOCALES = {
|
|||||||
'login.hasAccount': 'Already have an account?',
|
'login.hasAccount': 'Already have an account?',
|
||||||
'login.confirm': 'Confirm Password',
|
'login.confirm': 'Confirm Password',
|
||||||
'login.passMismatch': 'Passwords do not match',
|
'login.passMismatch': 'Passwords do not match',
|
||||||
'login.passTooShort': 'Password must be at least 3 characters',
|
'login.passTooShort': 'Password must be at least 6 characters',
|
||||||
'login.signingIn': 'Signing in...',
|
'login.signingIn': 'Signing in...',
|
||||||
'loading.text': 'Loading...',
|
'loading.text': 'Loading...',
|
||||||
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
|
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
|
||||||
@@ -88,6 +88,24 @@ const LOCALES = {
|
|||||||
'toast.loggedOut': 'Logged out',
|
'toast.loggedOut': 'Logged out',
|
||||||
'toast.enterCredentials': 'Enter username and password',
|
'toast.enterCredentials': 'Enter username and password',
|
||||||
'toast.loginFailed': 'Login failed',
|
'toast.loginFailed': 'Login failed',
|
||||||
|
'login.errNetwork': 'Could not reach the server. Check your connection.',
|
||||||
|
'login.errInvalidInput': 'Invalid data. The password must be at least 6 characters.',
|
||||||
|
'login.errTaken': 'This username is already taken.',
|
||||||
|
'login.errBadCredentials': 'Wrong username or password.',
|
||||||
|
'login.errServer': 'The server returned an error. Try again later.',
|
||||||
|
'offline.title': 'Offline Mode',
|
||||||
|
'offline.subtitle': 'The Zern server is unreachable. You can still launch locally installed packs without an account.',
|
||||||
|
'offline.nickname': 'Nickname',
|
||||||
|
'offline.instance': 'Pack',
|
||||||
|
'offline.noInstances': 'No locally installed packs found',
|
||||||
|
'offline.launch': 'Launch Offline',
|
||||||
|
'offline.launching': 'Launching...',
|
||||||
|
'offline.needNickname': 'Enter a nickname',
|
||||||
|
'offline.nickTooLong': 'Nickname must be at most 16 characters',
|
||||||
|
'offline.needInstance': 'Select a pack',
|
||||||
|
'offline.retry': 'Try again online',
|
||||||
|
'offline.exit': 'Exit',
|
||||||
|
'offline.stillOffline': 'Server is still unreachable',
|
||||||
'toast.launching': 'Launching {name}...',
|
'toast.launching': 'Launching {name}...',
|
||||||
'toast.launchFailed': 'Launch failed',
|
'toast.launchFailed': 'Launch failed',
|
||||||
'toast.updated': '{name} updated to v{version}!',
|
'toast.updated': '{name} updated to v{version}!',
|
||||||
@@ -203,7 +221,7 @@ const LOCALES = {
|
|||||||
'login.hasAccount': 'Уже есть аккаунт?',
|
'login.hasAccount': 'Уже есть аккаунт?',
|
||||||
'login.confirm': 'Подтвердите пароль',
|
'login.confirm': 'Подтвердите пароль',
|
||||||
'login.passMismatch': 'Пароли не совпадают',
|
'login.passMismatch': 'Пароли не совпадают',
|
||||||
'login.passTooShort': 'Пароль должен быть минимум 3 символа',
|
'login.passTooShort': 'Пароль должен быть минимум 6 символов',
|
||||||
'login.signingIn': 'Вход...',
|
'login.signingIn': 'Вход...',
|
||||||
'loading.text': 'Загрузка...',
|
'loading.text': 'Загрузка...',
|
||||||
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
||||||
@@ -279,6 +297,24 @@ const LOCALES = {
|
|||||||
'toast.loggedOut': 'Вы вышли',
|
'toast.loggedOut': 'Вы вышли',
|
||||||
'toast.enterCredentials': 'Введите логин и пароль',
|
'toast.enterCredentials': 'Введите логин и пароль',
|
||||||
'toast.loginFailed': 'Ошибка входа',
|
'toast.loginFailed': 'Ошибка входа',
|
||||||
|
'login.errNetwork': 'Не удалось связаться с сервером. Проверьте подключение.',
|
||||||
|
'login.errInvalidInput': 'Некорректные данные. Пароль должен быть минимум 6 символов.',
|
||||||
|
'login.errTaken': 'Это имя пользователя уже занято.',
|
||||||
|
'login.errBadCredentials': 'Неверный логин или пароль.',
|
||||||
|
'login.errServer': 'Сервер вернул ошибку. Попробуйте позже.',
|
||||||
|
'offline.title': 'Оффлайн-режим',
|
||||||
|
'offline.subtitle': 'Сервер Zern недоступен. Вы можете запустить локально установленные сборки без аккаунта.',
|
||||||
|
'offline.nickname': 'Никнейм',
|
||||||
|
'offline.instance': 'Сборка',
|
||||||
|
'offline.noInstances': 'Локально установленные сборки не найдены',
|
||||||
|
'offline.launch': 'Запустить оффлайн',
|
||||||
|
'offline.launching': 'Запуск...',
|
||||||
|
'offline.needNickname': 'Введите никнейм',
|
||||||
|
'offline.nickTooLong': 'Никнейм должен быть не длиннее 16 символов',
|
||||||
|
'offline.needInstance': 'Выберите сборку',
|
||||||
|
'offline.retry': 'Попробовать снова онлайн',
|
||||||
|
'offline.exit': 'Выйти',
|
||||||
|
'offline.stillOffline': 'Сервер всё ещё недоступен',
|
||||||
'toast.launching': 'Запуск {name}...',
|
'toast.launching': 'Запуск {name}...',
|
||||||
'toast.launchFailed': 'Ошибка запуска',
|
'toast.launchFailed': 'Ошибка запуска',
|
||||||
'toast.updated': '{name} обновлён до v{version}!',
|
'toast.updated': '{name} обновлён до v{version}!',
|
||||||
@@ -534,6 +570,12 @@ class ZernMCLauncher {
|
|||||||
// ==================== AUTH ====================
|
// ==================== AUTH ====================
|
||||||
async checkAuth() {
|
async checkAuth() {
|
||||||
this.showLoading(true);
|
this.showLoading(true);
|
||||||
|
const status = await this.req('/offline/status');
|
||||||
|
if (status.success && status.offline) {
|
||||||
|
this.showOffline();
|
||||||
|
this.showLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const auto = await this.req('/auto-login');
|
const auto = await this.req('/auto-login');
|
||||||
if (auto.success && auto.autoLogin) {
|
if (auto.success && auto.autoLogin) {
|
||||||
this.state.account = auto.data;
|
this.state.account = auto.data;
|
||||||
@@ -565,7 +607,7 @@ class ZernMCLauncher {
|
|||||||
if (this._registerMode) {
|
if (this._registerMode) {
|
||||||
const confirm = document.getElementById('confirm-password').value;
|
const confirm = document.getElementById('confirm-password').value;
|
||||||
if (password !== confirm) { this.showLoginError(t('login.passMismatch')); return; }
|
if (password !== confirm) { this.showLoginError(t('login.passMismatch')); return; }
|
||||||
if (password.length < 3) { this.showLoginError(t('login.passTooShort')); return; }
|
if (password.length < 6) { this.showLoginError(t('login.passTooShort')); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -594,7 +636,48 @@ class ZernMCLauncher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.showLoginError(r.error || t('toast.loginFailed'));
|
this.showLoginError(this.friendlyError(r.error) || r.error || t('toast.loginFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
friendlyError(err) {
|
||||||
|
if (!err) return null;
|
||||||
|
const s = String(err);
|
||||||
|
if (/failed to fetch|fetch failed|networkerror|ENOTFOUND|ECONNREFUSED|ECONNRESET|timeout|aborted/i.test(s)) {
|
||||||
|
return t('login.errNetwork');
|
||||||
|
}
|
||||||
|
if (s.includes('HTTP 422') || /at least 6|string should have/i.test(s)) {
|
||||||
|
return t('login.errInvalidInput');
|
||||||
|
}
|
||||||
|
if (s.includes('HTTP 409') || /already taken/i.test(s)) {
|
||||||
|
return t('login.errTaken');
|
||||||
|
}
|
||||||
|
if (s.includes('HTTP 401') || /invalid.*(login|password)|wrong.*(login|password)/i.test(s)) {
|
||||||
|
return t('login.errBadCredentials');
|
||||||
|
}
|
||||||
|
if (/HTTP \d{3}/.test(s)) {
|
||||||
|
return t('login.errServer');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
validatePassword() {
|
||||||
|
if (!this._registerMode) return;
|
||||||
|
const password = document.getElementById('password');
|
||||||
|
const hint = document.getElementById('password-hint');
|
||||||
|
const btn = document.getElementById('login-btn');
|
||||||
|
const tooShort = password.value.length < 6;
|
||||||
|
if (tooShort) {
|
||||||
|
password.classList.add('invalid');
|
||||||
|
hint.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
password.classList.remove('invalid');
|
||||||
|
hint.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (btn) btn.disabled = tooShort;
|
||||||
|
const errEl = document.getElementById('login-error');
|
||||||
|
if (errEl && !errEl.classList.contains('hidden')) {
|
||||||
|
errEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,10 +705,14 @@ class ZernMCLauncher {
|
|||||||
if (isReg) {
|
if (isReg) {
|
||||||
confirmField.classList.remove('hidden');
|
confirmField.classList.remove('hidden');
|
||||||
document.getElementById('confirm-password').required = true;
|
document.getElementById('confirm-password').required = true;
|
||||||
|
this.validatePassword();
|
||||||
} else {
|
} else {
|
||||||
confirmField.classList.add('hidden');
|
confirmField.classList.add('hidden');
|
||||||
document.getElementById('confirm-password').required = false;
|
document.getElementById('confirm-password').required = false;
|
||||||
document.getElementById('confirm-password').value = '';
|
document.getElementById('confirm-password').value = '';
|
||||||
|
document.getElementById('login-btn').disabled = false;
|
||||||
|
document.getElementById('password').classList.remove('invalid');
|
||||||
|
document.getElementById('password-hint').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('login-error').classList.add('hidden');
|
document.getElementById('login-error').classList.add('hidden');
|
||||||
@@ -689,9 +776,113 @@ class ZernMCLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== OFFLINE MODE ====================
|
||||||
|
showOffline() {
|
||||||
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
|
document.getElementById('main-screen').classList.add('hidden');
|
||||||
|
document.getElementById('offline-screen').classList.remove('hidden');
|
||||||
|
this.loadOfflineInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadOfflineInstances() {
|
||||||
|
const select = document.getElementById('offline-instance');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '';
|
||||||
|
const r = await this.req('/instances');
|
||||||
|
let instances = (r.success && r.data) ? r.data : [];
|
||||||
|
instances = instances.filter(function(inst) { return inst.name; });
|
||||||
|
if (instances.length === 0) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.textContent = t('offline.noInstances');
|
||||||
|
opt.value = '';
|
||||||
|
select.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instances.forEach(function(inst) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = inst.name;
|
||||||
|
let label = inst.name;
|
||||||
|
if (inst.minecraftVersion) label += ' (' + inst.minecraftVersion + ')';
|
||||||
|
opt.textContent = label;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
this.state.offlineInstances = instances;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleOfflineLaunch(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const nickname = document.getElementById('offline-nickname').value.trim();
|
||||||
|
const name = document.getElementById('offline-instance').value;
|
||||||
|
const errEl = document.getElementById('offline-error');
|
||||||
|
const btn = document.getElementById('offline-launch-btn');
|
||||||
|
const label = btn.querySelector('.btn-label');
|
||||||
|
const spinner = btn.querySelector('.spinner');
|
||||||
|
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
if (!nickname) {
|
||||||
|
errEl.textContent = t('offline.needNickname');
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nickname.length > 16) {
|
||||||
|
errEl.textContent = t('offline.nickTooLong');
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
errEl.textContent = t('offline.needInstance');
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
label.textContent = t('offline.launching');
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
|
||||||
|
const r = await this.req('/offline-launch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: name, nickname: nickname }),
|
||||||
|
timeout: 120000
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
label.textContent = t('offline.launch');
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
|
||||||
|
if (r.success) {
|
||||||
|
this.toast(tr('toast.launched', null, {pid: String(r.data?.pid || '')}), 'success');
|
||||||
|
} else {
|
||||||
|
errEl.textContent = r.error || t('toast.launchFailed');
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryOnline() {
|
||||||
|
this.showLoading(true);
|
||||||
|
document.getElementById('offline-screen').classList.add('hidden');
|
||||||
|
const status = await this.req('/offline/status');
|
||||||
|
if (status.success && status.offline) {
|
||||||
|
this.showOffline();
|
||||||
|
this.showLoading(false);
|
||||||
|
this.toast(t('offline.stillOffline'), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== NAV ====================
|
// ==================== NAV ====================
|
||||||
bindEvents() {
|
bindEvents() {
|
||||||
document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e));
|
document.getElementById('login-form').addEventListener('submit', e => this.handleLogin(e));
|
||||||
|
const offlineForm = document.getElementById('offline-form');
|
||||||
|
if (offlineForm) offlineForm.addEventListener('submit', e => this.handleOfflineLaunch(e));
|
||||||
|
const retryBtn = document.getElementById('offline-retry-btn');
|
||||||
|
if (retryBtn) retryBtn.addEventListener('click', () => this.retryOnline());
|
||||||
|
const exitBtn = document.getElementById('offline-exit-btn');
|
||||||
|
if (exitBtn) exitBtn.addEventListener('click', () => this.req('/shutdown', { method: 'POST' }));
|
||||||
|
const pwInput = document.getElementById('password');
|
||||||
|
if (pwInput) pwInput.addEventListener('input', () => this.validatePassword());
|
||||||
|
const confirmInput = document.getElementById('confirm-password');
|
||||||
|
if (confirmInput) confirmInput.addEventListener('input', () => this.validatePassword());
|
||||||
document.getElementById('login-footer').addEventListener('click', () => this.toggleMode());
|
document.getElementById('login-footer').addEventListener('click', () => this.toggleMode());
|
||||||
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
|
document.getElementById('logout-btn').addEventListener('click', () => this.logout());
|
||||||
document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings'));
|
document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings'));
|
||||||
@@ -1868,13 +2059,28 @@ class ZernMCLauncher {
|
|||||||
modalP.classList.remove('hidden');
|
modalP.classList.remove('hidden');
|
||||||
const pct = (r.data.percent || 0) + '%';
|
const pct = (r.data.percent || 0) + '%';
|
||||||
const label = r.data.label || 'Installing...';
|
const label = r.data.label || 'Installing...';
|
||||||
|
const indeterminate = r.data.inProgress && (!r.data.total || r.data.total <= 0);
|
||||||
|
|
||||||
document.getElementById('progress-fill').style.width = pct;
|
const fill = document.getElementById('progress-fill');
|
||||||
|
if (indeterminate) {
|
||||||
|
fill.classList.add('indeterminate');
|
||||||
|
fill.style.width = '';
|
||||||
|
} else {
|
||||||
|
fill.classList.remove('indeterminate');
|
||||||
|
fill.style.width = pct;
|
||||||
|
}
|
||||||
document.getElementById('progress-label').textContent = label;
|
document.getElementById('progress-label').textContent = label;
|
||||||
|
|
||||||
const inlineProgress = document.getElementById('inline-install-progress');
|
const inlineProgress = document.getElementById('inline-install-progress');
|
||||||
if (!inlineProgress.classList.contains('hidden')) {
|
if (!inlineProgress.classList.contains('hidden')) {
|
||||||
document.getElementById('inline-progress-fill').style.width = pct;
|
const inlineFill = document.getElementById('inline-progress-fill');
|
||||||
|
if (indeterminate) {
|
||||||
|
inlineFill.classList.add('indeterminate');
|
||||||
|
inlineFill.style.width = '';
|
||||||
|
} else {
|
||||||
|
inlineFill.classList.remove('indeterminate');
|
||||||
|
inlineFill.style.width = pct;
|
||||||
|
}
|
||||||
document.getElementById('inline-progress-label').textContent = label;
|
document.getElementById('inline-progress-label').textContent = label;
|
||||||
const inlineStage = document.getElementById('inline-progress-stage');
|
const inlineStage = document.getElementById('inline-progress-stage');
|
||||||
if (r.data.stageName && r.data.stageCount > 1) {
|
if (r.data.stageName && r.data.stageCount > 1) {
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ body {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
.field input.invalid {
|
||||||
|
border-color: var(--error);
|
||||||
|
box-shadow: 0 0 0 2px rgba(248,113,113,0.15);
|
||||||
|
}
|
||||||
|
.field .field-hint {
|
||||||
|
font-size: 12px; color: var(--error); line-height: 1.4;
|
||||||
|
}
|
||||||
|
.field .field-hint.hidden { display: none; }
|
||||||
.field select {
|
.field select {
|
||||||
width: 100%; padding: 10px 12px; font-size: 15px;
|
width: 100%; padding: 10px 12px; font-size: 15px;
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
@@ -642,6 +650,15 @@ body {
|
|||||||
background: linear-gradient(90deg, var(--accent), #ff6b6b);
|
background: linear-gradient(90deg, var(--accent), #ff6b6b);
|
||||||
border-radius: 3px; transition: width 0.3s ease;
|
border-radius: 3px; transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
.progress-fill.indeterminate {
|
||||||
|
width: 30%;
|
||||||
|
transition: none;
|
||||||
|
animation: indeterminate 1.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% { margin-left: -30%; }
|
||||||
|
100% { margin-left: 100%; }
|
||||||
|
}
|
||||||
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
|
.progress-label { font-size: 13px; color: var(--text-secondary); margin-top: 8px; text-align: center; }
|
||||||
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
|
.progress-stage { font-size: 11px; color: var(--text-muted); margin-top: 4px; text-align: center; }
|
||||||
|
|
||||||
@@ -900,3 +917,40 @@ body {
|
|||||||
.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; }
|
.pack-entry.disabled .pack-entry-name::after { content: ' (' attr(data-disabled-text) ')'; color: var(--error); font-size: 11px; }
|
||||||
.admin-pack-disabled { color: var(--error) !important; font-weight: 600; }
|
.admin-pack-disabled { color: var(--error) !important; font-weight: 600; }
|
||||||
.admin-pack-enabled { color: var(--success) !important; font-weight: 600; }
|
.admin-pack-enabled { color: var(--success) !important; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ========== OFFLINE MODE ========== */
|
||||||
|
.offline-container {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 32px 32px 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.offline-badge {
|
||||||
|
width: 64px; height: 64px;
|
||||||
|
margin: 0 auto 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(233,69,96,0.12);
|
||||||
|
border: 1px solid rgba(233,69,96,0.3);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.offline-title { font-size: 22px; font-weight: 700; color: var(--text); }
|
||||||
|
.offline-subtitle {
|
||||||
|
color: var(--text-muted); font-size: 13px; margin: 8px 0 20px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.offline-container .login-form { text-align: left; }
|
||||||
|
.offline-footer {
|
||||||
|
display: flex; gap: 8px; justify-content: center; margin-top: 18px;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 9px 14px; font-size: 13px;
|
||||||
|
background: transparent; color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
cursor: pointer; font-family: var(--font);
|
||||||
|
transition: border-color 150ms ease, color 150ms ease;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|||||||
+7
-1
@@ -14,12 +14,13 @@
|
|||||||
|
|
||||||
<modules>
|
<modules>
|
||||||
<module>bootstrap</module>
|
<module>bootstrap</module>
|
||||||
|
<module>diag</module>
|
||||||
<module>launcher</module>
|
<module>launcher</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>1.0.16</revision>
|
<revision>1.0.16</revision>
|
||||||
<hotfix>3</hotfix>
|
<hotfix>13</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>
|
||||||
@@ -95,6 +96,11 @@
|
|||||||
<version>23.0.1</version>
|
<version>23.0.1</version>
|
||||||
<classifier>win</classifier>
|
<classifier>win</classifier>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dnsjava</groupId>
|
||||||
|
<artifactId>dnsjava</artifactId>
|
||||||
|
<version>3.6.5</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openjfx</groupId>
|
<groupId>org.openjfx</groupId>
|
||||||
<artifactId>javafx-media</artifactId>
|
<artifactId>javafx-media</artifactId>
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ async def register(body: RegisterRequest, request: Request):
|
|||||||
|
|
||||||
allowed, wait = check_rate_limit(ip)
|
allowed, wait = check_rate_limit(ip)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
|
logger.warning("register rate limited", username=body.username, client_ip=ip)
|
||||||
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -392,6 +393,7 @@ async def register(body: RegisterRequest, request: Request):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
|
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
|
||||||
raise HTTPException(409, "Пользователь с таким именем уже существует")
|
raise HTTPException(409, "Пользователь с таким именем уже существует")
|
||||||
|
|
||||||
uuid = generate_uuid()
|
uuid = generate_uuid()
|
||||||
@@ -405,6 +407,7 @@ async def register(body: RegisterRequest, request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
|
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
|
||||||
|
|
||||||
# Создаем сессию
|
# Создаем сессию
|
||||||
session_token = secrets.token_urlsafe(32)
|
session_token = secrets.token_urlsafe(32)
|
||||||
@@ -456,6 +459,7 @@ async def login(body: LoginRequest, request: Request):
|
|||||||
|
|
||||||
allowed, wait = check_rate_limit(ip)
|
allowed, wait = check_rate_limit(ip)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
|
logger.warning("login rate limited", username=body.username, client_ip=ip)
|
||||||
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -465,15 +469,20 @@ async def login(body: LoginRequest, request: Request):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not user or not verify_password(body.password, user["password_hash"]):
|
if not user or not verify_password(body.password, user["password_hash"]):
|
||||||
|
logger.warning("login failed: bad credentials", username=body.username, client_ip=ip)
|
||||||
record_login_attempt(ip, False)
|
record_login_attempt(ip, False)
|
||||||
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
||||||
|
|
||||||
if not user["is_active"]:
|
if not user["is_active"]:
|
||||||
|
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
|
||||||
raise HTTPException(403, "Аккаунт деактивирован")
|
raise HTTPException(403, "Аккаунт деактивирован")
|
||||||
|
|
||||||
if user["banned_until"] and user["banned_until"] > time.time():
|
if user["banned_until"] and user["banned_until"] > time.time():
|
||||||
|
logger.warning("login failed: account banned", username=body.username, client_ip=ip)
|
||||||
raise HTTPException(403, "Аккаунт забанен")
|
raise HTTPException(403, "Аккаунт забанен")
|
||||||
|
|
||||||
|
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
|
||||||
|
|
||||||
record_login_attempt(ip, True)
|
record_login_attempt(ip, True)
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+89
-10
@@ -48,8 +48,11 @@ WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
|||||||
|
|
||||||
# Mirror configuration
|
# Mirror configuration
|
||||||
LAUNCHER_MIRRORS = {
|
LAUNCHER_MIRRORS = {
|
||||||
"main": "https://api.zernmc.ru",
|
"main": "https://api.zern.cc", # primary
|
||||||
"mirror-1": "https://api.zernmc.online",
|
"mirror-1": "https://api.zernmc.ru", # legacy
|
||||||
|
"mirror-2": "https://api.zernmc.online", # legacy
|
||||||
|
"geo-pl": "https://api.pl.zern.cc",
|
||||||
|
"geo-swe": "https://api.swe.zern.cc",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Server role: "main" or "mirror"
|
# Server role: "main" or "mirror"
|
||||||
@@ -71,6 +74,9 @@ BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
|||||||
# Crash reports directory
|
# Crash reports directory
|
||||||
CRASH_REPORTS_DIR = Path("data/crash_reports")
|
CRASH_REPORTS_DIR = Path("data/crash_reports")
|
||||||
|
|
||||||
|
# Network diagnostics reports directory
|
||||||
|
DIAG_REPORTS_DIR = Path("data/diag_reports")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -1350,6 +1356,14 @@ def generate_launcher_builds_meta():
|
|||||||
# CLI exe is not shipped to users
|
# CLI exe is not shipped to users
|
||||||
if rel_path == "zernmc-cli.exe":
|
if rel_path == "zernmc-cli.exe":
|
||||||
continue
|
continue
|
||||||
|
# zernmc.exe embeds the launcher version, so its hash changes on
|
||||||
|
# every build even when the bootstrap code is unchanged. Exclude it
|
||||||
|
# from the incremental update: a new exe is only delivered via a
|
||||||
|
# full ZIP reinstall. Without this the bootstrap would re-download
|
||||||
|
# and re-stage its own exe on every launcher version bump, and the
|
||||||
|
# detached .cmd helper fails with "file not found".
|
||||||
|
if rel_path == "zernmc.exe":
|
||||||
|
continue
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
|
|
||||||
# Calculate hash
|
# Calculate hash
|
||||||
@@ -1391,6 +1405,8 @@ def generate_version_meta(version_path: Path, version: str) -> dict:
|
|||||||
for file_path in version_path.rglob("*"):
|
for file_path in version_path.rglob("*"):
|
||||||
if file_path.is_file() and file_path.name != "meta.json":
|
if file_path.is_file() and file_path.name != "meta.json":
|
||||||
rel_path = str(file_path.relative_to(version_path))
|
rel_path = str(file_path.relative_to(version_path))
|
||||||
|
if rel_path in ("zernmc.exe", "zernmc-cli.exe"):
|
||||||
|
continue
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
file_hash = calculate_file_hash(file_path)
|
file_hash = calculate_file_hash(file_path)
|
||||||
files.append({
|
files.append({
|
||||||
@@ -1488,13 +1504,18 @@ def extract_new_format_versions():
|
|||||||
# Find all ZernMC-win-*.zip files
|
# Find all ZernMC-win-*.zip files
|
||||||
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
|
new_format_zips = list(BUILDS_DIR.glob("ZernMC-win-*.zip"))
|
||||||
|
|
||||||
|
total = len(new_format_zips)
|
||||||
|
extracted = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
for zip_file in new_format_zips:
|
for zip_file in new_format_zips:
|
||||||
version = zip_file.stem.replace("ZernMC-win-", "")
|
version = zip_file.stem.replace("ZernMC-win-", "")
|
||||||
extract_dir = VERSIONS_DIR / version
|
extract_dir = VERSIONS_DIR / version
|
||||||
|
|
||||||
# Skip if already extracted and meta exists
|
# Skip if already extracted and meta exists
|
||||||
if extract_dir.exists() and (extract_dir / "meta.json").exists():
|
if extract_dir.exists() and (extract_dir / "meta.json").exists():
|
||||||
logger.debug(f"Version {version} already extracted")
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
|
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
|
||||||
@@ -1511,9 +1532,13 @@ def extract_new_format_versions():
|
|||||||
# writes versions/<v>/meta.json, so every scan would re-extract the zip.
|
# writes versions/<v>/meta.json, so every scan would re-extract the zip.
|
||||||
generate_version_meta(extract_dir, version)
|
generate_version_meta(extract_dir, version)
|
||||||
|
|
||||||
|
extracted += 1
|
||||||
logger.info(f"Extracted {zip_file.name} successfully")
|
logger.info(f"Extracted {zip_file.name} successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failed += 1
|
||||||
logger.error(f"Failed to extract {zip_file.name}: {e}")
|
logger.error(f"Failed to extract {zip_file.name}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Version scan complete: {total} total, {extracted} extracted, {skipped} skipped (already present), {failed} failed")
|
||||||
|
|
||||||
|
|
||||||
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
||||||
@@ -1594,6 +1619,14 @@ def get_legacy_zips() -> list:
|
|||||||
return zips
|
return zips
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/launcher/ip")
|
||||||
|
async def get_launcher_client_ip(request: Request):
|
||||||
|
"""Return the public IP that the server sees for this client."""
|
||||||
|
from middleware import get_client_ip
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
return {"ip": client_ip}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/launcher/version")
|
@app.get("/launcher/version")
|
||||||
async def get_launcher_version():
|
async def get_launcher_version():
|
||||||
"""Return launcher version information"""
|
"""Return launcher version information"""
|
||||||
@@ -1965,6 +1998,36 @@ async def receive_crash_report(request: Request):
|
|||||||
return {"status": "ok", "id": report_id}
|
return {"status": "ok", "id": report_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/diag/upload")
|
||||||
|
async def receive_diag_report(request: Request):
|
||||||
|
"""Receive and store network diagnostics reports from the diag utility"""
|
||||||
|
try:
|
||||||
|
body = await request.body()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to read body")
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty body")
|
||||||
|
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
name = request.query_params.get("name", "").strip()
|
||||||
|
if not name or "/" in name or "\\" in name or ".." in name:
|
||||||
|
name = f"diag_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{ip}.log"
|
||||||
|
|
||||||
|
report_path = DIAG_REPORTS_DIR / name
|
||||||
|
|
||||||
|
try:
|
||||||
|
DIAG_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
async with aiofiles.open(report_path, "w", encoding="utf-8") as f:
|
||||||
|
await f.write(body.decode("utf-8", errors="replace"))
|
||||||
|
logger.info(f"Diag report saved: {report_path.name} from {ip}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save diag report: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save report")
|
||||||
|
|
||||||
|
return {"status": "ok", "name": report_path.name}
|
||||||
|
|
||||||
|
|
||||||
# ====================== НОВОСТИ ======================
|
# ====================== НОВОСТИ ======================
|
||||||
|
|
||||||
NEWS_DIR = Path(__file__).parent / "news"
|
NEWS_DIR = Path(__file__).parent / "news"
|
||||||
@@ -2195,19 +2258,34 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
|||||||
# Сначала получаем манифест, чтобы найти URL версии
|
# Сначала получаем манифест, чтобы найти URL версии
|
||||||
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||||
|
|
||||||
cache_key = f"version_url_{version_id}"
|
# Cache the final version JSON, not just the version URL. piston-meta is
|
||||||
version_url = proxy_cache.get(cache_key)
|
# slow/unreliable from some regions, so full serialization of the version
|
||||||
|
# JSON (plus a warm-up) keeps installs from stalling on install time.
|
||||||
|
cache_key = f"version_json_{version_id}"
|
||||||
|
cached = proxy_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
logger.info(f"Proxy served from cache: Mojang version {version_id}")
|
||||||
|
return JSONResponse(content=cached)
|
||||||
|
|
||||||
|
version_url = proxy_cache.get(f"version_url_{version_id}")
|
||||||
|
|
||||||
if not version_url:
|
if not version_url:
|
||||||
try:
|
try:
|
||||||
response = await proxy_client.get(manifest_url)
|
# Reuse the manifest already cached by /proxy/mojang/version_manifest
|
||||||
response.raise_for_status()
|
manifest = proxy_cache.get(manifest_url)
|
||||||
manifest = response.json()
|
if manifest is None:
|
||||||
|
response = await proxy_client.get(manifest_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
manifest = response.json()
|
||||||
|
proxy_cache[manifest_url] = manifest
|
||||||
|
logger.info("Proxy success: Mojang manifest")
|
||||||
|
else:
|
||||||
|
logger.info("Proxy served manifest from cache")
|
||||||
|
|
||||||
for version in manifest.get("versions", []):
|
for version in manifest.get("versions", []):
|
||||||
if version.get("id") == version_id:
|
if version.get("id") == version_id:
|
||||||
version_url = version.get("url")
|
version_url = version.get("url")
|
||||||
proxy_cache[cache_key] = version_url
|
proxy_cache[f"version_url_{version_id}"] = version_url
|
||||||
break
|
break
|
||||||
|
|
||||||
if not version_url:
|
if not version_url:
|
||||||
@@ -2222,6 +2300,7 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
proxy_cache[cache_key] = data
|
||||||
logger.info(f"Proxy success: Mojang version {version_id}")
|
logger.info(f"Proxy success: Mojang version {version_id}")
|
||||||
return JSONResponse(content=data)
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user