diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..dd84ea7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
+
+**Smartphone (please complete the following information):**
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..bbcbbe7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.gitignore b/.gitignore
index 7d21df3..1a9dd0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,10 +2,22 @@ logs/
__pycache__/
./.venv/
launcher/target
+bootstrap/target
+src/target
server/builds
server/packs
server/data
jre
.vscode
dependency-reduced-pom.xml
+.flattened-pom.xml
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
+telegram-bot/
+builds/
+server/news/
+data/
+packs/
+.__pycache__
+.pytest_cache
+.venvtodo.txt
+*.bak
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4011438
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 SashegDev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 857a9fc..5fef510 100644
--- a/README.md
+++ b/README.md
@@ -8,56 +8,85 @@
- Написан полностью с нуля, без использования готовых решений
- Удобная загрузка и обновление сборок прямо с сервера ZernMC
- Чистый и относительно читаемый код как клиента, так и сервера
-- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.)
-- Возможность запуска клиента даже без графического интерфейса (TUI-режим)
+- Графический интерфейс на **JavaFX** (по умолчанию) + **TUI** (режим `--cli`)
+- Поддержка **прокси-режима** через свой сервер при проблемах с внешними ресурсами (Fabric/Forge Maven, Mojang и т.д.), обход блокировок через выбор домена и SNI
+- Поддержка загрузчиков **Fabric**, **Forge**, **NeoForge** и ванильных версий (установка через встроенный JRE)
+- Ротационные логи в стиле Minecraft: `latest.log` + архивы `.log.gz`
+- Инкрементальное обновление JavaFX и автообновление лаунчера
+- Подпись exe самоподписным сертификатом Zern.cc (jsign/Authenticode)
- Полное отсутствие рекламы и телеметрии
- Не собирает никаких данных о пользователе
-- Работает «из коробки»: достаточно распаковать архив и запустить `ZernMCLauncher.exe`
+- Работает «из коробки»: достаточно распаковать архив и запустить `zernmc.exe`
- Есть система аккаунтов и проходок
+## Состав дистрибутива
+
+В архиве `ZernMC-win-<версия>.zip`:
+
+- `zernmc.exe` — основная версия с GUI (JavaFX)
+- `zernmc-cli.exe` — консольная версия (TUI)
+- `zernmc-cli-jfx.exe` — консоль + JavaFX UI
+- `lib/jre21` — встроенный JRE 21
+- `lib/javafx` — модули JavaFX 23.0.1
+
+Bootstrap-модуль встроен прямо в `.exe`, отдельный jar рядом не нужен.
+
## Чего пока нет в лаунчере
-- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
-- Поддержки **Forge** (в разработке)
-- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
-- Раздела новостей об обновлениях Minecraft и лаунчера
-- Выбора готовых пресетов оптимизации JVM
+- Готовых пресетов оптимизации JVM (настраиваются Java и выделенная память)
+- Поддержки Quilt, LabyMod и других лоадеров
+- Кастомных модов (UI, спавнеры, DPI, карточки)
+- Сайта для лаунчера и сервера (текущий требует переделки)
+- Трекинга наигранного времени (обновление раз в час вместо минуты)
## Что планируется доработать в ближайшее время
+- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
+- **GUI мод** — привести в единый стиль с главным меню
+- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
+- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпиленной проверкой, уведомления админу в Telegram с технической информацией
+- **Сайт** — полноценный сайт для лаунчера и сервера
+- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
+- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
+- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
- Генерацию команды запуска Minecraft
- Стабильную работу автообновления лаунчера
- Полноценные настройки
-- Стабильность и производительность серверной части
+- **Улучшенный античит / ClientChecker** — проверка подлинности клиента при подключении к серверу, без нужного клиента не пустит; поставляется вместе с лаунчером, не общедоступный. Хеш-проверка всех папок и файлов сборки при каждом запуске — при несовпадении одного хеша все моды переустанавливаются. Игнорируются только: логи, ресурспаки, шейдеры, сейвы, личные файлы. Защита от подмены libs и лоадеров (Meteor и аналоги), проверка целостности модов через хеши. В перспективе — защита от Mixin-атак (перехват логики других модов), сбор отчёта о текущей сборке и сравнение с базовой
+- **Баг-фиксы сервера:** подключить `admin_router` в `main.py`, исправить импорты ролей (`ROLE_USER` и др. не существуют в `roles.py`), добавить эндпоинт `/auth/pass/activate`, убрать дубли импортов (`TTLCache`, `Response`)
- Улучшение прокси-режима
+- Стабильность и производительность серверной части
- Общую надёжность загрузки файлов с сервера
-- аккаунты, проходки
## Важная информация перед использованием
### Управление в лаунчере (TUI)
-Лаунчер использует **текстовый интерфейс (TUI)**:
+Консольные версии (`zernmc-cli.exe`, а также режим `--cli`) используют **текстовый интерфейс (TUI)**:
-- `W` / `S` (или `Ц` / `Ы`) — перемещение по меню
-- `ENTER` — выбор пункта
+- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
+- `ENTER` — выбор пункта
- `ESC` или пункт «Назад» — возврат назад
-> **Важно:** Стрелки ↑/↓ могут вызывать баги и краши. Используйте только `W`/`S`.
-
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
+Основная версия `zernmc.exe` запускает графический интерфейс JavaFX.
+
### Расположение сборок
Все установленные сборки хранятся в папке:
- **Windows**: `%USERPROFILE%\.zernmc\instances`
-- **Linux**: `~/.zernmc/instances`
+- **Linux**: `~/.zernmc\instances`
Если сборка не удаляется корректно — удалите папку вручную.
+### Логи
+
+Логи лаунчера пишутся в файл `latest.log` рядом с исполняемым файлом; старые логи архивируются в `.log.gz` (в стиле Minecraft).
+
## Как скачать и запустить
-Пока автообновления и GitHub Releases не настроены, скачать последнюю версию можно по ссылкам:
+Скачать последнюю версию можно по ссылкам:
**Скачать лаунчер:**
- https://api.zernmc.ru/launcher/download/latest
@@ -65,9 +94,9 @@
**Инструкция:**
1. Скачайте zip-архив
-2. Распакуйте в удобную папку
-3. Запустите `ZernMCLauncher.exe`
-4. Нажмите **«Начать игру»**
+2. Распакуйте в удобную папку (например `C:\ZernMC`)
+3. Запустите `zernmc.exe` (или `zernmc-cli.exe` для консольного режима)
+4. Войдите в аккаунт и нажмите **«Начать игру»**
### Установка первой сборки
@@ -75,28 +104,31 @@
1. Выберите **«Установить новую сборку»**
2. Выберите тип сборки:
- **ZernMC** — готовые сборки с нашего сервера
- - **Модовый загрузчик** — Fabric / Forge / Quilt и т.д.
+ - **Модовый загрузчик** — Fabric / Forge / NeoForge
- **Ваниль** — чистая версия от Mojang
**Рекомендация:** Не используйте кириллицу в названии сборки! (science 1.0.0-1.0.5)
## Возможные проблемы и решения
+### «Error: Invalid or corrupt jarfile» при запуске exe
+Перекачайте архив целиком из https://api.zernmc.ru/launcher/download/latest и распакуйте заново — старые версии (до 1.0.16.17) были повреждены подписью и не запускаются.
+
### Проблемы с загрузкой ассетов
-Иногда лаунчер неправильно определяет Asset Index.
+Иногда лаунчер неправильно определяет Asset Index.
**Решение:**
1. Запустите версию и посмотрите в логах, какой `.json` файл он пытается загрузить.
2. Скопируйте правильный файл из `assets/indexes/` и переименуйте его в нужное имя.
-### Forge версии
-Запуск Forge пока работает нестабильно. Рекомендуется дождаться исправления генерации команды запуска.
+### Forge / NeoForge
+Работает через установщики лоадеров. Если у вас нет внешнего доступа к Maven-репозиториям — используйте прокси-режим.
### Прокси-режим
Если нет доступа к Fabric/Forge Maven или серверам Mojang — лаунчер пытается использовать наш сервер как прокси. Если и к нему нет доступа — загрузка может не работать.
## Как сообщить об ошибке (Issue)
-1. Скопируйте весь текст ошибки из логов (лаунчер не очищает экран, поэтому просто скролльте вверх)
+1. Скопируйте весь текст ошибки из логов (`latest.log`)
2. Укажите, что вы делали перед ошибкой
3. Укажите версию лаунчера
4. Создайте Issue на GitHub
@@ -107,25 +139,21 @@
git clone https://github.com/SashegDev/launcher
```
Откройте `launcher/launcher/pom.xml`
-отредактируете пути для билда, так как сейчас - он расчитан на разработку только на моём сервере
+отредактируйте пути для билда, так как сейчас - он рассчитан на разработку только на своём сервере
```bash
mvn clean package
```
можете начинать!
+**Сборка exe на Linux требует:** `jsign` (`~/tools/jsign/jsign.jar`), файлы сертификата в `/root/cert/codesign/`, встроенный JRE в `~/launcher/jre/jre21`.
+
### Благодарность
-Glitch135 - За бета-тест лаунчера и фидбеки
-
-### помощь
-Я был бы рад если нашлись люди кто мог бы помочь в разработке лаунчера и добавления к нему хотя бы базового UI чтоб было намногл удобнее работать с ним
-
-
+- Glitch135 — за бета-тест лаунчера и фидбеки
Буду рад помощи в разработке!
Особенно приветствуется помощь с:
-- Переходом на нормальный графический интерфейс (GUI)
- Улучшением генерации команды запуска
-- Добавлением поддержки Forge / NeoForge / Quilt
+- Добавлением поддержки Quilt / LabyMod
- Стабилизацией серверной части
-- вообще улучшением клиента
+- Вообще улучшением клиента
\ No newline at end of file
diff --git a/launcher/.gitignore b/launcher/.gitignore
new file mode 100644
index 0000000..edbd9be
--- /dev/null
+++ b/launcher/.gitignore
@@ -0,0 +1,24 @@
+# Maven
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+dependency-reduced-pom.xml
+
+# IDE
+.idea/
+*.iml
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Build outputs
+server/builds/
+server/logs/
+
+# Colab
+colab/
\ No newline at end of file
diff --git a/launcher/bootstrap/pom.xml b/launcher/bootstrap/pom.xml
new file mode 100644
index 0000000..c76fd2e
--- /dev/null
+++ b/launcher/bootstrap/pom.xml
@@ -0,0 +1,56 @@
+
+
+ 4.0.0
+
+
+ me.sashegdev
+ ZernMCLauncher
+ ${revision}
+
+
+ zernmc-bootstrap
+ jar
+
+ ZernMC Bootstrap
+ Bootstrap module - handles updates and Java launching
+
+
+
+
+ com.google.code.gson
+ gson
+
+
+ org.apache.httpcomponents
+ httpclient
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+ ../../server/builds/zernmc-bootstrap.jar
+
+
+ me.sashegdev.zernmc.launcher.Bootstrap
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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
new file mode 100644
index 0000000..f875ac5
--- /dev/null
+++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java
@@ -0,0 +1,1302 @@
+package me.sashegdev.zernmc.launcher;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.*;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import javax.swing.*;
+import javax.swing.plaf.basic.BasicProgressBarUI;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.util.jar.Attributes;
+import java.util.jar.JarFile;
+import java.util.jar.Manifest;
+
+public class Bootstrap {
+ private static final String JAR_NAME = "zernmclauncher.jar";
+ private static final String BASE_URL = "https://api.zernmc.ru";
+ private static List MIRRORS = new ArrayList<>();
+ private static final List stagedSelfUpdates = new ArrayList<>();
+ private static volatile boolean jfxChildExiting = false;
+
+ private static Path baseDir;
+ private static Path binDir;
+ private static Path logDir;
+ private static Path javafxPath;
+ private static boolean isCliMode;
+ private static boolean isJfxMode;
+ private static BootstrapUI ui;
+
+ private static Path getLauncherJar() {
+ return binDir.resolve(JAR_NAME);
+ }
+
+ private static Path resolveBaseDir() {
+ // 1) Стартуем от папки, где лежит сам .exe (для launch4j codeSource -> путь к exe).
+ Path exeDir = null;
+ try {
+ java.security.CodeSource cs = Bootstrap.class.getProtectionDomain().getCodeSource();
+ if (cs != null && cs.getLocation() != null) {
+ Path loc = Paths.get(cs.getLocation().toURI()).toAbsolutePath();
+ if (!Files.isDirectory(loc)) {
+ loc = loc.getParent(); // jar/exe -> его папка
+ }
+ exeDir = loc;
+ }
+ } catch (Exception ignored) {
+ }
+ if (exeDir != null) {
+ // exe в bin/ -> база родитель (lib/jre21/assets в корне)
+ Path parent = exeDir.getParent();
+ Path candidates[] = { exeDir, parent }; // порядок: корень дистрибутива, затем родитель
+ for (Path c : candidates) {
+ if (c == null) continue;
+ if (Files.exists(c.resolve("bin").resolve(JAR_NAME))) {
+ return c;
+ }
+ }
+ }
+ // 2) Fallback: текущая рабочая папка (старое поведение).
+ return Paths.get("").toAbsolutePath();
+ }
+
+ public static void main(String[] args) throws Exception {
+ baseDir = resolveBaseDir();
+ binDir = baseDir.resolve("bin");
+ Files.createDirectories(binDir);
+ logDir = baseDir.resolve("logs");
+ Files.createDirectories(logDir);
+ javafxPath = baseDir.resolve("lib").resolve("javafx");
+ rotateLogs();
+
+ log("=== ZernMC Launcher ===");
+
+ List argList = new ArrayList<>(Arrays.asList(args));
+ isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx"));
+ isJfxMode = !isCliMode;
+ if (isCliMode && !argList.contains("--cli")) {
+ argList.add("--cli");
+ }
+
+ log("Mode: " + (isCliMode ? "CLI" : "JFX"));
+
+ if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
+ ui = new BootstrapUI();
+ SwingUtilities.invokeLater(() -> ui.show());
+ }
+
+ String currentVersion = readCurrentVersion();
+ String serverVersion = getServerVersion();
+
+ 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);
+ log("Mirrors available: " + (MIRRORS.size() + 1));
+
+ if (isNewer(serverVersion, currentVersion)) {
+ log("Update available!");
+ downloadUpdate(serverVersion);
+ } else {
+ log("Version is up to date");
+ if (hasMissingFiles(serverVersion)) {
+ log("Repairing missing files...");
+ downloadUpdate(serverVersion);
+ }
+ }
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ log("Shutdown signal received...");
+ applyStagedSelfUpdates();
+ }));
+
+ if (ui != null) {
+ setTitle("Launching...");
+ setProgress(100, 100);
+ log("Starting launcher...");
+ try { Thread.sleep(400); } catch (InterruptedException ignored) {}
+ ui.close();
+ }
+
+ if (!stagedSelfUpdates.isEmpty()) {
+ notifyRestartRequired();
+ System.exit(0);
+ return;
+ }
+
+ launchMain(argList.toArray(new String[0]));
+ }
+
+ private static void launchMain(String[] args) throws Exception {
+ log("Loading launcher: " + getLauncherJar());
+
+ if (isCliMode) {
+ launchInProcess(args);
+ } else {
+ 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();
+ URL[] urls = { getLauncherJar().toUri().toURL() };
+ URLClassLoader cl = new URLClassLoader(urls, parent);
+
+ Thread.currentThread().setContextClassLoader(cl);
+
+ try {
+ Class> mainClass = cl.loadClass("me.sashegdev.zernmc.launcher.Main");
+ java.lang.reflect.Method mainMethod = mainClass.getMethod("main", String[].class);
+ mainMethod.invoke(null, (Object) args);
+ } finally {
+ cl.close();
+ }
+ }
+
+ private static void launchInNewProcess(String[] args) throws Exception {
+ String os = System.getProperty("os.name").toLowerCase();
+
+ Path javaBin = findJava(false);
+ // On Windows, use javaw.exe to hide console in JFX mode
+ if (os.contains("windows")) {
+ Path javawPath = javaBin.resolveSibling("javaw.exe");
+ if (Files.exists(javawPath)) {
+ javaBin = javawPath;
+ }
+ }
+ Path javafxPath = baseDir.resolve("lib").resolve("javafx");
+
+ List cmd = new ArrayList<>();
+ cmd.add(javaBin.toAbsolutePath().toString());
+ cmd.add("-Dfile.encoding=UTF-8");
+ cmd.add("-Dsun.stdout.encoding=UTF-8");
+ cmd.add("-Dsun.stderr.encoding=UTF-8");
+ cmd.add("-Dlauncher.server=" + BASE_URL);
+
+ if (Files.exists(javafxPath)) {
+ cmd.add("--module-path");
+ cmd.add(javafxPath.toAbsolutePath().toString());
+ cmd.add("--add-modules");
+ cmd.add("javafx.controls,javafx.web");
+ }
+
+ 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());
+ pb.redirectErrorStream(false);
+
+ log("Starting process: " + String.join(" ", cmd));
+
+ Process p = pb.start();
+
+ // Read stderr in background thread for crash reporting
+ StringBuilder errorOutput = new StringBuilder();
+ Thread errorReader = new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ errorOutput.append(line).append("\n");
+ log("[JFX] " + line);
+ }
+ } catch (Exception ignored) {}
+ });
+ errorReader.setDaemon(true);
+ errorReader.start();
+
+ int code = p.waitFor();
+ errorReader.join(2000);
+
+ log("JFX process exited with code: " + code);
+
+ if (code == 240) {
+ // Deliberate exit requested for applying a staged self-update.
+ // Run the shutdown hook (swaps the .update file via the detached
+ // helper) and exit silently - this is not a crash.
+ System.exit(240);
+ } else if (code != 0 && !GraphicsEnvironment.isHeadless()) {
+ String stderr = errorOutput.toString();
+ SwingUtilities.invokeLater(() -> showCrashDialog(code, stderr));
+ } else {
+ System.exit(code);
+ }
+ }
+
+ private static Path findJava(boolean preferConsole) {
+ String os = System.getProperty("os.name").toLowerCase();
+ String javaExe = "java.exe";
+
+ Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
+ if (!Files.exists(javaBin)) {
+ javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
+ }
+ if (!Files.exists(javaBin)) {
+ try {
+ Process p = new ProcessBuilder("where", javaExe).redirectErrorStream(true).start();
+ if (p.waitFor() == 0) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
+ String path = br.readLine();
+ if (path != null) javaBin = Paths.get(path.trim());
+ }
+ }
+ } catch (Exception ignored) {}
+ }
+
+ if (!Files.exists(javaBin)) {
+ throw new RuntimeException("Java not found");
+ }
+ return javaBin;
+ }
+
+ private static void rotateLogs() {
+ // Minecraft-style: archive previous latest.log as .log.gz, keep unlimited.
+ try {
+ Path latest = logDir.resolve("latest.log");
+ if (Files.exists(latest) && Files.size(latest) > 0) {
+ String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
+ int n = 1;
+ Path archive;
+ do {
+ archive = logDir.resolve(ts + (n == 1 ? "" : "-" + n) + ".log.gz");
+ n++;
+ } while (Files.exists(archive));
+ gzip(archive, latest);
+ Files.deleteIfExists(latest);
+ }
+ } catch (Exception e) {
+ System.out.println("[Bootstrap] rotate error: " + e.getMessage());
+ }
+ // Migrate old unlimited log.
+ try {
+ Files.deleteIfExists(logDir.resolve("launcher.log"));
+ } catch (Exception ignored) {
+ }
+ }
+
+ private static void gzip(Path archive, Path source) {
+ try (java.io.InputStream in = Files.newInputStream(source);
+ java.io.OutputStream out = Files.newOutputStream(archive);
+ java.util.zip.GZIPOutputStream gz = new java.util.zip.GZIPOutputStream(out)) {
+ byte[] buf = new byte[65536];
+ int n;
+ while ((n = in.read(buf)) > 0) {
+ gz.write(buf, 0, n);
+ }
+ } catch (Exception e) {
+ System.out.println("[Bootstrap] gzip error: " + e.getMessage());
+ }
+ }
+
+ private static void log(String msg) {
+ String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
+ System.out.println(entry);
+ try {
+ Files.writeString(logDir.resolve("latest.log"), entry + "\n",
+ StandardOpenOption.CREATE, StandardOpenOption.APPEND);
+ } catch (Exception ignored) {}
+ if (ui != null) ui.setStatus(msg);
+ }
+
+ private static void setProgress(int current, int total) {
+ if (ui != null) ui.setProgress(current, total);
+ }
+
+ private static void setSpeedText(String text) {
+ if (ui != null) ui.setSpeedText(text);
+ }
+
+ private static void setVersionInfo(String localVer, String serverVer) {
+ if (ui != null) ui.setVersionInfo(localVer, serverVer);
+ }
+
+ private static void setTitle(String text) {
+ if (ui != null) ui.setTitleText(text);
+ }
+
+ private static String readCurrentVersion() {
+ Path jar = getLauncherJar();
+ if (Files.exists(jar)) {
+ try (JarFile jarFile = new JarFile(jar.toFile())) {
+ Manifest manifest = jarFile.getManifest();
+ if (manifest != null) {
+ String v = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
+ if (v != null && !v.isBlank()) return v;
+ }
+ } catch (Exception e) {
+ log("Error reading manifest: " + e.getMessage());
+ }
+ }
+ return "0.0.0";
+ }
+
+ private static String getServerVersion() {
+ try {
+ URL url = new URL(BASE_URL + "/launcher/version");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+ if (conn.getResponseCode() == 200) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = br.readLine()) != null) {
+ sb.append(line);
+ }
+ String response = sb.toString();
+
+ int versionStart = response.indexOf("\"version\":\"");
+ if (versionStart >= 0) {
+ int afterVersion = versionStart + 11;
+ int versionEnd = response.indexOf("\"", afterVersion);
+ if (versionEnd > afterVersion) {
+ return response.substring(afterVersion, versionEnd);
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ log("Error fetching version: " + e.getMessage());
+ }
+ return "unknown";
+ }
+
+ private static boolean isNewer(String server, String current) {
+ try {
+ String[] sa = server.split("\\.");
+ String[] ca = current.split("\\.");
+ for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
+ int sv = Integer.parseInt(sa[i]);
+ int cv = Integer.parseInt(ca[i]);
+ if (sv > cv) return true;
+ if (sv < cv) return false;
+ }
+ return sa.length > ca.length;
+ } catch (Exception ignored) {}
+ return false;
+ }
+
+ private static void downloadUpdate(String newVersion) throws Exception {
+ log("Checking for updates...");
+
+ Map serverFiles = fetchServerMeta(newVersion);
+ if (serverFiles.isEmpty()) {
+ log("Failed to get server meta");
+ return;
+ }
+
+ Map localFiles = scanLocalFiles();
+ log("Local files: " + localFiles.size());
+ log("Server files: " + serverFiles.size());
+
+ int downloaded = 0;
+ int skipped = 0;
+ int failed = 0;
+ int staged = 0;
+
+ for (Map.Entry entry : serverFiles.entrySet()) {
+ String filePath = entry.getKey();
+ FileMeta serverMeta = entry.getValue();
+
+ String localHash = localFiles.get(filePath);
+ String serverHash = serverMeta.hash.replace("sha256:", "");
+
+ if (localHash != null && localHash.equals(serverHash)) {
+ skipped++;
+ continue;
+ }
+
+ if (localHash != null) {
+ log("Updating: " + filePath);
+ } else {
+ log("Downloading: " + filePath);
+ }
+
+ try {
+ // Self-update: a running .exe cannot be overwritten on Windows,
+ // so download to a temp name and swap it after exit.
+ if (isSelfExe(filePath)) {
+ downloadFile(newVersion, filePath, serverMeta.size, filePath + ".update");
+ Path updatePath = baseDir.resolve(filePath + ".update");
+ Path targetPath = baseDir.resolve(filePath);
+ try {
+ Files.move(updatePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
+ log("Self-update applied: " + filePath);
+ } catch (Exception e) {
+ stagedSelfUpdates.add(filePath);
+ staged++;
+ log("Self-update staged (applied on exit): " + filePath);
+ }
+ downloaded++;
+ continue;
+ }
+
+ downloadFile(newVersion, filePath, serverMeta.size);
+ downloaded++;
+ } catch (Exception e) {
+ log("Warning: Could not update " + filePath + " - " + e.getMessage());
+ failed++;
+ }
+ }
+
+ String summary = "Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed;
+ if (staged > 0) summary += ", staged self-update: " + staged;
+ log(summary);
+
+ cleanStaleJavaFxJars(serverFiles);
+
+ log("Updated to v" + newVersion);
+ }
+
+ /**
+ * Removes old lib/javafx/*-win.jar files that are no longer part of the
+ * server meta (e.g. the previous JavaFX 23 jars after an upgrade to
+ * 23.0.1). Keeping both would put two JARs exporting the same module on
+ * the --module-path, which breaks module resolution.
+ */
+ private static void cleanStaleJavaFxJars(Map serverFiles) {
+ Path javafxDir = baseDir.resolve("lib").resolve("javafx");
+ if (!Files.isDirectory(javafxDir)) return;
+
+ java.util.Set keep = new java.util.HashSet<>();
+ for (String filePath : serverFiles.keySet()) {
+ String normalized = filePath.toLowerCase().replace("\\", "/");
+ if (normalized.startsWith("lib/javafx/") && normalized.endsWith("-win.jar")) {
+ keep.add(new File(filePath).getName().toLowerCase());
+ }
+ }
+
+ try (java.util.stream.Stream stream = Files.list(javafxDir)) {
+ stream.filter(Files::isRegularFile)
+ .filter(p -> {
+ String name = p.getFileName().toString().toLowerCase();
+ return name.endsWith("-win.jar") && !keep.contains(name);
+ })
+ .forEach(p -> {
+ try {
+ Files.delete(p);
+ log("Removed stale JavaFX jar: " + p.getFileName());
+ } catch (Exception e) {
+ log("Warning: could not remove stale JavaFX jar " + p.getFileName() + " - " + e.getMessage());
+ }
+ });
+ } catch (Exception e) {
+ log("Warning: failed to scan JavaFX dir: " + e.getMessage());
+ }
+ }
+
+ private static boolean isSelfExe(String filePath) {
+ String lower = filePath.toLowerCase().replace("\\", "/");
+ return lower.equals("zernmc.exe") || lower.equals("zernmc-cli.exe")
+ || lower.endsWith("/zernmc.exe") || lower.endsWith("/zernmc-cli.exe");
+ }
+
+ /**
+ * Checks whether any file from the current version's meta is missing
+ * locally (e.g. a deleted zernmc.exe). Only presence is checked, no
+ * hashing, so this stays cheap on every launch.
+ */
+ private static boolean hasMissingFiles(String version) {
+ Map serverFiles = fetchServerMeta(version);
+ if (serverFiles.isEmpty()) {
+ log("Warning: Could not fetch meta for repair check");
+ return false;
+ }
+ for (String filePath : serverFiles.keySet()) {
+ if (!Files.exists(baseDir.resolve(filePath))) {
+ log("Missing file: " + filePath);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Applies self-updates that were staged because the target .exe was
+ * locked (the launcher itself was running). A detached helper waits a
+ * few seconds for this process to fully exit, then swaps the file.
+ */
+ private static void applyStagedSelfUpdates() {
+ String os = System.getProperty("os.name", "").toLowerCase();
+ if (!os.contains("win") || stagedSelfUpdates.isEmpty()) return;
+
+ for (String filePath : stagedSelfUpdates) {
+ String targetName = new File(filePath).getName();
+ String updateName = targetName + ".update";
+ Path updatePath = baseDir.resolve(updateName);
+ if (!Files.exists(updatePath)) continue;
+
+ Path bat = baseDir.resolve("apply-update-" + System.currentTimeMillis() + ".cmd");
+ try {
+ String content = "@echo off\r\n"
+ + "ping -n 4 127.0.0.1 > nul\r\n"
+ + "move /y \"" + updatePath.toAbsolutePath() + "\" \"" + baseDir.resolve(targetName).toAbsolutePath() + "\" > nul\r\n"
+ + "del /q \"%~f0\"\r\n";
+ Files.writeString(bat, content, StandardCharsets.UTF_8);
+ log("Applying staged self-update after exit: " + targetName);
+ new ProcessBuilder("cmd.exe", "/c", "start", "", "/min", bat.toAbsolutePath().toString()).start();
+ } catch (Exception e) {
+ log("Warning: Could not stage self-update for " + targetName + " - " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Informs the user that a staged self-update was downloaded and needs a
+ * launcher restart to take effect, then lets the JVM exit so the
+ * detached helper can swap the .exe.
+ */
+ private static void notifyRestartRequired() {
+ String message = "Обновление лаунчера загружено.\n"
+ + "Перезапустите лаунчер для применения изменений в обновлении.";
+ log("Self-update staged - restart required to apply changes");
+ if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
+ try {
+ SwingUtilities.invokeAndWait(() ->
+ JOptionPane.showMessageDialog(null, message,
+ "ZernMC Launcher", JOptionPane.INFORMATION_MESSAGE));
+ } catch (Exception e) {
+ log("Warning: could not show restart dialog: " + e.getMessage());
+ }
+ }
+ }
+
+ private static Map fetchServerMeta(String version) {
+ Map files = new HashMap<>();
+ try {
+ URL url = new URL(BASE_URL + "/launcher/meta/" + version);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(10000);
+
+ if (conn.getResponseCode() == 200) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = br.readLine()) != null) sb.append(line);
+
+ com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(sb.toString()).getAsJsonObject();
+ com.google.gson.JsonArray filesArray = json.getAsJsonArray("files");
+
+ for (com.google.gson.JsonElement fileElem : filesArray) {
+ com.google.gson.JsonObject file = fileElem.getAsJsonObject();
+ files.put(file.get("path").getAsString(), new FileMeta(
+ file.get("hash").getAsString(),
+ file.get("size").getAsLong()
+ ));
+ }
+ }
+ }
+ } catch (Exception e) {
+ log("Error fetching meta: " + e.getMessage());
+ }
+ return files;
+ }
+
+ private static Map scanLocalFiles() {
+ Map files = new HashMap<>();
+ try {
+ Files.walk(baseDir)
+ .filter(Files::isRegularFile)
+ .filter(p -> !p.toString().contains(".git"))
+ .forEach(path -> {
+ try {
+ String relativePath = baseDir.relativize(path).toString().replace("\\", "/");
+ String hash = calculateFileHash(path);
+ files.put(relativePath, hash);
+ } catch (Exception ignored) {}
+ });
+ } catch (Exception ignored) {}
+ return files;
+ }
+
+ private static String calculateFileHash(Path path) throws Exception {
+ try (InputStream is = Files.newInputStream(path)) {
+ java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
+ byte[] buf = new byte[8192];
+ int len;
+ while ((len = is.read(buf)) > 0) {
+ digest.update(buf, 0, len);
+ }
+ byte[] hash = digest.digest();
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hash) sb.append(String.format("%02x", b));
+ return sb.toString();
+ }
+ }
+
+ private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
+ downloadFile(version, filePath, expectedSize, filePath);
+ }
+
+ private static void downloadFile(String version, String filePath, long expectedSize, String saveAs) throws Exception {
+ List servers = new ArrayList<>();
+ if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
+ servers.addAll(MIRRORS);
+ java.util.Collections.shuffle(servers);
+
+ Exception lastError = null;
+ for (String server : servers) {
+ try {
+ downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, saveAs);
+ return;
+ } catch (Exception e) {
+ lastError = e;
+ }
+ }
+
+ downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, saveAs);
+ }
+
+ private static void downloadFileFromServer(String urlStr, long expectedSize, String saveAs) throws Exception {
+ URL url = new URL(urlStr);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(60000);
+
+ if (conn.getResponseCode() != 200) {
+ throw new IOException("HTTP " + conn.getResponseCode());
+ }
+
+ if (expectedSize <= 0) {
+ expectedSize = conn.getContentLengthLong();
+ }
+
+ Path outPath = baseDir.resolve(saveAs);
+ Files.createDirectories(outPath.getParent());
+
+ long downloaded = 0;
+ long lastUpdate = 0;
+ long startTime = System.currentTimeMillis();
+ setTitle("Downloading " + new File(saveAs).getName());
+
+ try (InputStream in = conn.getInputStream();
+ OutputStream out = new FileOutputStream(outPath.toFile())) {
+ byte[] buf = new byte[65536];
+ int len;
+ while ((len = in.read(buf)) > 0) {
+ out.write(buf, 0, len);
+ downloaded += len;
+
+ if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
+ long elapsed = System.currentTimeMillis() - startTime;
+ double speedBps = elapsed > 0 ? downloaded * 1000.0 / elapsed : 0;
+ double speed = speedBps / 1024.0 / 1024.0;
+ double downloadedMB = downloaded / 1024.0 / 1024.0;
+ double totalMB = expectedSize / 1024.0 / 1024.0;
+ String etaStr = "";
+ if (speedBps > 0) {
+ long etaSec = (long)((expectedSize - downloaded) / speedBps);
+ if (etaSec > 3600) {
+ etaStr = String.format(" ETA: %dh %dm", etaSec / 3600, (etaSec % 3600) / 60);
+ } else if (etaSec > 60) {
+ etaStr = String.format(" ETA: %dm %ds", etaSec / 60, etaSec % 60);
+ } else if (etaSec > 0) {
+ etaStr = " ETA: " + etaSec + "s";
+ }
+ }
+ String progressStr = String.format("%.1f/%.1f MB (%.1f MB/s%s)", downloadedMB, totalMB, speed, etaStr);
+ log(progressStr);
+ setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
+ setSpeedText(String.format("%.1f / %.1f MB \u2022 %.1f MB/s", downloadedMB, totalMB, speed));
+ lastUpdate = downloaded;
+ }
+ }
+ }
+
+ long elapsed = System.currentTimeMillis() - startTime;
+ double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
+ log(String.format("Downloaded %.1f MB (%.1f MB/s) - Done!", downloaded / 1024.0 / 1024.0, speed));
+ setProgress((int) downloaded, (int) Math.max(expectedSize, 1));
+ }
+
+ private static String getProgressBar(long current, long total) {
+ if (total <= 0) return "====";
+ int filled = (int) ((current * 20) / total);
+ StringBuilder sb = new StringBuilder("[");
+ for (int i = 0; i < 20; i++) {
+ sb.append(i < filled ? "=" : " ");
+ }
+ sb.append("]");
+ return sb.toString();
+ }
+
+ private static class FileMeta {
+ String hash;
+ long size;
+ FileMeta(String hash, long size) {
+ this.hash = hash;
+ this.size = size;
+ }
+ }
+
+ private static void loadMirrors() {
+ try {
+ URL url = new URL(BASE_URL + "/launcher/mirrors");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+
+ if (conn.getResponseCode() == 200) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = br.readLine()) != null) sb.append(line);
+
+ com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
+ com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
+
+ for (com.google.gson.JsonElement elem : mirrorsArray) {
+ com.google.gson.JsonObject mirror = elem.getAsJsonObject();
+ String mirrorUrl = mirror.get("url").getAsString();
+ if (!MIRRORS.contains(mirrorUrl)) {
+ MIRRORS.add(mirrorUrl);
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ log("Mirrors unavailable: " + e.getMessage());
+ }
+ }
+
+ private static boolean isServerReachable(String serverUrl) {
+ try {
+ URL url = new URL(serverUrl + "/launcher/version");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(2000);
+ conn.setReadTimeout(2000);
+ return conn.getResponseCode() == 200;
+ } catch (Exception ignored) {
+ return false;
+ }
+ }
+
+ // ====================== CRASH HANDLER ======================
+
+ private static String readFileSafe(Path path, int maxLines) {
+ if (!Files.exists(path)) return "(file not found)";
+ try {
+ List lines = Files.readAllLines(path, StandardCharsets.UTF_8);
+ if (lines.size() <= maxLines) {
+ return String.join("\n", lines);
+ }
+ List tail = lines.subList(lines.size() - maxLines, lines.size());
+ return "... (" + (lines.size() - maxLines) + " lines omitted)\n" + String.join("\n", tail);
+ } catch (Exception e) {
+ return "(error reading: " + e.getMessage() + ")";
+ }
+ }
+
+ private static Map collectCrashInfo(int exitCode, String stderr) {
+ Map info = new HashMap<>();
+
+ // Version
+ info.put("launcher_version", readCurrentVersion());
+
+ // Exit
+ info.put("exit_code", exitCode);
+ info.put("stderr", stderr != null ? stderr : "");
+ info.put("launcher_log", readFileSafe(logDir.resolve("latest.log"), 200));
+
+ // System
+ Map system = new HashMap<>();
+ system.put("os_name", System.getProperty("os.name", "unknown"));
+ system.put("os_version", System.getProperty("os.version", "unknown"));
+ system.put("os_arch", System.getProperty("os.arch", "unknown"));
+ system.put("java_version", System.getProperty("java.version", "unknown"));
+ system.put("java_vendor", System.getProperty("java.vendor", "unknown"));
+ system.put("available_processors", Runtime.getRuntime().availableProcessors());
+ system.put("total_memory_mb", Runtime.getRuntime().maxMemory() / (1024 * 1024));
+ info.put("system", system);
+
+ // User (from auth.json, tokens excluded)
+ Map user = new HashMap<>();
+ try {
+ Path authFile = Path.of(System.getProperty("user.home"), ".zernmc", "auth.json");
+ if (Files.exists(authFile)) {
+ String authContent = Files.readString(authFile, StandardCharsets.UTF_8);
+ JsonObject authJson = JsonParser.parseString(authContent).getAsJsonObject();
+ if (authJson.has("username")) user.put("username", authJson.get("username").getAsString());
+ if (authJson.has("uuid")) user.put("uuid", authJson.get("uuid").getAsString());
+ if (authJson.has("role")) user.put("role", authJson.get("role").getAsInt());
+ }
+ } catch (Exception ignored) {}
+ info.put("user", user);
+
+ return info;
+ }
+
+ private static void showCrashDialog(int exitCode, String stderr) {
+ Map crashInfo = collectCrashInfo(exitCode, stderr);
+ Gson gson = new GsonBuilder().setPrettyPrinting().create();
+ String fullReport = gson.toJson(crashInfo);
+
+ JFrame frame = new JFrame("ZernMC Launcher - Crash Report");
+ frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ frame.setSize(700, 520);
+ frame.setLocationRelativeTo(null);
+ frame.setResizable(true);
+
+ Color bgColor = new Color(0x0c, 0x0c, 0x12);
+ Color surfaceColor = new Color(0x16, 0x16, 0x1f);
+ Color accentColor = new Color(0xe9, 0x45, 0x60);
+ Color textColor = new Color(0xee, 0xee, 0xf0);
+ Color mutedColor = new Color(0x88, 0x88, 0x9a);
+
+ JPanel root = new JPanel(new BorderLayout());
+ root.setBackground(bgColor);
+ root.setBorder(BorderFactory.createEmptyBorder(16, 20, 16, 20));
+
+ // Title
+ JLabel titleLabel = new JLabel("ZernMC Launcher Crashed (code: " + exitCode + ")");
+ titleLabel.setFont(new Font("Segoe UI", Font.BOLD, 16));
+ titleLabel.setForeground(accentColor);
+ titleLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 12, 0));
+ root.add(titleLabel, BorderLayout.NORTH);
+
+ // Log area
+ JTextArea logArea = new JTextArea(fullReport);
+ logArea.setEditable(false);
+ logArea.setFont(new Font("Consolas", Font.PLAIN, 12));
+ logArea.setBackground(surfaceColor);
+ logArea.setForeground(textColor);
+ logArea.setCaretColor(textColor);
+ logArea.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
+
+ JScrollPane scrollPane = new JScrollPane(logArea);
+ scrollPane.setBorder(BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a)));
+ scrollPane.getViewport().setBackground(surfaceColor);
+ root.add(scrollPane, BorderLayout.CENTER);
+
+ // Buttons
+ JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 12));
+ buttonPanel.setOpaque(false);
+
+ JLabel statusLabel = new JLabel(" ");
+ statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ statusLabel.setForeground(mutedColor);
+ buttonPanel.add(statusLabel);
+
+ JButton openLogsBtn = new JButton("Open Logs Folder");
+ styleCrashButton(openLogsBtn, mutedColor, bgColor);
+ openLogsBtn.addActionListener(e -> {
+ try {
+ Desktop.getDesktop().open(logDir.toFile());
+ } catch (Exception ignored) {}
+ });
+ buttonPanel.add(openLogsBtn);
+
+ JButton sendBtn = new JButton("Send to Server");
+ styleCrashButton(sendBtn, new Color(0x45, 0xe9, 0x60), bgColor);
+ sendBtn.addActionListener(e -> {
+ sendBtn.setEnabled(false);
+ sendBtn.setText("Sending...");
+ new Thread(() -> {
+ boolean ok = sendCrashReport(crashInfo);
+ SwingUtilities.invokeLater(() -> {
+ if (ok) {
+ statusLabel.setForeground(new Color(0x45, 0xe9, 0x60));
+ statusLabel.setText("Crash report sent. Thank you!");
+ sendBtn.setText("Sent");
+ new Thread(() -> {
+ try { Thread.sleep(1200); } catch (InterruptedException ignored) {}
+ SwingUtilities.invokeLater(() -> {
+ frame.dispose();
+ System.exit(exitCode);
+ });
+ }).start();
+ } else {
+ statusLabel.setForeground(accentColor);
+ statusLabel.setText("Failed to send. Check logs folder.");
+ sendBtn.setEnabled(true);
+ sendBtn.setText("Send to Server");
+ }
+ });
+ }).start();
+ });
+ buttonPanel.add(sendBtn);
+
+ JButton closeBtn = new JButton("Close");
+ styleCrashButton(closeBtn, new Color(0x45, 0x60, 0xe9), bgColor);
+ closeBtn.addActionListener(e -> {
+ frame.dispose();
+ System.exit(exitCode);
+ });
+ buttonPanel.add(closeBtn);
+
+ root.add(buttonPanel, BorderLayout.SOUTH);
+
+ frame.setContentPane(root);
+ frame.setVisible(true);
+ frame.toFront();
+ }
+
+ private static void styleCrashButton(JButton btn, Color fg, Color bg) {
+ btn.setFont(new Font("Segoe UI", Font.PLAIN, 13));
+ btn.setForeground(fg);
+ btn.setBackground(bg);
+ btn.setBorderPainted(false);
+ btn.setFocusPainted(false);
+ btn.setContentAreaFilled(false);
+ btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+ btn.addMouseListener(new java.awt.event.MouseAdapter() {
+ public void mouseEntered(java.awt.event.MouseEvent e) { btn.setForeground(Color.WHITE); }
+ public void mouseExited(java.awt.event.MouseEvent e) { btn.setForeground(fg); }
+ });
+ }
+
+ private static boolean sendCrashReport(Map data) {
+ try {
+ Gson gson = new Gson();
+ String jsonBody = gson.toJson(data);
+ byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8);
+
+ URL url = new URL(BASE_URL + "/launcher/crash-report");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
+ conn.setRequestProperty("Accept", "application/json");
+ conn.setRequestProperty("Content-Length", String.valueOf(bodyBytes.length));
+ conn.setDoOutput(true);
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(10000);
+
+ try (java.io.OutputStream os = conn.getOutputStream()) {
+ os.write(bodyBytes);
+ os.flush();
+ }
+
+ int status = conn.getResponseCode();
+ conn.disconnect();
+ return status == 200;
+ } catch (Exception e) {
+ log("Crash report send failed: " + e.getMessage());
+ return false;
+ }
+ }
+
+ // ====================== SWING UI ======================
+
+ private static class BootstrapUI {
+ private final JFrame frame;
+ private final JLabel statusLabel;
+ private final JProgressBar progressBar;
+ private final JLabel titleLabel;
+ private final JLabel versionLabel;
+ private final JLabel speedLabel;
+ private final Color bgColor = new Color(0x0c, 0x0c, 0x12);
+ private final Color surfaceColor = new Color(0x16, 0x16, 0x1f);
+ private final Color accentColor = new Color(0xe9, 0x45, 0x60);
+ private final Color textColor = new Color(0xee, 0xee, 0xf0);
+ private final Color mutedColor = new Color(0x88, 0x88, 0x9a);
+
+ BootstrapUI() {
+ try {
+ UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
+ } catch (Exception ignored) {}
+
+ frame = new JFrame("ZernMC Launcher");
+ frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ frame.setSize(480, 280);
+ frame.setLocationRelativeTo(null);
+ frame.setResizable(false);
+ frame.setBackground(bgColor);
+ frame.setUndecorated(true);
+
+ JPanel root = new JPanel(new BorderLayout());
+ root.setBackground(bgColor);
+ root.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(new Color(0x2a, 0x2a, 0x3a), 1),
+ BorderFactory.createEmptyBorder(20, 24, 20, 24)
+ ));
+
+ // Title bar
+ JPanel titleBar = new JPanel(new BorderLayout());
+ titleBar.setOpaque(false);
+
+ JLabel brandLabel = new JLabel("ZernMC Launcher");
+ brandLabel.setFont(new Font("Segoe UI", Font.BOLD, 18));
+ brandLabel.setForeground(textColor);
+
+ JPanel titleControls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
+ titleControls.setOpaque(false);
+ JButton closeBtn = createTitleButton("\u2715");
+ closeBtn.addActionListener(e -> System.exit(0));
+ titleControls.add(closeBtn);
+
+ titleBar.add(brandLabel, BorderLayout.WEST);
+ titleBar.add(titleControls, BorderLayout.EAST);
+ root.add(titleBar, BorderLayout.NORTH);
+
+ // Center content
+ JPanel center = new JPanel();
+ center.setOpaque(false);
+ center.setLayout(new BoxLayout(center, BoxLayout.Y_AXIS));
+ center.add(Box.createVerticalStrut(16));
+
+ titleLabel = new JLabel("Initializing...");
+ titleLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
+ titleLabel.setForeground(mutedColor);
+ titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ center.add(titleLabel);
+ center.add(Box.createVerticalStrut(8));
+
+ versionLabel = new JLabel(" ");
+ versionLabel.setFont(new Font("Segoe UI", Font.PLAIN, 12));
+ versionLabel.setForeground(mutedColor);
+ versionLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ center.add(versionLabel);
+ center.add(Box.createVerticalStrut(16));
+
+ statusLabel = new JLabel("Starting...");
+ statusLabel.setFont(new Font("Segoe UI", Font.PLAIN, 13));
+ statusLabel.setForeground(textColor);
+ statusLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ center.add(statusLabel);
+ center.add(Box.createVerticalStrut(12));
+
+ progressBar = new JProgressBar(0, 100);
+ progressBar.setPreferredSize(new Dimension(400, 6));
+ progressBar.setMaximumSize(new Dimension(400, 6));
+ progressBar.setAlignmentX(Component.CENTER_ALIGNMENT);
+ progressBar.setBackground(new Color(0x2a, 0x2a, 0x3a));
+ progressBar.setForeground(accentColor);
+ progressBar.setBorderPainted(false);
+ progressBar.setValue(0);
+ progressBar.setUI(new BasicProgressBarUI() {
+ protected Color getSelectionBackground() { return accentColor; }
+ protected Color getSelectionForeground() { return accentColor; }
+ });
+ center.add(progressBar);
+ center.add(Box.createVerticalStrut(6));
+
+ speedLabel = new JLabel(" ");
+ speedLabel.setFont(new Font("Segoe UI", Font.PLAIN, 11));
+ speedLabel.setForeground(mutedColor);
+ speedLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ center.add(speedLabel);
+
+ root.add(center, BorderLayout.CENTER);
+
+ // Draggable frame
+ MouseAdapter dragAdapter = new MouseAdapter() {
+ private int x, y;
+ public void mousePressed(MouseEvent e) { x = e.getX(); y = e.getY(); }
+ public void mouseDragged(MouseEvent e) {
+ frame.setLocation(e.getXOnScreen() - x, e.getYOnScreen() - y);
+ }
+ };
+ root.addMouseListener(dragAdapter);
+ root.addMouseMotionListener(dragAdapter);
+
+ frame.setContentPane(root);
+ }
+
+ private JButton createTitleButton(String text) {
+ JButton btn = new JButton(text);
+ btn.setFont(new Font("Segoe UI", Font.PLAIN, 14));
+ btn.setForeground(mutedColor);
+ btn.setBackground(bgColor);
+ btn.setBorderPainted(false);
+ btn.setFocusPainted(false);
+ btn.setContentAreaFilled(false);
+ btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
+ btn.addMouseListener(new MouseAdapter() {
+ public void mouseEntered(MouseEvent e) { btn.setForeground(accentColor); }
+ public void mouseExited(MouseEvent e) { btn.setForeground(mutedColor); }
+ });
+ return btn;
+ }
+
+ void show() {
+ frame.setVisible(true);
+ frame.toFront();
+ }
+
+ void close() {
+ frame.dispose();
+ }
+
+ void setStatus(final String text) {
+ SwingUtilities.invokeLater(() -> statusLabel.setText(text));
+ }
+
+ void setProgress(final int current, final int total) {
+ SwingUtilities.invokeLater(() -> {
+ if (total > 0) {
+ int pct = (int) ((long) current * 100 / total);
+ progressBar.setValue(Math.min(pct, 100));
+ } else {
+ progressBar.setIndeterminate(true);
+ }
+ });
+ }
+
+ void setSpeedText(final String text) {
+ SwingUtilities.invokeLater(() -> speedLabel.setText(text));
+ }
+
+ void setVersionInfo(final String local, final String server) {
+ SwingUtilities.invokeLater(() ->
+ versionLabel.setText("v" + local + " \u2192 v" + server));
+ }
+
+ void setTitleText(final String text) {
+ SwingUtilities.invokeLater(() -> titleLabel.setText(text));
+ }
+ }
+}
diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml
index 41197c7..e79f340 100644
--- a/launcher/dependency-reduced-pom.xml
+++ b/launcher/dependency-reduced-pom.xml
@@ -3,9 +3,13 @@
4.0.0
me.sashegdev
ZernMCLauncher
- 1.0.7
+ 1.0.8
+
+ maven-surefire-plugin
+ 3.2.3
+
maven-shade-plugin
3.5.0
@@ -24,7 +28,7 @@
${project.version}
ZernMC Launcher
SashegDev
- Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)
+ Samopisnui Minecraft-launcher. by SashegDev
https://github.com/SashegDev/launcher
@@ -45,10 +49,11 @@
launch4j
- ../server/builds/ZernMCLauncher.exe
+ ../server/builds/ZernMCLauncher-${project.version}.exe
../server/builds/ZernMCLauncher.jar
console
false
+ me.sashegdev.zernmc.launcher.Bootstrap
jre21
21
@@ -56,13 +61,13 @@
${project.version}.0
${project.version}
- ZernMC Launcher — самописный Minecraft лаунчер
+ ZernMC Launcher — just a Minecraft launcher
${project.version}.0
${project.version}
ZernMC Launcher
ZernMC(SashegDev)
ZernMCLauncher
- ZernMCLauncher.exe
+ ZernMCLauncher-${project.version}.exe
@@ -80,9 +85,15 @@
${project.version}
+
+
-
+
+
+
+
+
@@ -91,10 +102,53 @@
+
+
+ global
+
+ ZernMC Launcher
+ global
+ http://87.120.187.36:1582
+
+
+
+ zernmc
+
+ ZernMC Private Launcher
+ zernmc
+ http://87.120.187.36:1582
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.1
+ test
+
+
+ junit-jupiter-api
+ org.junit.jupiter
+
+
+ junit-jupiter-params
+ org.junit.jupiter
+
+
+ junit-jupiter-engine
+ org.junit.jupiter
+
+
+
+
- 21
+ ZernMC Launcher - just a minimalistic launcher by SashegDev
me.sashegdev.zernmc.launcher.Main
21
+ ZernMC
+ 21
UTF-8
+ 2026
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/pom.xml b/launcher/launcher/pom.xml
new file mode 100644
index 0000000..73cd02b
--- /dev/null
+++ b/launcher/launcher/pom.xml
@@ -0,0 +1,412 @@
+
+
+ 4.0.0
+
+
+ me.sashegdev
+ ZernMCLauncher
+ ${revision}
+
+
+ zernmclauncher
+ jar
+
+ ZernMC Launcher
+ Main launcher module with JFX UI
+
+
+
+
+ org.apache.httpcomponents
+ httpclient
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ com.google.code.gson
+ gson
+
+
+ org.json
+ json
+
+
+
+
+ org.fusesource.jansi
+ jansi
+
+
+ org.jline
+ jline
+
+
+ me.tongfei
+ progressbar
+
+
+
+
+ commons-io
+ commons-io
+
+
+
+
+ org.openjfx
+ javafx-controls
+ 23.0.1
+ win
+ provided
+
+
+ org.openjfx
+ javafx-web
+ 23.0.1
+ win
+ provided
+
+
+ org.openjfx
+ javafx-graphics
+ 23.0.1
+ win
+ provided
+
+
+ org.openjfx
+ javafx-base
+ 23.0.1
+ win
+ provided
+
+
+ org.openjfx
+ javafx-media
+ 23.0.1
+ win
+ provided
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.1
+ test
+
+
+
+
+
+
+ src/resources
+ assets
+
+ ui/**
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+ ../../server/builds/zernmclauncher.jar
+
+
+ me.sashegdev.zernmc.launcher.Main
+
+ ${project.version}.${hotfix}
+ ZernMC Launcher
+
+
+
+
+
+
+
+
+
+
+ com.akathist.maven.plugins.launch4j
+ launch4j-maven-plugin
+ 2.5.0
+
+
+
+ l4j-gui
+ package
+
+ launch4j
+
+
+ ../../server/builds/zernmc.exe
+ ../../server/builds/zernmc-bootstrap.jar
+ ${project.basedir}/src/main/icons/zernmc.ico
+ gui
+ false
+
+ me.sashegdev.zernmc.launcher.Bootstrap
+ false
+
+ --jfx
+
+ lib/jre21
+ 21
+
+
+ ${project.version}.${hotfix}
+ ${project.version}.${hotfix}
+ ZernMC Launcher
+ Copyright (c) 2023-2026 ZernMC
+ ${project.version}.${hotfix}
+ ${project.version}.${hotfix}
+ ZernMC
+ ZernMC
+ ZernMC
+ zernmc
+ zernmc.exe
+
+
+
+
+
+
+ l4j-cli
+ package
+
+ launch4j
+
+
+ ../../server/builds/zernmc-cli.exe
+ ../../server/builds/zernmc-bootstrap.jar
+ console
+ false
+
+ me.sashegdev.zernmc.launcher.Bootstrap
+ false
+
+ --cli
+
+ lib/jre21
+ 21
+
+
+ ${project.version}.${hotfix}
+ ${project.version}.${hotfix}
+ ZernMC Launcher CLI
+ Copyright (c) 2023-2026 ZernMC
+ ${project.version}.${hotfix}
+ ${project.version}.${hotfix}
+ ZernMC CLI
+ ZernMC
+ ZernMC
+ zernmc-cli
+ zernmc-cli.exe
+
+
+
+
+
+
+ l4j-cli-jfx
+ package
+
+ launch4j
+
+
+ ../../server/builds/zernmc-cli-jfx.exe
+ ../../server/builds/zernmc-bootstrap.jar
+ console
+ false
+
+ me.sashegdev.zernmc.launcher.Bootstrap
+ false
+
+ --jfx
+
+ lib/jre21
+ 21
+
+
+ ${project.version}.${hotfix}
+ ${project.version}.${hotfix}
+ ZernMC Launcher CLI + JFX
+ Copyright (c) 2023-2026 ZernMC
+ ${project.version}.${hotfix}
+ ${project.version}.${hotfix}
+ ZernMC CLI JFX
+ ZernMC
+ ZernMC
+ zernmc-cli-jfx
+ zernmc-cli-jfx.exe
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+ run
+
+
+ ${project.version}.${hotfix}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ZernMC Launcher
+
+Files:
+- zernmc.exe - Main launcher with GUI (default)
+- zernmc-cli.exe - CLI version (console, architecture-independent)
+- zernmc-cli-jfx.exe - CLI + JFX UI (console visible + JavaFX frontend)
+
+How to use:
+ Just run zernmc.exe (or zernmc-cli.exe / zernmc-cli-jfx.exe for CLI)
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/launcher/launcher/src/main/icons/zernmc.ico b/launcher/launcher/src/main/icons/zernmc.ico
new file mode 100644
index 0000000..6f60d98
Binary files /dev/null and b/launcher/launcher/src/main/icons/zernmc.ico differ
diff --git a/launcher/launcher/src/main/icons/zernmc.svg b/launcher/launcher/src/main/icons/zernmc.svg
new file mode 100644
index 0000000..11a1e02
--- /dev/null
+++ b/launcher/launcher/src/main/icons/zernmc.svg
@@ -0,0 +1,10 @@
+
diff --git a/launcher/launcher/src/main/icons/zernmc_master.png b/launcher/launcher/src/main/icons/zernmc_master.png
new file mode 100644
index 0000000..cc59ca3
Binary files /dev/null and b/launcher/launcher/src/main/icons/zernmc_master.png differ
diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java
new file mode 100644
index 0000000..96f1228
--- /dev/null
+++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Bootstrap.java
@@ -0,0 +1,337 @@
+package me.sashegdev.zernmc.launcher;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Stream;
+
+public class Bootstrap {
+ private static final String VERSION_FILE = "build.version";
+ private static final String JAR_NAME = "ZernMCLauncher.jar";
+ private static final String BASE_URL = "https://api.zernmc.ru";
+
+ private static Path baseDir;
+ private static Path logDir;
+
+ public static void main(String[] args) throws Exception {
+ baseDir = Paths.get("").toAbsolutePath();
+ logDir = baseDir.resolve("logs");
+ Files.createDirectories(logDir);
+
+ log("=== ZernMC Launcher ===");
+
+ List argList = Arrays.asList(args);
+ boolean cliMode = argList.contains("--cli");
+ boolean jfxMode = !cliMode;
+
+ cleanupStaleUpdates();
+
+ String currentVersion = readCurrentVersion();
+ String serverVersion = getServerVersion();
+
+ log("Local version: " + currentVersion);
+ log("Server version: " + serverVersion);
+
+ if (isNewer(serverVersion, currentVersion)) {
+ log("Update available!");
+ updateJar(serverVersion);
+
+ Path ownExe = getOwnExe();
+ if (ownExe != null) {
+ log("Self-updating bootstrap...");
+ selfUpdate(ownExe);
+ return;
+ }
+
+ Files.writeString(baseDir.resolve(VERSION_FILE), serverVersion);
+ log("Updated to v" + serverVersion);
+ } else {
+ log("Version is up to date");
+ }
+
+ if (jfxMode) {
+ launchJFX();
+ } else {
+ launchCLI();
+ }
+ }
+
+ private static void cleanupStaleUpdates() {
+ try (Stream files = Files.list(baseDir)) {
+ files.filter(p -> p.toString().endsWith(".update"))
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ log("Cleaned stale: " + p.getFileName());
+ } catch (Exception ignored) {}
+ });
+ } catch (Exception ignored) {}
+ }
+
+ private static void updateJar(String newVersion) throws Exception {
+ Path jarFile = baseDir.resolve(JAR_NAME);
+ Path jarUpdate = baseDir.resolve(JAR_NAME + ".update");
+
+ if (Files.exists(jarFile)) {
+ try {
+ Files.move(jarFile, jarUpdate, StandardCopyOption.REPLACE_EXISTING);
+ log("Renamed " + JAR_NAME + " → " + JAR_NAME + ".update");
+ } catch (Exception e) {
+ log("Could not rename " + JAR_NAME + ": " + e.getMessage());
+ }
+ }
+
+ downloadFile(BASE_URL + "/launcher/download/jar", jarFile);
+ log("Downloaded new " + JAR_NAME);
+
+ try {
+ Files.deleteIfExists(jarUpdate);
+ log("Removed " + JAR_NAME + ".update");
+ } catch (Exception e) {
+ log("Could not remove " + JAR_NAME + ".update: " + e.getMessage());
+ }
+
+ Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
+ }
+
+ private static void selfUpdate(Path exePath) throws Exception {
+ String exeName = exePath.getFileName().toString();
+ Path exeUpdate = baseDir.resolve(exeName + ".update");
+
+ try {
+ Files.move(exePath, exeUpdate, StandardCopyOption.REPLACE_EXISTING);
+ log("Renamed " + exeName + " → " + exeName + ".update");
+ } catch (Exception e) {
+ log("Could not rename " + exeName + ": " + e.getMessage());
+ return;
+ }
+
+ downloadFile(BASE_URL + "/launcher/download/exe", exePath);
+
+ log("Launching new " + exeName + "...");
+ new ProcessBuilder(exePath.toAbsolutePath().toString())
+ .directory(baseDir.toFile())
+ .inheritIO()
+ .start();
+
+ Thread.sleep(500);
+ log("Exiting old process");
+ System.exit(0);
+ }
+
+ private static Path getOwnExe() {
+ try {
+ String cmd = ProcessHandle.current().info().command().orElse(null);
+ if (cmd != null) {
+ Path p = Paths.get(cmd);
+ String name = p.getFileName().toString().toLowerCase();
+ if (name.contains("zernmc")) return p;
+ }
+ } catch (Exception ignored) {}
+
+ for (String name : Arrays.asList("zernmc.exe", "zernmc-cli.exe")) {
+ Path p = baseDir.resolve(name);
+ if (Files.exists(p)) return p;
+ }
+ return null;
+ }
+
+ private static void downloadFile(String urlStr, Path target) throws Exception {
+ URL url = new URL(urlStr);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+
+ if (conn.getResponseCode() == 200) {
+ try (InputStream in = conn.getInputStream();
+ OutputStream out = new FileOutputStream(target.toFile())) {
+ byte[] buf = new byte[8192];
+ int len;
+ long total = 0;
+ while ((len = in.read(buf)) > 0) {
+ out.write(buf, 0, len);
+ total += len;
+ System.out.print("\rDownloaded: " + (total / 1024 / 1024) + " MB");
+ }
+ }
+ System.out.println();
+ } else {
+ throw new IOException("Server returned code: " + conn.getResponseCode());
+ }
+ }
+
+ private static String readCurrentVersion() {
+ Path f = baseDir.resolve(VERSION_FILE);
+ try {
+ if (Files.exists(f)) return Files.readString(f).trim();
+ } catch (Exception ignored) {}
+ return "0.0.0";
+ }
+
+ private static String getServerVersion() {
+ try {
+ URL url = new URL(BASE_URL + "/launcher/version");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ if (conn.getResponseCode() == 200) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
+ String line = br.readLine();
+ if (line != null && line.contains("version")) {
+ return line.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
+ }
+ }
+ }
+ } catch (Exception ignored) {}
+ return "unknown";
+ }
+
+ private static boolean isNewer(String server, String current) {
+ try {
+ String[] sa = server.split("\\.");
+ String[] ca = current.split("\\.");
+ int max = Math.max(sa.length, ca.length);
+ for (int i = 0; i < max; i++) {
+ int sv = i < sa.length ? Integer.parseInt(sa[i]) : 0;
+ int cv = i < ca.length ? Integer.parseInt(ca[i]) : 0;
+ if (sv > cv) return true;
+ if (sv < cv) return false;
+ }
+ return false;
+ } catch (Exception ignored) {}
+ return false;
+ }
+
+ private static void log(String msg) {
+ String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
+ System.out.println(entry);
+ try {
+ Files.writeString(logDir.resolve("latest.log"), entry + "\n",
+ StandardOpenOption.CREATE, StandardOpenOption.APPEND);
+ } catch (Exception ignored) {}
+ }
+
+ private static void launchJFX() throws Exception {
+ Path javaBin = findJava();
+ Path jarPath = baseDir.resolve(JAR_NAME);
+
+ log("Starting JFX mode...");
+ log("Java: " + javaBin);
+ log("JAR: " + jarPath);
+
+ List cmd = new ArrayList<>();
+ cmd.add(javaBin.toAbsolutePath().toString());
+ cmd.add("-Dfile.encoding=UTF-8");
+ cmd.add("-Dsun.stdout.encoding=UTF-8");
+ cmd.add("-Dsun.stderr.encoding=UTF-8");
+ cmd.add("-jar");
+ cmd.add(jarPath.toAbsolutePath().toString());
+ cmd.add("--jfx");
+
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(baseDir.toFile());
+
+ if (System.getProperty("os.name").toLowerCase().contains("windows")) {
+ pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
+ }
+
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+
+ Thread outputThread = new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ System.out.println(line);
+ }
+ } catch (Exception ignored) {}
+ });
+ outputThread.start();
+
+ int code = p.waitFor();
+ try { outputThread.interrupt(); } catch (Exception ignored) {}
+ log("Exited with code: " + code);
+ System.exit(code);
+ }
+
+ private static void launchCLI() throws Exception {
+ Path javaBin = findJava();
+ Path jarPath = baseDir.resolve(JAR_NAME);
+
+ log("Starting CLI mode...");
+ log("Java: " + javaBin);
+ log("JAR: " + jarPath);
+
+ List cmd = new ArrayList<>();
+ cmd.add(javaBin.toAbsolutePath().toString());
+ cmd.add("-Dfile.encoding=UTF-8");
+ cmd.add("-Dsun.stdout.encoding=UTF-8");
+ cmd.add("-Dsun.stderr.encoding=UTF-8");
+ cmd.add("-jar");
+ cmd.add(jarPath.toAbsolutePath().toString());
+ cmd.add("--cli");
+
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(baseDir.toFile());
+
+ if (System.getProperty("os.name").toLowerCase().contains("windows")) {
+ pb.environment().put("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
+ }
+
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+
+ Thread outputThread = new Thread(() -> {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ System.out.println(line);
+ }
+ } catch (Exception ignored) {}
+ });
+ outputThread.start();
+
+ int code = p.waitFor();
+ try { outputThread.interrupt(); } catch (Exception ignored) {}
+ log("Exited with code: " + code);
+ System.exit(code);
+ }
+
+ private static Path findJava() {
+ String os = System.getProperty("os.name").toLowerCase();
+ String javaExe = os.contains("windows") ? "java.exe" : "java";
+
+ Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
+
+ if (!Files.exists(javaBin)) {
+ javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
+ }
+
+ if (!Files.exists(javaBin)) {
+ try {
+ Process p = new ProcessBuilder("which", javaExe).start();
+ if (p.waitFor() == 0) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
+ String path = br.readLine();
+ if (path != null) {
+ javaBin = Paths.get(path.trim());
+ }
+ }
+ }
+ } catch (Exception ignored) {}
+ }
+
+ if (!Files.exists(javaBin)) {
+ throw new RuntimeException("Java not found. Make sure jre21 is present in the launcher folder or Java is installed on the system");
+ }
+
+ return javaBin;
+ }
+}
diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java
new file mode 100644
index 0000000..1c0339b
--- /dev/null
+++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/Main.java
@@ -0,0 +1,137 @@
+package me.sashegdev.zernmc.launcher;
+
+import me.sashegdev.zernmc.launcher.api.LauncherAPI;
+import me.sashegdev.zernmc.launcher.auth.AuthManager;
+import me.sashegdev.zernmc.launcher.menu.*;
+import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
+import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
+import me.sashegdev.zernmc.launcher.utils.*;
+import java.io.IOException;
+import java.util.List;
+
+public class Main {
+
+ private static final String CURRENT_VERSION = Version.getCurrentVersion();
+ private static final LauncherAPI api = new LauncherAPI();
+
+ public static void main(String[] args) throws IOException {
+ System.setProperty("file.encoding", "UTF-8");
+ System.setProperty("sun.stderr.encoding", "UTF-8");
+ System.setProperty("sun.stdout.encoding", "UTF-8");
+ System.setProperty("java.stdout.encoding", "UTF-8");
+ System.setProperty("java.stderr.encoding", "UTF-8");
+ System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
+
+ LauncherLogger.init();
+
+ if (System.getProperty("os.name").toLowerCase().contains("windows")) {
+ try {
+ new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
+ } catch (Exception ignored) {}
+ }
+
+ ZAnsi.install();
+ LauncherLogger.info("Starting ZernMC Launcher " + CURRENT_VERSION);
+
+ List argList = List.of(args);
+ 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;
+ }
+
+ System.out.print("\033[H\033[2J");
+ System.out.println(ZAnsi.brightGreen("Welcome to ZernMC Launcher " + CURRENT_VERSION));
+
+ startCLI();
+ }
+
+ private static void launchJFX() {
+ try {
+ System.setProperty("javafx.runtime.version", "23.0.1");
+
+ JFXLauncher.main(new String[]{});
+ } catch (Exception e) {
+ System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
+ if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
+ System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
+ System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
+ }
+ e.printStackTrace();
+ System.exit(1);
+ }
+ }
+
+ private static void startCLI() throws IOException {
+ DomainSelector.selectBest();
+ ZHttpClient.checkAllServicesOnStartup(true);
+
+ System.out.println(ZAnsi.cyan("Checking authorization..."));
+ var sessionResponse = api.checkSession();
+
+ if (!sessionResponse.isSuccess()) {
+ LoginMenu loginMenu = new LoginMenu();
+ boolean loggedIn = loginMenu.show();
+ if (!loggedIn) {
+ System.out.println(ZAnsi.yellow("Goodbye!"));
+ ZAnsi.uninstall();
+ System.exit(0);
+ }
+ } else {
+ var sessionInfo = sessionResponse.getData();
+ System.out.println(ZAnsi.brightGreen("Welcome back, " + sessionInfo.getUsername() + "!"));
+ }
+
+ System.out.println(ZAnsi.cyan("Starting CLI mode..."));
+
+ try {
+ mainLoop();
+ } catch (Exception e) {
+ System.err.println(ZAnsi.brightRed("Critical error: " + e.getMessage()));
+ e.printStackTrace();
+ } finally {
+ ZAnsi.uninstall();
+ }
+ }
+
+ private static void mainLoop() throws Exception {
+ globalFlow();
+ }
+
+ private static void globalFlow() throws Exception {
+ while (true) {
+ ConsoleUtils.clearScreen();
+ System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
+
+ List options = List.of(
+ "Launch Game",
+ "Check Updates",
+ "Settings",
+ "Server Connection Check",
+ "Exit"
+ );
+
+ ArrowMenu menu = new ArrowMenu("Main Menu", options);
+ int choice = menu.show();
+
+ if (choice == -1 || choice == 4) {
+ System.out.println(ZAnsi.yellow("Goodbye!"));
+ break;
+ }
+
+ switch (choice) {
+ case 0 -> new LaunchMenu().show();
+ case 1 -> new UpdateMenu().show();
+ case 2 -> new SettingsMenu().show();
+ case 3 -> new ServerCheckMenu().show();
+ }
+ }
+ }
+}
diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/ApiResponse.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/ApiResponse.java
new file mode 100644
index 0000000..c2337ae
--- /dev/null
+++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/ApiResponse.java
@@ -0,0 +1,33 @@
+package me.sashegdev.zernmc.launcher.api;
+
+public class ApiResponse {
+ private boolean success;
+ private T data;
+ private String error;
+
+ public ApiResponse(boolean success, T data, String error) {
+ this.success = success;
+ this.data = data;
+ this.error = error;
+ }
+
+ public static ApiResponse success(T data) {
+ return new ApiResponse<>(true, data, null);
+ }
+
+ public static ApiResponse error(String error) {
+ return new ApiResponse<>(false, null, error);
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public String getError() {
+ return error;
+ }
+}
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
new file mode 100644
index 0000000..66b6fa2
--- /dev/null
+++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/LauncherAPI.java
@@ -0,0 +1,199 @@
+package me.sashegdev.zernmc.launcher.api;
+
+import me.sashegdev.zernmc.launcher.api.auth.AuthService;
+import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
+import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
+import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
+import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class LauncherAPI {
+
+ private final AuthService authService;
+ private final InstanceService instanceService;
+ private final LaunchService launchService;
+
+ public LauncherAPI() {
+ this.authService = new AuthService();
+ this.instanceService = new InstanceService();
+ this.launchService = new LaunchService();
+ }
+
+ public AuthService auth() {
+ return authService;
+ }
+
+ public InstanceService instances() {
+ return instanceService;
+ }
+
+ public LaunchService launch() {
+ return launchService;
+ }
+
+ public boolean isLoggedIn() {
+ return authService.isLoggedIn();
+ }
+
+ public String getCurrentUsername() {
+ return authService.getCurrentUsername();
+ }
+
+ public ApiResponse checkSession() {
+ return authService.checkSession();
+ }
+
+ public ApiResponse login(String username, String password) {
+ return authService.login(username, password);
+ }
+
+ public ApiResponse logout() {
+ return authService.logout();
+ }
+
+ public ApiResponse activatePass(String passCode) {
+ return authService.activatePass(passCode);
+ }
+
+ public ApiResponse register(String username, String password) {
+ return authService.register(username, password);
+ }
+
+ public ApiResponse> getAllInstances() {
+ return instanceService.getAllInstances();
+ }
+
+ public ApiResponse getLaunchInfo(String instanceName) {
+ return launchService.getLaunchInfo(instanceName);
+ }
+
+ public ApiResponse prepareLaunch(String instanceName) {
+ return launchService.prepareLaunch(instanceName);
+ }
+
+ public ApiResponse launch(String instanceName) {
+ return launchService.launch(instanceName);
+ }
+
+ public ApiResponse> getMCVersions() {
+ try {
+ org.json.JSONObject manifest = ZHttpClient.getMojangVersionManifest();
+ org.json.JSONArray versions = manifest.getJSONArray("versions");
+ List mcVersions = new ArrayList<>();
+ for (int i = 0; i < versions.length(); i++) {
+ mcVersions.add(versions.getJSONObject(i).getString("id"));
+ }
+ return ApiResponse.success(mcVersions);
+ } catch (Exception e) {
+ LauncherLogger.warn("[API] MC versions fetch failed: " + e.getMessage());
+ }
+ return ApiResponse.error("Failed to load Minecraft versions");
+ }
+
+ public ApiResponse> getLoaderVersions(String mcVersion, String loader) {
+ try {
+ List versions = new ArrayList<>();
+
+ switch (loader.toLowerCase()) {
+ case "fabric":
+ versions = ZHttpClient.getFabricLoaderVersions();
+ break;
+ case "forge":
+ String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
+ int idx = 0;
+ while ((idx = xml.indexOf("", idx)) != -1) {
+ int start = idx + 9;
+ int end = xml.indexOf("", start);
+ if (end == -1) break;
+ String fullVersion = xml.substring(start, end).trim();
+ if (fullVersion.startsWith(mcVersion + "-")) {
+ versions.add(fullVersion.substring(mcVersion.length() + 1));
+ }
+ idx = end;
+ }
+ versions.sort(LauncherAPI::compareVersions);
+ break;
+ case "neoforge":
+ String neoforgeXml = ZHttpClient.downloadString("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
+ int neoidx = 0;
+ while ((neoidx = neoforgeXml.indexOf("", neoidx)) != -1) {
+ int start = neoidx + 9;
+ int end = neoforgeXml.indexOf("", start);
+ if (end == -1) break;
+ String fullVersion = neoforgeXml.substring(start, end).trim();
+ if (isNeoForgeCompatible(fullVersion, mcVersion)) {
+ versions.add(fullVersion);
+ }
+ neoidx = end;
+ }
+ versions.sort(LauncherAPI::compareVersions);
+ break;
+ default:
+ break;
+ }
+ return ApiResponse.success(versions);
+ } catch (Exception e) {
+ LauncherLogger.warn("[API] Loader versions fetch failed: " + e.getMessage());
+ return ApiResponse.error("Failed to load loader versions");
+ }
+ }
+
+ private static int compareVersions(String a, String b) {
+ String[] partsA = a.split("\\.");
+ String[] partsB = b.split("\\.");
+ int max = Math.max(partsA.length, partsB.length);
+ for (int i = 0; i < max; i++) {
+ try {
+ int numA = i < partsA.length ? Integer.parseInt(partsA[i]) : 0;
+ int numB = i < partsB.length ? Integer.parseInt(partsB[i]) : 0;
+ if (numA != numB) return Integer.compare(numB, numA);
+ } catch (NumberFormatException ignored) {}
+ }
+ return 0;
+ }
+
+ private boolean isNeoForgeCompatible(String version, String mcVersion) {
+ if (mcVersion.startsWith("1.21")) {
+ return version.contains("1.21") && !version.contains("1.20");
+ } else if (mcVersion.startsWith("1.20") && !mcVersion.equals("1.20")) {
+ return version.contains("1.20.4") || version.contains("1.20.5") || version.contains("1.20.6");
+ }
+ return false;
+ }
+
+ public ApiResponse>> getZernMCPacks() {
+ try {
+ String token = authService.getCurrentToken();
+ if (token == null) {
+ LauncherLogger.warn("getZernMCPacks: not logged in");
+ return ApiResponse.error("Not logged in");
+ }
+
+ String response = ZHttpClient.get("/packs");
+ org.json.JSONObject root = new org.json.JSONObject(response);
+ org.json.JSONArray arr = root.optJSONArray("packs");
+ List