Слияние ui -> main #1
@@ -20,3 +20,4 @@ packs/
|
||||
.__pycache__
|
||||
.pytest_cache
|
||||
.venvtodo.txt
|
||||
*.bak
|
||||
|
||||
@@ -78,6 +78,19 @@ public class Bootstrap {
|
||||
log("Server version: " + 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();
|
||||
log("Primary server: " + BASE_URL);
|
||||
log("Mirrors available: " + (MIRRORS.size() + 1));
|
||||
@@ -125,6 +138,119 @@ public class Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
ClassLoader parent = Bootstrap.class.getClassLoader();
|
||||
URL[] urls = { getLauncherJar().toUri().toURL() };
|
||||
@@ -172,6 +298,12 @@ public class Bootstrap {
|
||||
cmd.add(getLauncherJar().toAbsolutePath().toString());
|
||||
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);
|
||||
pb.directory(baseDir.toFile());
|
||||
pb.redirectErrorStream(false);
|
||||
|
||||
@@ -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 cliMode = argList.contains("--cli");
|
||||
|
||||
if (argList.contains("--offline")) {
|
||||
System.setProperty("zernmc.offline", "true");
|
||||
LauncherLogger.info("Offline mode requested (--offline)");
|
||||
}
|
||||
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
return;
|
||||
@@ -65,6 +70,7 @@ public class Main {
|
||||
}
|
||||
|
||||
private static void startCLI() throws IOException {
|
||||
DomainSelector.selectBest();
|
||||
ZHttpClient.checkAllServicesOnStartup(true);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Checking authorization..."));
|
||||
|
||||
@@ -88,7 +88,7 @@ public class LauncherAPI {
|
||||
}
|
||||
return ApiResponse.success(mcVersions);
|
||||
} 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");
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public class LauncherAPI {
|
||||
}
|
||||
return ApiResponse.success(versions);
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -26,10 +26,15 @@ public class AuthService {
|
||||
return ApiResponse.error(result.error != null ? result.error : "Registration failed");
|
||||
} catch (Exception e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg != null && msg.contains("HTTP 409")) {
|
||||
return ApiResponse.error("Username already taken");
|
||||
if (msg != null) {
|
||||
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());
|
||||
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) {
|
||||
return ApiResponse.error("Auth error: " + e.getMessage());
|
||||
return ApiResponse.error(AuthManager.friendlyConnectionError(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-3
@@ -24,7 +24,7 @@ public class LaunchService {
|
||||
|
||||
static {
|
||||
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 -> {
|
||||
try {
|
||||
p.destroy();
|
||||
@@ -67,13 +67,44 @@ public class LaunchService {
|
||||
|
||||
LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")");
|
||||
|
||||
long t0 = System.currentTimeMillis();
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
LaunchOptions options = createOptions();
|
||||
options.setUsername(AuthManager.getUsername());
|
||||
options.setAccessToken(AuthManager.getAccessToken());
|
||||
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);
|
||||
LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms");
|
||||
LauncherLogger.info("Generated command for " + instanceName + ":");
|
||||
|
||||
@@ -113,17 +113,62 @@ public class AuthManager {
|
||||
saveSession();
|
||||
userInfo = fetchUserInfo();
|
||||
return AuthResult.ok();
|
||||
} else if (resp.statusCode() == 422) {
|
||||
return AuthResult.fail("Validation error: " + extractError(resp.body()));
|
||||
} else {
|
||||
return AuthResult.fail(extractError(resp.body()));
|
||||
return AuthResult.fail(friendlyHttpError(resp.statusCode(), resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Connection error: " + e.getMessage());
|
||||
LauncherLogger.warn("authRequest failed (" + endpoint + "): " + 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() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import java.io.IOException;
|
||||
@@ -142,7 +144,7 @@ public class Instance {
|
||||
this.serverPackName = meta.serverPackName;
|
||||
this.presetId = meta.presetId;
|
||||
} 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;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -51,7 +53,7 @@ public class InstanceManager {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Не удалось удалить: " + path);
|
||||
LauncherLogger.warn("Не удалось удалить: " + path);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
|
||||
+16
-17
@@ -71,12 +71,12 @@ public class MinecraftLib {
|
||||
* Stub - will be expanded
|
||||
*/
|
||||
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
|
||||
boolean mcInstalled = installMinecraft(minecraftVersion);
|
||||
if (!mcInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Minecraft " + minecraftVersion));
|
||||
LauncherLogger.error("Failed to install Minecraft " + minecraftVersion);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,40 +84,39 @@ public class MinecraftLib {
|
||||
if ("fabric".equalsIgnoreCase(loaderType)) {
|
||||
boolean fabricInstalled = installFabric(minecraftVersion, loaderVersion);
|
||||
if (!fabricInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
LauncherLogger.error("Failed to install Fabric");
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||
if (!forgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
LauncherLogger.error("Failed to install Forge");
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||
if (!neoforgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
LauncherLogger.error("Failed to install NeoForge");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
//Launch
|
||||
public void launch(LaunchOptions options) throws Exception {
|
||||
System.out.println(ZAnsi.brightGreen("Launching pack: " + instance.getName()));
|
||||
LauncherLogger.info("Launching pack: " + instance.getName());
|
||||
cleanupOldLoaders();
|
||||
validateJarFiles();
|
||||
|
||||
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||
List<String> command = builder.build(options);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Launch command (" + command.size() + " args):"));
|
||||
command.forEach(arg -> System.out.println(" " + arg));
|
||||
LauncherLogger.info("Launch command (" + command.size() + " args) written below");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(instance.getPath().toFile());
|
||||
@@ -126,12 +125,12 @@ public class MinecraftLib {
|
||||
Path cmdLogFile = instance.getPath().resolve("launch-command.log");
|
||||
try {
|
||||
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) {
|
||||
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();
|
||||
|
||||
Process process = pb.start();
|
||||
@@ -168,7 +167,7 @@ public class MinecraftLib {
|
||||
outThread.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) {
|
||||
@@ -215,7 +214,7 @@ public class MinecraftLib {
|
||||
|
||||
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
|
||||
Path libraries = instance.getPath().resolve("libraries");
|
||||
@@ -247,14 +246,14 @@ public class MinecraftLib {
|
||||
.filter(p -> !isValidJar(p))
|
||||
.forEach(p -> {
|
||||
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);
|
||||
} catch (IOException e) {
|
||||
System.out.println(ZAnsi.red(" Failed to delete: " + p.getFileName()));
|
||||
LauncherLogger.error("Failed to delete: " + p.getFileName());
|
||||
}
|
||||
});
|
||||
} 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());
|
||||
LauncherLogger.info("installOrUpdatePack: Fabric install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Fabric"));
|
||||
LauncherLogger.error("installOrUpdatePack: Fabric install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install Fabric");
|
||||
reportProgress("Failed to install Fabric", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
@@ -195,8 +194,7 @@ public class PackDownloader {
|
||||
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: NeoForge install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install NeoForge"));
|
||||
LauncherLogger.error("installOrUpdatePack: NeoForge install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install NeoForge");
|
||||
reportProgress("Failed to install NeoForge", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
@@ -206,8 +204,7 @@ public class PackDownloader {
|
||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: Forge install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Forge"));
|
||||
LauncherLogger.error("installOrUpdatePack: Forge install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install Forge");
|
||||
reportProgress("Failed to install Forge", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
@@ -217,16 +214,14 @@ public class PackDownloader {
|
||||
boolean success = lib.installMinecraft(manifest.getMinecraftVersion());
|
||||
LauncherLogger.info("installOrUpdatePack: Vanilla install result=" + success);
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft"));
|
||||
LauncherLogger.error("installOrUpdatePack: Vanilla install failed");
|
||||
LauncherLogger.error("[INSTALL] Failed to install Vanilla Minecraft");
|
||||
reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
LauncherLogger.info("installOrUpdatePack: Minecraft+loader install completed successfully");
|
||||
} else {
|
||||
LauncherLogger.info("installOrUpdatePack: Minecraft already installed, skipping");
|
||||
System.out.println(ZAnsi.green("Minecraft already installed, skipping..."));
|
||||
LauncherLogger.info("Minecraft already installed, skipping...");
|
||||
}
|
||||
|
||||
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 (manifest.files == null || manifest.files.isEmpty()) {
|
||||
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);
|
||||
|
||||
saveUserFilesSnapshot(localFiles);
|
||||
@@ -253,7 +248,7 @@ public class PackDownloader {
|
||||
instance.setAssetIndex(resolveAssetIndex(manifest));
|
||||
|
||||
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;
|
||||
}
|
||||
LauncherLogger.info("installOrUpdatePack: manifest has " + manifest.files.size() + " files, proceeding to diff");
|
||||
@@ -302,7 +297,7 @@ public class PackDownloader {
|
||||
instance.setLoaderVersion(manifest.getLoaderVersion());
|
||||
instance.setAssetIndex(resolveAssetIndex(manifest));
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Pack installed successfully!"));
|
||||
LauncherLogger.info("Pack installed successfully!");
|
||||
LauncherLogger.info("installOrUpdatePack: SUCCESS");
|
||||
} else {
|
||||
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 {
|
||||
System.out.println(ZAnsi.cyan("Checking updates for " + instance.getName() + "..."));
|
||||
LauncherLogger.info("Checking updates for " + instance.getName() + "...");
|
||||
|
||||
PackManifest manifest = getPackManifest(packName);
|
||||
int serverVersion = manifest.getVersion();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow("Update available: v" + instance.getServerVersion() + " → v" + serverVersion));
|
||||
LauncherLogger.info("Update available: v" + instance.getServerVersion() + " -> v" + serverVersion);
|
||||
|
||||
// Scan local files
|
||||
Map<String, String> localFiles = scanLocalFiles();
|
||||
@@ -351,7 +346,7 @@ public class PackDownloader {
|
||||
Set<String> protectedFiles = getUserModifiedFiles(localFiles);
|
||||
if (!protectedFiles.isEmpty()) {
|
||||
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
|
||||
@@ -366,7 +361,7 @@ public class PackDownloader {
|
||||
saveUserFilesSnapshot(updatedHashes);
|
||||
|
||||
instance.setServerVersion(serverVersion);
|
||||
System.out.println(ZAnsi.brightGreen("Pack updated to v" + serverVersion));
|
||||
LauncherLogger.info("Pack updated to v" + serverVersion);
|
||||
}
|
||||
|
||||
return success;
|
||||
@@ -519,34 +514,30 @@ public class PackDownloader {
|
||||
|
||||
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());
|
||||
System.out.println(ZAnsi.cyan("\nApplying changes:"));
|
||||
System.out.println(" Download: " + diff.getToDownload().size() + " files");
|
||||
System.out.println(" Delete: " + diff.getToDelete().size() + " files");
|
||||
if (!protectedFiles.isEmpty()) {
|
||||
System.out.println(ZAnsi.yellow(" Protected: " + protectedFiles.size() + " user-modified files (skipped)"));
|
||||
}
|
||||
LauncherLogger.info("Apply changes: Download " + diff.getToDownload().size() + " files, Delete " + diff.getToDelete().size() + " files"
|
||||
+ (!protectedFiles.isEmpty() ? ", Protected " + protectedFiles.size() + " user-modified files (skipped)" : ""));
|
||||
|
||||
// Create directories if needed
|
||||
try {
|
||||
Files.createDirectories(instance.getPath());
|
||||
} catch (IOException e) {
|
||||
System.err.println(ZAnsi.red("Error creating directories: " + e.getMessage()));
|
||||
LauncherLogger.error("Error creating directories: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete files (skip protected)
|
||||
for (String filePath : diff.getToDelete()) {
|
||||
if (protectedFiles.contains(filePath)) {
|
||||
System.out.println(ZAnsi.yellow(" Skipped delete (user-modified): " + filePath));
|
||||
LauncherLogger.info("Skipped delete (user-modified): " + filePath);
|
||||
continue;
|
||||
}
|
||||
Path fullPath = instance.getPath().resolve(filePath);
|
||||
try {
|
||||
if (Files.deleteIfExists(fullPath)) {
|
||||
System.out.println(ZAnsi.yellow(" Deleted: " + filePath));
|
||||
LauncherLogger.info("Deleted: " + filePath);
|
||||
}
|
||||
} 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)) {
|
||||
skipped++;
|
||||
System.out.println(ZAnsi.yellow(" Skipped (user-modified): " + path));
|
||||
LauncherLogger.info("Skipped (user-modified): " + path);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -597,7 +588,6 @@ public class PackDownloader {
|
||||
}
|
||||
if (!hashMatch) {
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
@@ -642,8 +632,7 @@ public class PackDownloader {
|
||||
|
||||
} catch (Exception e) {
|
||||
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()));
|
||||
System.err.println(ZAnsi.yellow(" Skipping file " + path + " due to download error"));
|
||||
LauncherLogger.error("Download error " + path + ": " + e.getMessage() + " - skipping file");
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -653,7 +642,7 @@ public class PackDownloader {
|
||||
ProgressBar.finish("Download");
|
||||
}
|
||||
if (skipped > 0) {
|
||||
System.out.println(ZAnsi.cyan(" Skipped " + skipped + " user-modified files"));
|
||||
LauncherLogger.info("Skipped " + skipped + " user-modified files");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
+44
-6
@@ -6,7 +6,9 @@ import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -33,6 +35,8 @@ public class FabricInstaller {
|
||||
instance.setAssetIndex(assetIndex);
|
||||
instance.setMinecraftVersion(minecraftVersion);
|
||||
|
||||
ProgressBar.showIndeterminate("Preparing Fabric installer");
|
||||
|
||||
String installerVersion = getLatestInstallerVersion();
|
||||
String installerUrl = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/"
|
||||
+ installerVersion + "/fabric-installer-" + installerVersion + ".jar";
|
||||
@@ -40,7 +44,7 @@ public class FabricInstaller {
|
||||
Path installerJar = instancePath.resolve("fabric-installer.jar");
|
||||
|
||||
if (!Files.exists(installerJar)) {
|
||||
ProgressBar.show("Downloading Fabric Installer", 0, 100, "%");
|
||||
ProgressBar.showIndeterminate("Downloading Fabric Installer");
|
||||
downloadFileWithFallback(installerUrl, installerJar);
|
||||
ProgressBar.finish("Fabric Installer downloaded");
|
||||
}
|
||||
@@ -48,6 +52,7 @@ public class FabricInstaller {
|
||||
preDownloadFabricLibraries(minecraftVersion, loaderVersion);
|
||||
|
||||
LauncherLogger.info("Running Fabric Installer...");
|
||||
ProgressBar.showIndeterminate("Running Fabric Installer (may take a few minutes)");
|
||||
|
||||
String fabricVersionId = "fabric-loader-" + loaderVersion + "-" + minecraftVersion;
|
||||
|
||||
@@ -60,17 +65,50 @@ public class FabricInstaller {
|
||||
"-noprofile"
|
||||
);
|
||||
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
boolean finished = process.waitFor(10, TimeUnit.MINUTES);
|
||||
if (!finished) {
|
||||
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
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;
|
||||
}
|
||||
int exitCode = process.exitValue();
|
||||
exitCode = process.exitValue();
|
||||
}
|
||||
|
||||
if (exitCode != 0) {
|
||||
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.utils.JavaResolver;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.*;
|
||||
@@ -57,15 +58,19 @@ public class ModLoaderInstaller {
|
||||
instance.setAssetIndex(assetIndex);
|
||||
createLauncherProfile();
|
||||
|
||||
ProgressBar.showIndeterminate("Preparing " + type.loaderName + " installer");
|
||||
|
||||
String installerUrl = buildInstallerUrl(mcVersion, loaderVersion, type);
|
||||
String jarName = type.loaderName + "-installer.jar";
|
||||
Path installerJar = instance.getPath().resolve(jarName);
|
||||
|
||||
LauncherLogger.info("Downloading " + type.loaderName + " Installer...");
|
||||
ProgressBar.showIndeterminate("Downloading " + type.loaderName + " Installer");
|
||||
ZHttpClient.downloadFileWithSmartProxy(installerUrl, installerJar);
|
||||
|
||||
LauncherLogger.info("Running " + type.loaderName + " Installer...");
|
||||
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);
|
||||
|
||||
@@ -145,6 +150,11 @@ public class ModLoaderInstaller {
|
||||
while ((line = reader.readLine()) != null) {
|
||||
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")) {
|
||||
LauncherLogger.info(" -> " + line);
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
@@ -218,6 +228,7 @@ public class ModLoaderInstaller {
|
||||
|
||||
private void downloadMissingLibraries(LoaderType type) throws Exception {
|
||||
LauncherLogger.info("Checking and downloading missing libraries...");
|
||||
ProgressBar.showIndeterminate("Downloading missing libraries");
|
||||
ZHttpClient.repairMissingLibraries(instance.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
+12
-6
@@ -86,15 +86,17 @@ public class VersionInstaller {
|
||||
String versionJson;
|
||||
try {
|
||||
// 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();
|
||||
LauncherLogger.info("[VERSION] Version json fetched for " + versionId);
|
||||
} 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;
|
||||
}
|
||||
try {
|
||||
Files.writeString(versionDir.resolve(versionId + ".json"), versionJson);
|
||||
} 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;
|
||||
}
|
||||
ProgressBar.show("Version info", 1, 1, "files");
|
||||
@@ -103,16 +105,20 @@ public class VersionInstaller {
|
||||
|
||||
// client.jar
|
||||
ProgressBar.show("Downloading client.jar", 0, 1, "files");
|
||||
LauncherLogger.info("[VERSION] Downloading client.jar for " + versionId + "...");
|
||||
Path clientJar = versionDir.resolve(versionId + ".jar");
|
||||
if (!Files.exists(clientJar)) {
|
||||
try {
|
||||
ZHttpClient.downloadFileWithSmartProxy(
|
||||
versionData.getJSONObject("downloads").getJSONObject("client").getString("url"),
|
||||
clientJar);
|
||||
LauncherLogger.info("[VERSION] client.jar downloaded for " + versionId);
|
||||
} 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;
|
||||
}
|
||||
} else {
|
||||
LauncherLogger.info("[VERSION] client.jar already present for " + versionId);
|
||||
}
|
||||
ProgressBar.show("Client.jar", 1, 1, "files");
|
||||
|
||||
@@ -241,7 +247,7 @@ public class VersionInstaller {
|
||||
try {
|
||||
ZHttpClient.downloadFileWithSmartProxy(url, target);
|
||||
} 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++;
|
||||
@@ -314,7 +320,7 @@ public class VersionInstaller {
|
||||
synchronized (this) {
|
||||
failed[0]++;
|
||||
}
|
||||
System.err.println("Failed to download " + hash);
|
||||
LauncherLogger.error("[VERSION] Failed to download asset " + hash);
|
||||
} else {
|
||||
try { Thread.sleep(500 * attempt); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
@@ -330,7 +336,7 @@ public class VersionInstaller {
|
||||
.get(10, TimeUnit.MINUTES);
|
||||
} catch (TimeoutException e) {
|
||||
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) {
|
||||
// one of the futures was cancelled
|
||||
} 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.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -36,7 +37,7 @@ public class LaunchCommandBuilder {
|
||||
}
|
||||
|
||||
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<>();
|
||||
|
||||
@@ -57,7 +58,7 @@ public class LaunchCommandBuilder {
|
||||
// with placeholder substitution. Do NOT manually add -p, -cp,
|
||||
// --add-modules, --add-opens, or -DignoreList. The version.json
|
||||
// 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
|
||||
// so securejarhandler creates module "minecraft" (from Automatic-Module-Name)
|
||||
@@ -92,9 +93,9 @@ public class LaunchCommandBuilder {
|
||||
boolean referencesClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs);
|
||||
if (referencesClasspath) {
|
||||
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 {
|
||||
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);
|
||||
@@ -107,9 +108,9 @@ public class LaunchCommandBuilder {
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -141,7 +142,7 @@ public class LaunchCommandBuilder {
|
||||
if (!hasCpInJson) {
|
||||
command.add("-cp");
|
||||
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
|
||||
@@ -151,9 +152,9 @@ public class LaunchCommandBuilder {
|
||||
}
|
||||
if (mainClass == null || mainClass.isEmpty()) {
|
||||
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 {
|
||||
System.out.println(ZAnsi.green(" Main class from manifest: " + mainClass));
|
||||
LauncherLogger.info(ZAnsi.green(" Main class from manifest: " + mainClass));
|
||||
}
|
||||
command.add(mainClass);
|
||||
|
||||
@@ -167,9 +168,9 @@ public class LaunchCommandBuilder {
|
||||
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 {
|
||||
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(getModloaderLaunchArgs());
|
||||
}
|
||||
@@ -183,24 +184,24 @@ public class LaunchCommandBuilder {
|
||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||
|
||||
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(writeClasspathFile(buildVanillaClasspath()));
|
||||
String mainClass = null;
|
||||
if (manifest != null) {
|
||||
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()) {
|
||||
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.addAll(getVanillaGameArguments(options));
|
||||
} else if (manifest != null) {
|
||||
String classpath = buildClasspathFromManifest(manifest, true);
|
||||
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(writeClasspathFile(buildVanillaClasspath()));
|
||||
command.add(getVanillaMainClass());
|
||||
@@ -229,20 +230,20 @@ public class LaunchCommandBuilder {
|
||||
if (versionJson != null && Files.exists(versionJson)) {
|
||||
String content = Files.readString(versionJson);
|
||||
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);
|
||||
manifest.resolveParent(instance.getPath().resolve("versions"));
|
||||
if (manifest.getInheritsFrom() != null) {
|
||||
System.out.println(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
|
||||
LauncherLogger.info(ZAnsi.cyan(" inheritsFrom: " + manifest.getInheritsFrom()));
|
||||
}
|
||||
return manifest;
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||
LauncherLogger.info(ZAnsi.yellow("version.json not found for " + instance.getName()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
@@ -496,14 +497,14 @@ public class LaunchCommandBuilder {
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
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 {
|
||||
// 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
|
||||
// fail to load the base resource pack (grey panorama, no sounds).
|
||||
Path indexPath = instance.getPath().resolve("assets").resolve("indexes").resolve(assetIndex + ".json");
|
||||
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"))) {
|
||||
java.util.List<Path> existing = stream
|
||||
.filter(p -> p.getFileName().toString().endsWith(".json"))
|
||||
@@ -512,16 +513,16 @@ public class LaunchCommandBuilder {
|
||||
if (!existing.isEmpty()) {
|
||||
String found = existing.get(existing.size() - 1).getFileName().toString()
|
||||
.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;
|
||||
} 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) {
|
||||
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("--username");
|
||||
@@ -612,7 +613,7 @@ public class LaunchCommandBuilder {
|
||||
List<String> paths = new ArrayList<>();
|
||||
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();
|
||||
boolean isForgeLike = "forge".equals(loaderType) || "neoforge".equals(loaderType);
|
||||
@@ -628,7 +629,7 @@ public class LaunchCommandBuilder {
|
||||
if (isValidJar(libPath)) {
|
||||
paths.add(libPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
Files.delete(libPath);
|
||||
}
|
||||
} else {
|
||||
@@ -638,7 +639,7 @@ public class LaunchCommandBuilder {
|
||||
if (isValidJar(fallbackPath)) {
|
||||
paths.add(fallbackPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt library, deleting: " + lib.name));
|
||||
Files.delete(fallbackPath);
|
||||
}
|
||||
} else {
|
||||
@@ -647,23 +648,23 @@ public class LaunchCommandBuilder {
|
||||
Path found = scanForJar(librariesDir, artifactName);
|
||||
if (found != null) {
|
||||
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());
|
||||
} 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);
|
||||
}
|
||||
} 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) {
|
||||
System.out.println(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
|
||||
System.out.println(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Version ID: " + getVersionId() + " (MC: " + instance.getMinecraftVersion() + ")"));
|
||||
LauncherLogger.info(ZAnsi.cyan(" Paths dir: " + instance.getPath().resolve("versions")));
|
||||
}
|
||||
|
||||
if (includeVersionJar) {
|
||||
@@ -671,9 +672,9 @@ public class LaunchCommandBuilder {
|
||||
if (versionJar != null) {
|
||||
if (isValidJar(versionJar)) {
|
||||
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 {
|
||||
System.out.println(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
|
||||
LauncherLogger.info(ZAnsi.yellow(" Corrupt version jar, deleting: " + versionJar.getFileName()));
|
||||
Files.delete(versionJar);
|
||||
}
|
||||
}
|
||||
@@ -785,7 +786,7 @@ public class LaunchCommandBuilder {
|
||||
Path targetJar = targetDir.resolve(versionId + ".jar");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -795,11 +796,11 @@ public class LaunchCommandBuilder {
|
||||
Path vanillaJar = versionsDir.resolve(mcVersion).resolve(mcVersion + ".jar");
|
||||
if (Files.exists(vanillaJar) && isValidJar(vanillaJar)) {
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
.orElse(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;
|
||||
}
|
||||
} catch (Exception e) { /* continue */ }
|
||||
@@ -848,7 +849,7 @@ public class LaunchCommandBuilder {
|
||||
.findFirst()
|
||||
.orElse(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;
|
||||
}
|
||||
} catch (Exception e) { /* continue */ }
|
||||
@@ -903,12 +904,12 @@ public class LaunchCommandBuilder {
|
||||
for (String candidate : candidates) {
|
||||
Path jarPath = librariesDir.resolve(candidate);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.yellow(" No Forge client/universal jar found!"));
|
||||
LauncherLogger.info(ZAnsi.yellow(" No Forge client/universal jar found!"));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1047,7 +1048,7 @@ public class LaunchCommandBuilder {
|
||||
Path tempFile = Files.createTempFile("zernmc-cp-", ".txt");
|
||||
Files.writeString(tempFile, classpath);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -75,7 +77,7 @@ public class VersionManifest {
|
||||
this.parent = new VersionManifest(json);
|
||||
this.parent.resolveParent(versionsDir);
|
||||
} 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.launch.LaunchCommandBuilder;
|
||||
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.Version;
|
||||
|
||||
@@ -216,7 +217,7 @@ public class JFXLauncher extends Application {
|
||||
try {
|
||||
String existingVersion = Files.readString(versionFile).trim();
|
||||
if (existingVersion.equals(currentVersion)) {
|
||||
System.out.println("[JFX] Assets up to date (v" + currentVersion + ")");
|
||||
log("Assets up to date (v" + currentVersion + ")");
|
||||
return;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
@@ -233,16 +234,16 @@ public class JFXLauncher extends Application {
|
||||
|
||||
String serverVersion = getServerVersion();
|
||||
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)) {
|
||||
System.out.println("[JFX] Assets loaded via meta");
|
||||
log("Assets loaded via meta");
|
||||
Files.writeString(versionFile, currentVersion);
|
||||
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);
|
||||
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
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);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[JFX] Error loading via meta: " + e.getMessage());
|
||||
log("Error loading assets via meta: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -363,6 +364,7 @@ public class JFXLauncher extends Application {
|
||||
log("Starting background network init...");
|
||||
Thread netInitThread = new Thread(() -> {
|
||||
try {
|
||||
DomainSelector.selectBest();
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
log("Network init complete");
|
||||
} catch (Exception e) {
|
||||
@@ -391,9 +393,9 @@ public class JFXLauncher extends Application {
|
||||
engine.setOnAlert(e -> {
|
||||
String msg = e.getData();
|
||||
if (msg.startsWith("[LOG] ")) {
|
||||
System.out.println("[JS] " + msg.substring(6));
|
||||
log("[JS] " + msg.substring(6));
|
||||
} else if (msg.startsWith("[ERR] ")) {
|
||||
System.err.println("[JS] " + msg.substring(6));
|
||||
log("[JS ERROR] " + msg.substring(6));
|
||||
} else {
|
||||
log("[UI] Alert: " + msg);
|
||||
}
|
||||
@@ -493,6 +495,8 @@ public class JFXLauncher extends Application {
|
||||
server.createContext("/api/account", this::handleAccount);
|
||||
server.createContext("/api/instances", this::handleInstances);
|
||||
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/progress", this::handleInstallProgress);
|
||||
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()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log("Login error: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||
log("Login error: " + AuthManager.friendlyConnectionError(e));
|
||||
sendJson(exchange, Map.of("success", false, "error", AuthManager.friendlyConnectionError(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,7 +644,6 @@ public class JFXLauncher extends Application {
|
||||
try {
|
||||
boolean loggedIn = AuthManager.isLoggedIn();
|
||||
boolean authExists = AuthManager.authFileExists();
|
||||
System.out.println("[AUTH] handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
||||
log("handleAutoLogin: isLoggedIn=" + loggedIn + " authFile=" + authExists);
|
||||
if (AuthManager.tryAutoLogin()) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
@@ -650,15 +652,12 @@ public class JFXLauncher extends Application {
|
||||
data.put("role", AuthManager.getRole());
|
||||
data.put("roleName", AuthManager.getRoleName());
|
||||
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());
|
||||
} else {
|
||||
System.out.println("[AUTH] handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
||||
log("handleAutoLogin: no valid session (authFile=" + authExists + ")");
|
||||
sendJson(exchange, Map.of("success", false, "autoLogin", false));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[AUTH] handleAutoLogin error: " + e.getMessage());
|
||||
log("handleAutoLogin 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) {
|
||||
if (!installInProgress.compareAndSet(false, true)) {
|
||||
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
|
||||
@@ -769,8 +816,8 @@ public class JFXLauncher extends Application {
|
||||
boolean success = false;
|
||||
|
||||
if (!ZHttpClient.isNetworkInitialized()) {
|
||||
log("Network init not finished, checking Mojang services synchronously...");
|
||||
ZHttpClient.forceCheckMojangServices();
|
||||
log("Network init not finished, running full service check (CLI-style)...");
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
}
|
||||
|
||||
if ("zernmc".equalsIgnoreCase(loader)) {
|
||||
@@ -895,17 +942,16 @@ public class JFXLauncher extends Application {
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
|
||||
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() {
|
||||
@Override
|
||||
public synchronized void onLog(String line) {
|
||||
try {
|
||||
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
|
||||
os.write(data.getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
} catch (Exception e) {
|
||||
removeLogConsumer(this);
|
||||
}
|
||||
public void onLog(String line) {
|
||||
queue.offer(line);
|
||||
}
|
||||
};
|
||||
consumerRef[0] = consumer;
|
||||
@@ -915,9 +961,13 @@ public class JFXLauncher extends Application {
|
||||
Thread.currentThread().setName("sse-logs-stream");
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
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();
|
||||
Thread.sleep(10000);
|
||||
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
||||
}
|
||||
} catch (Exception ignored) {} finally {
|
||||
@@ -939,17 +989,14 @@ public class JFXLauncher extends Application {
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
|
||||
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() {
|
||||
@Override
|
||||
public synchronized void onLog(String line) {
|
||||
try {
|
||||
String data = "data: " + line.replace("\n", "").replace("\r", "") + "\n\n";
|
||||
os.write(data.getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
} catch (Exception e) {
|
||||
removeGameLogConsumer(this);
|
||||
}
|
||||
public void onLog(String line) {
|
||||
queue.offer(line);
|
||||
}
|
||||
};
|
||||
consumerRef[0] = consumer;
|
||||
@@ -959,9 +1006,13 @@ public class JFXLauncher extends Application {
|
||||
Thread.currentThread().setName("sse-game-logs-stream");
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
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();
|
||||
Thread.sleep(10000);
|
||||
} catch (InterruptedException e) { break; } catch (Exception e) { break; }
|
||||
}
|
||||
} catch (Exception ignored) {} finally {
|
||||
@@ -1080,7 +1131,7 @@ public class JFXLauncher extends Application {
|
||||
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||
}
|
||||
} 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 entry = "[" + timestamp + "] " + msg;
|
||||
synchronized (launcherLogBuffer) {
|
||||
|
||||
@@ -13,7 +13,7 @@ public class Config {
|
||||
private static final Properties props = new Properties();
|
||||
|
||||
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 int windowWidth = 1280;
|
||||
private static volatile int windowHeight = 720;
|
||||
@@ -144,6 +144,11 @@ public class Config {
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
public static void setServerUrl(String url) {
|
||||
serverUrl = url != null && !url.isBlank() ? url : serverUrl;
|
||||
save();
|
||||
}
|
||||
|
||||
public static String getLastUsername() {
|
||||
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.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class LauncherLogger {
|
||||
|
||||
@@ -25,6 +26,7 @@ public class LauncherLogger {
|
||||
private static final int STDOUT_QUEUE_CAPACITY = 2048;
|
||||
private static final BlockingQueue<String> stdoutQueue = new LinkedBlockingQueue<>(STDOUT_QUEUE_CAPACITY);
|
||||
private static final Thread stdoutWriterThread;
|
||||
private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[A-Za-z]");
|
||||
|
||||
static {
|
||||
stdoutWriterThread = new Thread(() -> {
|
||||
@@ -97,9 +99,12 @@ public class LauncherLogger {
|
||||
// block application threads.
|
||||
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
|
||||
// in the GUI. The bridge is a safe no-op when JavaFX is unavailable.
|
||||
JFXBridge.appendLauncherLogFromLogger(line);
|
||||
JFXBridge.appendLauncherLogFromLogger(plainLine);
|
||||
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
@@ -120,7 +125,7 @@ public class LauncherLogger {
|
||||
}
|
||||
if (acquired) {
|
||||
try {
|
||||
Files.writeString(logFile, line + "\n", StandardOpenOption.APPEND);
|
||||
Files.writeString(logFile, plainLine + "\n", StandardOpenOption.APPEND);
|
||||
if (t != null) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
|
||||
@@ -4,14 +4,27 @@ import me.sashegdev.zernmc.launcher.ui.jfx.JFXBridge;
|
||||
|
||||
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 {
|
||||
|
||||
private static final int BAR_LENGTH = 40;
|
||||
private static final DecimalFormat DF = new DecimalFormat("#.##");
|
||||
|
||||
private static String currentLabel = "";
|
||||
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) {
|
||||
currentLabel = label;
|
||||
currentTotal = total;
|
||||
@@ -19,19 +32,11 @@ public class ProgressBar {
|
||||
JFXBridge.setInstallProgress(label, (int) current, (int) total);
|
||||
|
||||
if (total <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
LauncherLogger.info(label + " ...");
|
||||
return;
|
||||
}
|
||||
double progress = (double) current / total;
|
||||
int filled = (int) (progress * BAR_LENGTH);
|
||||
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();
|
||||
int percent = (int) ((double) current / total * 100);
|
||||
LauncherLogger.info(label + " " + percent + "% (" + current + "/" + total + " " + unit + ")");
|
||||
}
|
||||
|
||||
public static void showDownload(String label, long downloaded, long totalBytes) {
|
||||
@@ -40,25 +45,13 @@ public class ProgressBar {
|
||||
|
||||
JFXBridge.setInstallProgress(label + " " + formatBytes(downloaded) + "/" + formatBytes(totalBytes), (int) downloaded, (int) totalBytes);
|
||||
|
||||
if (totalBytes <= 0) {
|
||||
System.out.print("\r" + ZAnsi.cyan(label) + " ...");
|
||||
return;
|
||||
// Throttle console/file logging: called once per chunk during downloads.
|
||||
long now = System.currentTimeMillis();
|
||||
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) {
|
||||
@@ -67,18 +60,51 @@ public class ProgressBar {
|
||||
|
||||
JFXBridge.setInstallProgress(label, (int) current, (int) (total > 0 ? total : 100));
|
||||
|
||||
if (total <= 0) {
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (current / 1024) % 4;
|
||||
System.out.print("\r" + label + " [" + spinner[idx] + "] " + formatBytes(current));
|
||||
} else {
|
||||
show(label, (int) ((current * 100) / total), 100, unit);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDownloadLogMs >= DOWNLOAD_LOG_INTERVAL_MS) {
|
||||
lastDownloadLogMs = now;
|
||||
LauncherLogger.info(label + ": " + formatBytes(current)
|
||||
+ (total > 0 ? "/" + formatBytes(total) + " " + unit : ""));
|
||||
}
|
||||
}
|
||||
|
||||
public static void finish(String message) {
|
||||
System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓"));
|
||||
System.out.flush();
|
||||
LauncherLogger.info(message + " done");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@@ -86,8 +112,7 @@ public class ProgressBar {
|
||||
}
|
||||
|
||||
public static void clearLine() {
|
||||
System.out.print("\r" + " ".repeat(110) + "\r");
|
||||
System.out.flush();
|
||||
// No-op: console progress now goes through LauncherLogger.
|
||||
}
|
||||
|
||||
public static String formatBytes(long bytes) {
|
||||
|
||||
@@ -49,7 +49,7 @@ public class ZHttpClient {
|
||||
.executor(CLIENT_EXECUTOR)
|
||||
.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 proxyTested = new AtomicBoolean(false);
|
||||
@@ -64,7 +64,7 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
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_MAVEN("https://maven.fabricmc.net", false),
|
||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||
@@ -148,12 +148,19 @@ public class ZHttpClient {
|
||||
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 (verbose) {
|
||||
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);
|
||||
@@ -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() {
|
||||
System.out.println(ZAnsi.cyan("Forcing Mojang services check..."));
|
||||
|
||||
@@ -183,6 +213,9 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
private static boolean checkServiceHealth(ServiceType service) {
|
||||
if (service == ServiceType.ZERN_SERVER) {
|
||||
return checkDirectConnection(BASE_URL);
|
||||
}
|
||||
return checkDirectConnection(service.getBaseUrl());
|
||||
}
|
||||
|
||||
@@ -238,7 +271,7 @@ public class ZHttpClient {
|
||||
if (isHealthy && serviceProxyMode.get(service)) {
|
||||
serviceProxyMode.put(service, false);
|
||||
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)) {
|
||||
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
||||
serviceFailCount.put(service, fails);
|
||||
@@ -246,7 +279,7 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY) {
|
||||
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("timeout") ||
|
||||
msg.contains("refused") ||
|
||||
msg.contains("received within") ||
|
||||
msg.contains("closed") ||
|
||||
msg.contains("reset") ||
|
||||
msg.contains("abort");
|
||||
@@ -315,7 +349,7 @@ public class ZHttpClient {
|
||||
|
||||
if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
|
||||
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;
|
||||
if (!isRetryableError(e)) throw e;
|
||||
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) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw ie;
|
||||
@@ -543,7 +577,7 @@ public class ZHttpClient {
|
||||
try {
|
||||
return getMojangVersionManifestViaServer();
|
||||
} 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"));
|
||||
}
|
||||
}
|
||||
@@ -565,17 +599,30 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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();
|
||||
JSONArray versions = manifest.getJSONArray("versions");
|
||||
|
||||
for (int i = 0; i < versions.length(); i++) {
|
||||
JSONObject v = versions.getJSONObject(i);
|
||||
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");
|
||||
@@ -616,12 +663,12 @@ public class ZHttpClient {
|
||||
try {
|
||||
repairLibrariesFromJson(versionJson, minecraftDir);
|
||||
} 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) {
|
||||
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) {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
|
||||
public static boolean isZernServerHealthy() {
|
||||
return serviceHealthy.getOrDefault(ServiceType.ZERN_SERVER, false);
|
||||
}
|
||||
|
||||
public static Map<String, Object> getNetworkStatus() {
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
status.put("initialized", proxyTested.get());
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<div class="field">
|
||||
<label for="password" data-i18n="login.password">Password</label>
|
||||
<input type="password" id="password" autocomplete="current-password" required>
|
||||
<span class="field-hint hidden" id="password-hint" data-i18n="login.passTooShort"></span>
|
||||
</div>
|
||||
<div class="field hidden" id="confirm-field">
|
||||
<label for="confirm-password" data-i18n="login.confirm">Confirm Password</label>
|
||||
@@ -57,6 +58,38 @@
|
||||
</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 -->
|
||||
<div id="loading-overlay" class="overlay hidden">
|
||||
<div class="loader-ring"></div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const LOCALES = {
|
||||
'login.hasAccount': 'Already have an account?',
|
||||
'login.confirm': 'Confirm Password',
|
||||
'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...',
|
||||
'loading.text': 'Loading...',
|
||||
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
|
||||
@@ -88,6 +88,24 @@ const LOCALES = {
|
||||
'toast.loggedOut': 'Logged out',
|
||||
'toast.enterCredentials': 'Enter username and password',
|
||||
'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.launchFailed': 'Launch failed',
|
||||
'toast.updated': '{name} updated to v{version}!',
|
||||
@@ -203,7 +221,7 @@ const LOCALES = {
|
||||
'login.hasAccount': 'Уже есть аккаунт?',
|
||||
'login.confirm': 'Подтвердите пароль',
|
||||
'login.passMismatch': 'Пароли не совпадают',
|
||||
'login.passTooShort': 'Пароль должен быть минимум 3 символа',
|
||||
'login.passTooShort': 'Пароль должен быть минимум 6 символов',
|
||||
'login.signingIn': 'Вход...',
|
||||
'loading.text': 'Загрузка...',
|
||||
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
||||
@@ -279,6 +297,24 @@ const LOCALES = {
|
||||
'toast.loggedOut': 'Вы вышли',
|
||||
'toast.enterCredentials': 'Введите логин и пароль',
|
||||
'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.launchFailed': 'Ошибка запуска',
|
||||
'toast.updated': '{name} обновлён до v{version}!',
|
||||
@@ -534,6 +570,12 @@ class ZernMCLauncher {
|
||||
// ==================== AUTH ====================
|
||||
async checkAuth() {
|
||||
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');
|
||||
if (auto.success && auto.autoLogin) {
|
||||
this.state.account = auto.data;
|
||||
@@ -565,7 +607,7 @@ class ZernMCLauncher {
|
||||
if (this._registerMode) {
|
||||
const confirm = document.getElementById('confirm-password').value;
|
||||
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;
|
||||
@@ -594,7 +636,48 @@ class ZernMCLauncher {
|
||||
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) {
|
||||
confirmField.classList.remove('hidden');
|
||||
document.getElementById('confirm-password').required = true;
|
||||
this.validatePassword();
|
||||
} else {
|
||||
confirmField.classList.add('hidden');
|
||||
document.getElementById('confirm-password').required = false;
|
||||
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');
|
||||
@@ -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 ====================
|
||||
bindEvents() {
|
||||
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('logout-btn').addEventListener('click', () => this.logout());
|
||||
document.getElementById('settings-btn').addEventListener('click', () => this.switchView('settings'));
|
||||
@@ -1868,13 +2059,28 @@ class ZernMCLauncher {
|
||||
modalP.classList.remove('hidden');
|
||||
const pct = (r.data.percent || 0) + '%';
|
||||
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;
|
||||
|
||||
const inlineProgress = document.getElementById('inline-install-progress');
|
||||
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;
|
||||
const inlineStage = document.getElementById('inline-progress-stage');
|
||||
if (r.data.stageName && r.data.stageCount > 1) {
|
||||
|
||||
@@ -99,6 +99,14 @@ body {
|
||||
border-color: var(--accent);
|
||||
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 {
|
||||
width: 100%; padding: 10px 12px; font-size: 15px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
@@ -642,6 +650,15 @@ body {
|
||||
background: linear-gradient(90deg, var(--accent), #ff6b6b);
|
||||
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-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; }
|
||||
.admin-pack-disabled { color: var(--error) !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>
|
||||
<module>bootstrap</module>
|
||||
<module>diag</module>
|
||||
<module>launcher</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.16</revision>
|
||||
<hotfix>3</hotfix>
|
||||
<hotfix>13</hotfix>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -95,6 +96,11 @@
|
||||
<version>23.0.1</version>
|
||||
<classifier>win</classifier>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dnsjava</groupId>
|
||||
<artifactId>dnsjava</artifactId>
|
||||
<version>3.6.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-media</artifactId>
|
||||
|
||||
@@ -383,6 +383,7 @@ async def register(body: RegisterRequest, request: Request):
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
if not allowed:
|
||||
logger.warning("register rate limited", username=body.username, client_ip=ip)
|
||||
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||
|
||||
with get_db() as conn:
|
||||
@@ -392,6 +393,7 @@ async def register(body: RegisterRequest, request: Request):
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
logger.warning("register failed: username taken", username=body.username, client_ip=ip)
|
||||
raise HTTPException(409, "Пользователь с таким именем уже существует")
|
||||
|
||||
uuid = generate_uuid()
|
||||
@@ -405,6 +407,7 @@ async def register(body: RegisterRequest, request: Request):
|
||||
)
|
||||
|
||||
user_id = cursor.lastrowid
|
||||
logger.info("register ok", username=body.username, user_id=user_id, client_ip=ip)
|
||||
|
||||
# Создаем сессию
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
@@ -456,6 +459,7 @@ async def login(body: LoginRequest, request: Request):
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
if not allowed:
|
||||
logger.warning("login rate limited", username=body.username, client_ip=ip)
|
||||
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||
|
||||
with get_db() as conn:
|
||||
@@ -465,15 +469,20 @@ async def login(body: LoginRequest, request: Request):
|
||||
).fetchone()
|
||||
|
||||
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)
|
||||
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
||||
|
||||
if not user["is_active"]:
|
||||
logger.warning("login failed: account deactivated", username=body.username, client_ip=ip)
|
||||
raise HTTPException(403, "Аккаунт деактивирован")
|
||||
|
||||
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, "Аккаунт забанен")
|
||||
|
||||
logger.info("login ok", username=user["username"], user_id=user["id"], client_ip=ip)
|
||||
|
||||
record_login_attempt(ip, True)
|
||||
|
||||
now = time.time()
|
||||
|
||||
+85
-6
@@ -48,8 +48,11 @@ WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "https://api.zernmc.ru",
|
||||
"mirror-1": "https://api.zernmc.online",
|
||||
"main": "https://api.zern.cc", # primary
|
||||
"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"
|
||||
@@ -71,6 +74,9 @@ BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
||||
# Crash reports directory
|
||||
CRASH_REPORTS_DIR = Path("data/crash_reports")
|
||||
|
||||
# Network diagnostics reports directory
|
||||
DIAG_REPORTS_DIR = Path("data/diag_reports")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -1350,6 +1356,14 @@ def generate_launcher_builds_meta():
|
||||
# CLI exe is not shipped to users
|
||||
if rel_path == "zernmc-cli.exe":
|
||||
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()
|
||||
|
||||
# Calculate hash
|
||||
@@ -1391,6 +1405,8 @@ def generate_version_meta(version_path: Path, version: str) -> dict:
|
||||
for file_path in version_path.rglob("*"):
|
||||
if file_path.is_file() and file_path.name != "meta.json":
|
||||
rel_path = str(file_path.relative_to(version_path))
|
||||
if rel_path in ("zernmc.exe", "zernmc-cli.exe"):
|
||||
continue
|
||||
stat = file_path.stat()
|
||||
file_hash = calculate_file_hash(file_path)
|
||||
files.append({
|
||||
@@ -1488,13 +1504,18 @@ def extract_new_format_versions():
|
||||
# Find all ZernMC-win-*.zip files
|
||||
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:
|
||||
version = zip_file.stem.replace("ZernMC-win-", "")
|
||||
extract_dir = VERSIONS_DIR / version
|
||||
|
||||
# Skip if already extracted and meta exists
|
||||
if extract_dir.exists() and (extract_dir / "meta.json").exists():
|
||||
logger.debug(f"Version {version} already extracted")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
logger.info(f"Extracting {zip_file.name} to versions/{version}/...")
|
||||
@@ -1511,10 +1532,14 @@ def extract_new_format_versions():
|
||||
# writes versions/<v>/meta.json, so every scan would re-extract the zip.
|
||||
generate_version_meta(extract_dir, version)
|
||||
|
||||
extracted += 1
|
||||
logger.info(f"Extracted {zip_file.name} successfully")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
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 ЛАУНЧЕР МЕТА СИСТЕМА ======================
|
||||
|
||||
@@ -1594,6 +1619,14 @@ def get_legacy_zips() -> list:
|
||||
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")
|
||||
async def get_launcher_version():
|
||||
"""Return launcher version information"""
|
||||
@@ -1965,6 +1998,36 @@ async def receive_crash_report(request: Request):
|
||||
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"
|
||||
@@ -2195,19 +2258,34 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
||||
# Сначала получаем манифест, чтобы найти URL версии
|
||||
manifest_url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||
|
||||
cache_key = f"version_url_{version_id}"
|
||||
version_url = proxy_cache.get(cache_key)
|
||||
# Cache the final version JSON, not just the version URL. piston-meta is
|
||||
# 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:
|
||||
try:
|
||||
# Reuse the manifest already cached by /proxy/mojang/version_manifest
|
||||
manifest = proxy_cache.get(manifest_url)
|
||||
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", []):
|
||||
if version.get("id") == version_id:
|
||||
version_url = version.get("url")
|
||||
proxy_cache[cache_key] = version_url
|
||||
proxy_cache[f"version_url_{version_id}"] = version_url
|
||||
break
|
||||
|
||||
if not version_url:
|
||||
@@ -2222,6 +2300,7 @@ async def proxy_mojang_version(version_id: str, request: Request):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
proxy_cache[cache_key] = data
|
||||
logger.info(f"Proxy success: Mojang version {version_id}")
|
||||
return JSONResponse(content=data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user