From cdc81e4e8d22ede6204433968fa6b68fa79c1142 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Tue, 18 Aug 2026 22:19:55 +0000 Subject: [PATCH] =?UTF-8?q?v1.0.16.13=20=E2=80=94=20TSPU/SNI-obkhod:=20dom?= =?UTF-8?q?ain=20selection=20at=20login=20+=20dynamic=20mirrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add DomainSelector: probe every API candidate at launcher startup, pick fastest reachable (primary api.zern.cc, legacy .ru/.online, geo pl/swe); integrate into CLI start and JFX network init - Config.setServerUrl() + persist chosen domain in launcher.properties - ZHttpClient: default BASE_URL and ZERN_SERVER health-check follow the selected domain (runtime failover instead of hardcoded api.zernmc.ru) - server: LAUNCHER_MIRRORS main=api.zern.cc (primary), legacy .ru/.online, geo-pl api.pl.zern.cc, geo-swe api.swe.zern.cc; /launcher/mirrors exposes them - diag: check all TLD+geo API hosts (api.{pl,ru,swe}.zern.cc, .ru, .online) for DNS/TCP/HTTP, known server IPs for all 4 geo nodes - reverse-proxy geo boxes (pl, swe) -> main:1582 so clients bypass TSPU via an unblocked SNI; ru left as-is (VLESS VPN box, not proxying API) --- .gitignore | 1 + .../sashegdev/zernmc/launcher/Bootstrap.java | 132 +++++++++ launcher/diag/pom.xml | 75 +++++ .../zernmc/launcher/diag/DiagCheck.java | 29 ++ .../zernmc/launcher/diag/DiagConstants.java | 55 ++++ .../zernmc/launcher/diag/DiagMain.java | 79 ++++++ .../zernmc/launcher/diag/DiagnosticsUI.java | 194 +++++++++++++ .../zernmc/launcher/diag/DnsResolver.java | 128 +++++++++ .../zernmc/launcher/diag/NetProbe.java | 58 ++++ .../launcher/diag/NetworkDiagnostics.java | 263 ++++++++++++++++++ .../zernmc/launcher/diag/ReportSender.java | 69 +++++ .../zernmc/launcher/diag/DiagCheckTest.java | 35 +++ .../zernmc/launcher/diag/DnsResolverTest.java | 52 ++++ .../launcher/diag/ReportSenderTest.java | 24 ++ .../java/sashegdev/zernmc/launcher/Main.java | 6 + .../zernmc/launcher/api/LauncherAPI.java | 4 +- .../zernmc/launcher/api/auth/AuthService.java | 15 +- .../launcher/api/launch/LaunchService.java | 37 ++- .../zernmc/launcher/auth/AuthManager.java | 55 +++- .../zernmc/launcher/minecraft/Instance.java | 4 +- .../launcher/minecraft/InstanceManager.java | 4 +- .../launcher/minecraft/MinecraftLib.java | 33 ++- .../launcher/minecraft/PackDownloader.java | 55 ++-- .../minecraft/installer/FabricInstaller.java | 56 +++- .../installer/ModLoaderInstaller.java | 11 + .../minecraft/installer/VersionInstaller.java | 18 +- .../launch/LaunchCommandBuilder.java | 93 ++++--- .../minecraft/launch/VersionManifest.java | 4 +- .../zernmc/launcher/ui/jfx/JFXLauncher.java | 133 ++++++--- .../zernmc/launcher/utils/Config.java | 7 +- .../zernmc/launcher/utils/DomainSelector.java | 126 +++++++++ .../zernmc/launcher/utils/LauncherLogger.java | 9 +- .../zernmc/launcher/utils/ProgressBar.java | 121 ++++---- .../zernmc/launcher/utils/ZHttpClient.java | 79 +++++- launcher/launcher/src/resources/ui/index.html | 33 +++ .../launcher/src/resources/ui/launcher.js | 218 ++++++++++++++- launcher/launcher/src/resources/ui/style.css | 54 ++++ launcher/pom.xml | 8 +- server/auth.py | 9 + server/main.py | 99 ++++++- 40 files changed, 2233 insertions(+), 252 deletions(-) create mode 100644 launcher/diag/pom.xml create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagCheck.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagConstants.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagMain.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagnosticsUI.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DnsResolver.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetProbe.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetworkDiagnostics.java create mode 100644 launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/ReportSender.java create mode 100644 launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DiagCheckTest.java create mode 100644 launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DnsResolverTest.java create mode 100644 launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/ReportSenderTest.java create mode 100644 launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java diff --git a/.gitignore b/.gitignore index 2241a8c..1a9dd0d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ packs/ .__pycache__ .pytest_cache .venvtodo.txt +*.bak diff --git a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java index 2142390..0076a4d 100644 --- a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -77,6 +77,19 @@ public class Bootstrap { log("Local version: " + currentVersion); 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); @@ -124,6 +137,119 @@ public class Bootstrap { launchInNewProcess(args); } } + + private static boolean runDiagnosticsIfPossible() { + Path diagJar = binDir.resolve("zernmcdiag.jar"); + if (!Files.exists(diagJar)) { + log("Diagnostics tool not found: bin/zernmcdiag.jar is missing."); + log("Please download the latest launcher manually from the ZernMC website."); + showMessageUi("ZernMC — нет связи с сервером", + "Не удалось связаться с серверами Zern.\n\n" + + "Файл диагностики (bin/zernmcdiag.jar) отсутствует.\n" + + "Скачайте свежий лаунчер вручную с официального сайта ZernMC."); + return false; + } + + boolean confirmed; + if (isCliMode) { + confirmed = askCliConfirm(); + } else { + confirmed = confirmUi("ZernMC — нет связи с сервером", + "Не удалось связаться с серверами Zern.\nПровести диагностику сети?"); + } + if (!confirmed) { + log("Diagnostics declined by user."); + return false; + } + + try { + launchDiag(diagJar); + return true; + } catch (Exception e) { + log("Failed to launch diagnostics: " + e.getMessage()); + return false; + } + } + + private static void launchDiag(Path diagJar) throws Exception { + String os = System.getProperty("os.name").toLowerCase(); + Path javaBin = findJava(false); + if (os.contains("windows")) { + Path javawPath = javaBin.resolveSibling("javaw.exe"); + if (Files.exists(javawPath)) { + javaBin = javawPath; + } + } + + List 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(); @@ -171,6 +297,12 @@ public class Bootstrap { cmd.add("-jar"); 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()); diff --git a/launcher/diag/pom.xml b/launcher/diag/pom.xml new file mode 100644 index 0000000..b218c1a --- /dev/null +++ b/launcher/diag/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + + me.sashegdev + ZernMCLauncher + ${revision} + + + zernmc-diag + jar + + ZernMC Network Diagnostics + Standalone network diagnostics utility (DNS, connectivity, report upload) + + + + dnsjava + dnsjava + + + org.json + json + + + org.junit.jupiter + junit-jupiter + 5.10.1 + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + ../../server/builds/bin/zernmcdiag.jar + + + dnsjava:dnsjava + + META-INF/services/java.net.spi.InetAddressResolverProvider + META-INF/services/sun.net.spi.nameservice.NameServiceDescriptor + + + + + + me.sashegdev.zernmc.launcher.diag.DiagMain + + ${project.version}.${hotfix} + ZernMC Network Diagnostics + SashegDev + + + + + + + + + + \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagCheck.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagCheck.java new file mode 100644 index 0000000..84d0a85 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagCheck.java @@ -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(); + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagConstants.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagConstants.java new file mode 100644 index 0000000..aa3acb0 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagConstants.java @@ -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 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 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 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 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() { + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagMain.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagMain.java new file mode 100644 index 0000000..7740e99 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagMain.java @@ -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 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 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); + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagnosticsUI.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagnosticsUI.java new file mode 100644 index 0000000..95acd03 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DiagnosticsUI.java @@ -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 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 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 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(); + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DnsResolver.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DnsResolver.java new file mode 100644 index 0000000..ee03c00 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/DnsResolver.java @@ -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 resolveSystem(String host) { + try { + InetAddress[] addrs = InetAddress.getAllByName(host); + List 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 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 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 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 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 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 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); + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetProbe.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetProbe.java new file mode 100644 index 0000000..8d9aeeb --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetProbe.java @@ -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(); + } + } + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetworkDiagnostics.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetworkDiagnostics.java new file mode 100644 index 0000000..e631930 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/NetworkDiagnostics.java @@ -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 onLine; + private final Consumer onCheck; + private final List checks = new CopyOnWriteArrayList<>(); + private final StringBuilder report = new StringBuilder(); + private String clientIp = "unknown"; + + public NetworkDiagnostics(Path baseDir, Consumer onLine, Consumer onCheck) { + this.baseDir = baseDir; + this.onLine = onLine; + this.onCheck = onCheck; + } + + public List 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> tasks = new ArrayList<>(); + List 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> futures = new ArrayList<>(); + for (Callable task : tasks) { + futures.add(pool.submit(task)); + } + int idx = 0; + for (Future 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 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 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()); + } +} \ No newline at end of file diff --git a/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/ReportSender.java b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/ReportSender.java new file mode 100644 index 0000000..a774904 --- /dev/null +++ b/launcher/diag/src/main/java/me/sashegdev/zernmc/launcher/diag/ReportSender.java @@ -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 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 endpoints) { + return endpoints.get(endpoints.size() - 1); + } +} \ No newline at end of file diff --git a/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DiagCheckTest.java b/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DiagCheckTest.java new file mode 100644 index 0000000..a316d1f --- /dev/null +++ b/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DiagCheckTest.java @@ -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()); + } +} \ No newline at end of file diff --git a/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DnsResolverTest.java b/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DnsResolverTest.java new file mode 100644 index 0000000..4150c95 --- /dev/null +++ b/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/DnsResolverTest.java @@ -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\"}")); + } +} \ No newline at end of file diff --git a/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/ReportSenderTest.java b/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/ReportSenderTest.java new file mode 100644 index 0000000..bf7029e --- /dev/null +++ b/launcher/diag/src/test/java/me/sashegdev/zernmc/launcher/diag/ReportSenderTest.java @@ -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); + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java index 094ac62..1c0339b 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java @@ -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...")); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java index 7ca82c9..66b6fa2 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java @@ -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"); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java index 9fa3753..ee7647c 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/auth/AuthService.java @@ -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)); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java index f0baf04..17e0615 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -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 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 launchProcess(Instance instance, String instanceName, LaunchOptions options) { + try { + long t0 = System.currentTimeMillis(); + LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); List command = builder.build(options); LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms"); LauncherLogger.info("Generated command for " + instanceName + ":"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java index bbb16ed..509f59a 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -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 { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java index f989a98..64b2f65 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/Instance.java @@ -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()); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java index 9d16fbe..ff83b5a 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/InstanceManager.java @@ -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; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java index 0eaa569..748fe6d 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/MinecraftLib.java @@ -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 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()); } } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java index 11ab2f4..e6afbef 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -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 localFiles = scanLocalFiles(); @@ -351,7 +346,7 @@ public class PackDownloader { Set 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 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; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java index ec5548c..faf4d27 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/FabricInstaller.java @@ -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) { - process.destroyForcibly(); - LauncherLogger.error("Fabric Installer timed out after 10 minutes"); - return false; + + 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; + } + exitCode = process.exitValue(); } - int exitCode = process.exitValue(); if (exitCode != 0) { LauncherLogger.error("Fabric Installer failed (code " + exitCode + ")"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java index 08db58e..7a877ab 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/ModLoaderInstaller.java @@ -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()); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java index 311ae21..1826b8f 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java @@ -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) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java index bc0c35d..9477e12 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -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 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 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//.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 stream = Files.list(instance.getPath().resolve("assets").resolve("indexes"))) { java.util.List 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 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(); } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java index dd051e3..bae1878 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/VersionManifest.java @@ -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()); } } } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index b52d8ba..29f443e 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -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 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 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 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 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 { - os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS); + if (line != null) { + os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8)); + } else { + os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + } os.flush(); - 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 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 { - os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + String line = queue.poll(10, java.util.concurrent.TimeUnit.SECONDS); + if (line != null) { + os.write(("data: " + line.replace("\n", "").replace("\r", "") + "\n\n").getBytes(StandardCharsets.UTF_8)); + } else { + os.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + } os.flush(); - 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) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java index 532a968..e526529 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/Config.java @@ -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; } diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java new file mode 100644 index 0000000..f79029b --- /dev/null +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/DomainSelector.java @@ -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 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 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 fetchMirrors() { + List 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 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) { + } +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java index 4b70d91..582536d 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/LauncherLogger.java @@ -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 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); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java index ac587aa..fb29e53 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java @@ -4,81 +4,107 @@ 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; - + 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) { currentLabel = label; currentTotal = totalBytes; - + 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) { currentLabel = label; currentTotal = total; - + 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) { @@ -95,4 +120,4 @@ public class ProgressBar { if (bytes < 1024 * 1024) return DF.format(bytes / 1024.0) + " KB"; return DF.format(bytes / (1024.0 * 1024)) + " MB"; } -} +} \ No newline at end of file diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index bc19b58..29ed214 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -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 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 getNetworkStatus() { Map status = new HashMap<>(); status.put("initialized", proxyTested.get()); diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index e88d969..a1da17b 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -39,6 +39,7 @@
+
+ + +